1use std::sync::Arc;
2
3use axum::extract::DefaultBodyLimit;
4use rmcp::transport::streamable_http_server::session::local::LocalSessionManager;
5use rmcp::transport::streamable_http_server::{StreamableHttpServerConfig, StreamableHttpService};
6
7use crate::auth;
8use crate::mcp_handler::VictauriBrowserHandler;
9
10pub fn build_app(handler: VictauriBrowserHandler, auth_token: Option<String>) -> axum::Router {
15 build_app_full(handler, auth_token, None)
16}
17
18pub fn build_app_full(
20 handler: VictauriBrowserHandler,
21 auth_token: Option<String>,
22 rate_limiter: Option<Arc<auth::RateLimiterState>>,
23) -> axum::Router {
24 let rest = rest_routes(handler.clone());
25
26 let mcp_handler = handler.clone();
27 let mcp_service = StreamableHttpService::new(
28 move || Ok(mcp_handler.clone()),
29 Arc::new(LocalSessionManager::default()),
30 StreamableHttpServerConfig::default(),
31 );
32
33 let info_handler_ref = handler.clone();
34 let info_auth = auth_token.is_some();
35
36 let auth_state = Arc::new(auth::AuthState {
37 token: auth_token.clone(),
38 });
39
40 let mut router = axum::Router::new()
41 .route_service("/mcp", mcp_service)
42 .nest("/api/tools", rest)
43 .route(
44 "/info",
45 axum::routing::get(move || {
46 let h = info_handler_ref.clone();
47 async move {
48 axum::Json(serde_json::json!({
49 "name": "victauri-browser",
50 "version": env!("CARGO_PKG_VERSION"),
51 "protocol": "mcp",
52 "mode": "browser",
53 "tabs": h.tab_count().await,
54 "auth_required": info_auth,
55 }))
56 }
57 }),
58 );
59
60 if auth_token.is_some() {
61 router = router.layer(axum::middleware::from_fn_with_state(
62 auth_state,
63 auth::require_auth,
64 ));
65 }
66
67 let limiter = rate_limiter.unwrap_or_else(auth::default_rate_limiter);
68 router = router.layer(axum::middleware::from_fn_with_state(
69 limiter,
70 auth::rate_limit,
71 ));
72
73 router
74 .route(
75 "/health",
76 axum::routing::get(|| async { axum::Json(serde_json::json!({"status": "ok"})) }),
77 )
78 .layer(DefaultBodyLimit::max(2 * 1024 * 1024))
79 .layer(axum::middleware::from_fn(auth::security_headers))
80 .layer(axum::middleware::from_fn(auth::origin_guard))
81 .layer(axum::middleware::from_fn(auth::dns_rebinding_guard))
82}
83
84fn rest_routes(handler: VictauriBrowserHandler) -> axum::Router {
85 let list_handler = handler.clone();
86
87 axum::Router::new()
88 .route(
89 "/",
90 axum::routing::get(move || {
91 let h = list_handler.clone();
92 async move { axum::Json(h.list_tools()) }
93 }),
94 )
95 .route(
96 "/{name}",
97 axum::routing::post(move |path, body| execute_tool(handler, path, body)),
98 )
99}
100
101async fn execute_tool(
102 handler: VictauriBrowserHandler,
103 axum::extract::Path(name): axum::extract::Path<String>,
104 axum::Json(args): axum::Json<serde_json::Value>,
105) -> axum::Json<serde_json::Value> {
106 match handler.execute_tool(&name, args).await {
107 Ok(result) => axum::Json(serde_json::json!({"result": result})),
108 Err(e) => axum::Json(serde_json::json!({"error": e})),
109 }
110}
111
112#[cfg(test)]
113mod tests {
114 use super::*;
115 use crate::bridge_dispatch::BridgeDispatch;
116 use crate::tab_state::TabManager;
117 use axum::body::Body;
118 use http_body_util::BodyExt;
119 use std::sync::Arc;
120 use tower::ServiceExt;
121
122 fn make_app(auth: Option<String>) -> axum::Router {
123 let tab_mgr = Arc::new(TabManager::new());
124 let dispatch = Arc::new(BridgeDispatch::new(tokio::io::stdout()));
125 let handler = VictauriBrowserHandler::new(tab_mgr, dispatch);
126 build_app(handler, auth)
127 }
128
129 fn req(uri: &str) -> axum::http::request::Builder {
130 axum::http::Request::builder()
131 .uri(uri)
132 .header("host", "localhost")
133 }
134
135 async fn get_json(
136 app: axum::Router,
137 path: &str,
138 ) -> (axum::http::StatusCode, serde_json::Value) {
139 let req = axum::http::Request::builder()
140 .uri(path)
141 .header("host", "localhost")
142 .body(Body::empty())
143 .unwrap();
144 let resp = app.oneshot(req).await.unwrap();
145 let status = resp.status();
146 let body = resp.into_body().collect().await.unwrap().to_bytes();
147 let json: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default();
148 (status, json)
149 }
150
151 async fn post_json(
152 app: axum::Router,
153 path: &str,
154 body: serde_json::Value,
155 auth: Option<&str>,
156 ) -> (axum::http::StatusCode, serde_json::Value) {
157 let mut req = axum::http::Request::builder()
158 .method("POST")
159 .uri(path)
160 .header("host", "localhost")
161 .header("content-type", "application/json");
162 if let Some(token) = auth {
163 req = req.header("authorization", format!("Bearer {token}"));
164 }
165 let req = req
166 .body(Body::from(serde_json::to_vec(&body).unwrap()))
167 .unwrap();
168 let resp = app.oneshot(req).await.unwrap();
169 let status = resp.status();
170 let body = resp.into_body().collect().await.unwrap().to_bytes();
171 let json: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default();
172 (status, json)
173 }
174
175 #[test]
176 fn router_builds_without_auth() {
177 let _router = make_app(None);
178 }
179
180 #[test]
181 fn router_builds_with_auth() {
182 let _router = make_app(Some("test-token".to_string()));
183 }
184
185 #[tokio::test]
186 async fn health_returns_ok() {
187 let (status, json) = get_json(make_app(None), "/health").await;
188 assert_eq!(status, 200);
189 assert_eq!(json["status"], "ok");
190 }
191
192 #[tokio::test]
193 async fn info_returns_metadata() {
194 let (status, json) = get_json(make_app(None), "/info").await;
195 assert_eq!(status, 200);
196 assert_eq!(json["name"], "victauri-browser");
197 assert_eq!(json["protocol"], "mcp");
198 assert_eq!(json["mode"], "browser");
199 assert_eq!(json["tabs"], 0);
200 }
201
202 #[tokio::test]
203 async fn tool_list_returns_20() {
204 let (status, json) = get_json(make_app(None), "/api/tools").await;
205 assert_eq!(status, 200);
206 assert_eq!(json.as_array().unwrap().len(), 20);
207 }
208
209 #[tokio::test]
210 async fn plugin_info_via_rest() {
211 let (status, json) = post_json(
212 make_app(None),
213 "/api/tools/get_plugin_info",
214 serde_json::json!({}),
215 None,
216 )
217 .await;
218 assert_eq!(status, 200);
219 assert_eq!(json["result"]["name"], "victauri-browser");
220 assert_eq!(json["result"]["tool_count"], 20);
221 }
222
223 #[tokio::test]
224 async fn tabs_list_empty_via_rest() {
225 let (status, json) = post_json(
226 make_app(None),
227 "/api/tools/tabs",
228 serde_json::json!({"action": "list"}),
229 None,
230 )
231 .await;
232 assert_eq!(status, 200);
233 assert!(json["result"].as_array().unwrap().is_empty());
234 }
235
236 #[tokio::test]
237 async fn unknown_tool_returns_error() {
238 let (status, json) = post_json(
239 make_app(None),
240 "/api/tools/nonexistent",
241 serde_json::json!({}),
242 None,
243 )
244 .await;
245 assert_eq!(status, 200);
246 assert!(json["error"].as_str().unwrap().contains("unknown tool"));
247 }
248
249 #[tokio::test]
250 async fn auth_blocks_without_token() {
251 let app = make_app(Some("secret-token".to_string()));
252 let (status, _) = get_json(app, "/info").await;
253 assert_eq!(status, 401);
254 }
255
256 #[tokio::test]
257 async fn auth_passes_with_correct_token() {
258 let token = "secret-token";
259 let app = make_app(Some(token.to_string()));
260 let req = req("/info")
261 .header("authorization", format!("Bearer {token}"))
262 .body(Body::empty())
263 .unwrap();
264 let resp = app.oneshot(req).await.unwrap();
265 assert_eq!(resp.status(), 200);
266 }
267
268 #[tokio::test]
269 async fn health_bypasses_auth() {
270 let app = make_app(Some("secret-token".to_string()));
271 let (status, json) = get_json(app, "/health").await;
272 assert_eq!(status, 200);
273 assert_eq!(json["status"], "ok");
274 }
275
276 #[tokio::test]
277 async fn security_headers_present() {
278 let app = make_app(None);
279 let req = req("/health").body(Body::empty()).unwrap();
280 let resp = app.oneshot(req).await.unwrap();
281 assert_eq!(
282 resp.headers().get("x-content-type-options").unwrap(),
283 "nosniff"
284 );
285 assert_eq!(resp.headers().get("cache-control").unwrap(), "no-store");
286 }
287
288 #[tokio::test]
289 async fn non_local_origin_blocked() {
290 let app = make_app(None);
291 let req = req("/health")
292 .header("origin", "https://evil.com")
293 .body(Body::empty())
294 .unwrap();
295 let resp = app.oneshot(req).await.unwrap();
296 assert_eq!(resp.status(), 403);
297 }
298
299 #[tokio::test]
300 async fn local_origin_allowed() {
301 let app = make_app(None);
302 let req = req("/health")
303 .header("origin", "http://127.0.0.1:7474")
304 .body(Body::empty())
305 .unwrap();
306 let resp = app.oneshot(req).await.unwrap();
307 assert_eq!(resp.status(), 200);
308 }
309
310 fn make_app_with_rate_limit(budget: u64) -> axum::Router {
311 let tab_mgr = Arc::new(TabManager::new());
312 let dispatch = Arc::new(BridgeDispatch::new(tokio::io::stdout()));
313 let handler = VictauriBrowserHandler::new(tab_mgr, dispatch);
314 let limiter = Arc::new(crate::auth::RateLimiterState::new(budget));
315 build_app_full(handler, None, Some(limiter))
316 }
317
318 #[tokio::test]
319 async fn rate_limit_exhaustion_returns_429() {
320 let app = make_app_with_rate_limit(1);
321
322 let req1 = req("/info").body(Body::empty()).unwrap();
323 let resp1 = app.clone().oneshot(req1).await.unwrap();
324 assert_eq!(resp1.status(), 200);
325
326 let req2 = req("/info").body(Body::empty()).unwrap();
327 let resp2 = app.oneshot(req2).await.unwrap();
328 assert_eq!(resp2.status(), 429);
329 }
330
331 #[tokio::test]
332 async fn auth_wrong_token_returns_401() {
333 let app = make_app(Some("correct-token".to_string()));
334 let req = req("/info")
335 .header("authorization", "Bearer wrong-token")
336 .body(Body::empty())
337 .unwrap();
338 let resp = app.oneshot(req).await.unwrap();
339 assert_eq!(resp.status(), 401);
340 }
341
342 #[tokio::test]
343 async fn auth_no_bearer_prefix_returns_401() {
344 let app = make_app(Some("my-token".to_string()));
345 let req = req("/info")
346 .header("authorization", "my-token")
347 .body(Body::empty())
348 .unwrap();
349 let resp = app.oneshot(req).await.unwrap();
350 assert_eq!(resp.status(), 401);
351 }
352
353 #[tokio::test]
354 async fn auth_case_insensitive_bearer() {
355 let token = "my-secret-token";
356 let app = make_app(Some(token.to_string()));
357 let req = req("/info")
358 .header("authorization", format!("BEARER {token}"))
359 .body(Body::empty())
360 .unwrap();
361 let resp = app.oneshot(req).await.unwrap();
362 assert_eq!(resp.status(), 200);
363 }
364
365 #[tokio::test]
366 async fn rest_tool_with_auth() {
367 let token = "secret";
368 let (status, json) = post_json(
369 make_app(Some(token.to_string())),
370 "/api/tools/get_plugin_info",
371 serde_json::json!({}),
372 Some(token),
373 )
374 .await;
375 assert_eq!(status, 200);
376 assert_eq!(json["result"]["name"], "victauri-browser");
377 }
378
379 #[tokio::test]
380 async fn rest_tool_without_auth_when_required() {
381 let (status, _) = post_json(
382 make_app(Some("secret".to_string())),
383 "/api/tools/get_plugin_info",
384 serde_json::json!({}),
385 None,
386 )
387 .await;
388 assert_eq!(status, 401);
389 }
390
391 #[tokio::test]
392 async fn localhost_origin_allowed() {
393 let app = make_app(None);
394 let req = req("/health")
395 .header("origin", "http://localhost:3000")
396 .body(Body::empty())
397 .unwrap();
398 let resp = app.oneshot(req).await.unwrap();
399 assert_eq!(resp.status(), 200);
400 }
401
402 #[tokio::test]
403 async fn ipv6_localhost_origin_allowed() {
404 let app = make_app(None);
405 let req = req("/health")
406 .header("origin", "http://[::1]:7474")
407 .body(Body::empty())
408 .unwrap();
409 let resp = app.oneshot(req).await.unwrap();
410 assert_eq!(resp.status(), 200);
411 }
412
413 #[tokio::test]
414 async fn no_origin_header_allowed() {
415 let app = make_app(None);
416 let (status, json) = get_json(app, "/health").await;
417 assert_eq!(status, 200);
418 assert_eq!(json["status"], "ok");
419 }
420
421 #[tokio::test]
422 async fn info_shows_auth_required() {
423 let (_, json_no_auth) = get_json(make_app(None), "/info").await;
424 assert_eq!(json_no_auth["auth_required"], false);
425
426 let app = make_app(Some("tok".to_string()));
427 let req = req("/info")
428 .header("authorization", "Bearer tok")
429 .body(Body::empty())
430 .unwrap();
431 let resp = app.oneshot(req).await.unwrap();
432 let body = resp.into_body().collect().await.unwrap().to_bytes();
433 let json_auth: serde_json::Value = serde_json::from_slice(&body).unwrap();
434 assert_eq!(json_auth["auth_required"], true);
435 }
436
437 #[tokio::test]
438 async fn tool_list_via_rest_has_names() {
439 let (_, json) = get_json(make_app(None), "/api/tools").await;
440 let tools = json.as_array().unwrap();
441 let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
442 assert!(names.contains(&"eval_js"));
443 assert!(names.contains(&"dom_snapshot"));
444 assert!(names.contains(&"screenshot"));
445 assert!(names.contains(&"tabs"));
446 }
447
448 #[tokio::test]
449 async fn rest_error_format() {
450 let (status, json) = post_json(
451 make_app(None),
452 "/api/tools/nonexistent",
453 serde_json::json!({}),
454 None,
455 )
456 .await;
457 assert_eq!(status, 200);
458 assert!(json.get("error").is_some());
459 assert!(json.get("result").is_none());
460 }
461
462 #[tokio::test]
463 async fn rest_success_format() {
464 let (status, json) = post_json(
465 make_app(None),
466 "/api/tools/get_plugin_info",
467 serde_json::json!({}),
468 None,
469 )
470 .await;
471 assert_eq!(status, 200);
472 assert!(json.get("result").is_some());
473 assert!(json.get("error").is_none());
474 }
475
476 #[tokio::test]
480 async fn origin_bypass_localhost_in_subdomain_blocked() {
481 let app = make_app(None);
482 let req = req("/health")
483 .header("origin", "https://localhost.evil.com")
484 .body(Body::empty())
485 .unwrap();
486 let resp = app.oneshot(req).await.unwrap();
487 assert_eq!(resp.status(), 403);
488 }
489
490 #[tokio::test]
491 async fn origin_bypass_127_in_subdomain_blocked() {
492 let app = make_app(None);
493 let req = req("/health")
494 .header("origin", "https://127.0.0.1.evil.com")
495 .body(Body::empty())
496 .unwrap();
497 let resp = app.oneshot(req).await.unwrap();
498 assert_eq!(resp.status(), 403);
499 }
500
501 #[tokio::test]
502 async fn origin_with_path_containing_localhost_blocked() {
503 let app = make_app(None);
504 let req = req("/health")
505 .header("origin", "https://evil.com/localhost")
506 .body(Body::empty())
507 .unwrap();
508 let resp = app.oneshot(req).await.unwrap();
509 assert_eq!(resp.status(), 403);
510 }
511
512 #[tokio::test]
513 async fn origin_evil_localhost_prefix_blocked() {
514 let app = make_app(None);
515 let req = req("/health")
516 .header("origin", "https://evil-localhost.com")
517 .body(Body::empty())
518 .unwrap();
519 let resp = app.oneshot(req).await.unwrap();
520 assert_eq!(resp.status(), 403);
521 }
522
523 #[tokio::test]
524 async fn origin_localhost_with_port_allowed() {
525 let app = make_app(None);
526 let req = req("/health")
527 .header("origin", "http://localhost:9999")
528 .body(Body::empty())
529 .unwrap();
530 let resp = app.oneshot(req).await.unwrap();
531 assert_eq!(resp.status(), 200);
532 }
533
534 #[tokio::test]
535 async fn origin_127_with_port_allowed() {
536 let app = make_app(None);
537 let req = req("/health")
538 .header("origin", "http://127.0.0.1:8080")
539 .body(Body::empty())
540 .unwrap();
541 let resp = app.oneshot(req).await.unwrap();
542 assert_eq!(resp.status(), 200);
543 }
544
545 #[tokio::test]
546 async fn auth_with_extra_spaces_in_bearer() {
547 let token = "test-token";
548 let app = make_app(Some(token.to_string()));
549 let req = req("/info")
550 .header("authorization", "Bearer test-token")
551 .body(Body::empty())
552 .unwrap();
553 let resp = app.oneshot(req).await.unwrap();
554 assert_eq!(resp.status(), 401);
556 }
557
558 #[tokio::test]
559 async fn auth_with_trailing_whitespace() {
560 let token = "test-token";
561 let app = make_app(Some(token.to_string()));
562 let req = req("/info")
563 .header("authorization", "Bearer test-token ")
564 .body(Body::empty())
565 .unwrap();
566 let resp = app.oneshot(req).await.unwrap();
567 assert_eq!(resp.status(), 401);
569 }
570
571 #[tokio::test]
572 async fn auth_empty_bearer_token() {
573 let token = "secret";
574 let app = make_app(Some(token.to_string()));
575 let req = req("/info")
576 .header("authorization", "Bearer ")
577 .body(Body::empty())
578 .unwrap();
579 let resp = app.oneshot(req).await.unwrap();
580 assert_eq!(resp.status(), 401);
581 }
582
583 #[tokio::test]
584 async fn rate_limit_concurrent_burst() {
585 let app = make_app_with_rate_limit(5);
586
587 let mut handles = vec![];
588 for _ in 0..20 {
589 let a = app.clone();
590 handles.push(tokio::spawn(async move {
591 let req = req("/info").body(Body::empty()).unwrap();
592 let resp = a.oneshot(req).await.unwrap();
593 resp.status()
594 }));
595 }
596
597 let mut ok_count = 0u32;
598 let mut limited_count = 0u32;
599 for h in handles {
600 match h.await.unwrap().as_u16() {
601 200 => ok_count += 1,
602 429 => limited_count += 1,
603 s => panic!("unexpected status: {s}"),
604 }
605 }
606
607 assert!(ok_count <= 5, "too many passed: {ok_count}");
608 assert!(ok_count >= 1, "none passed");
609 assert!(limited_count >= 15, "not enough limited: {limited_count}");
610 }
611
612 #[tokio::test]
613 async fn body_limit_enforcement() {
614 let app = make_app(None);
615 let huge_body = "x".repeat(3 * 1024 * 1024);
616 let req = req("/api/tools/eval_js")
617 .method("POST")
618 .header("content-type", "application/json")
619 .body(Body::from(huge_body))
620 .unwrap();
621 let resp = app.oneshot(req).await.unwrap();
622 assert_eq!(resp.status(), 413);
623 }
624
625 #[tokio::test]
626 async fn malformed_json_body() {
627 let app = make_app(None);
628 let req = req("/api/tools/eval_js")
629 .method("POST")
630 .header("content-type", "application/json")
631 .body(Body::from("not json {{{"))
632 .unwrap();
633 let resp = app.oneshot(req).await.unwrap();
634 assert_eq!(resp.status(), 400);
636 }
637
638 #[tokio::test]
639 async fn empty_body_on_post() {
640 let app = make_app(None);
641 let req = req("/api/tools/eval_js")
642 .method("POST")
643 .header("content-type", "application/json")
644 .body(Body::empty())
645 .unwrap();
646 let resp = app.oneshot(req).await.unwrap();
647 assert_eq!(resp.status(), 400);
649 }
650
651 #[tokio::test]
652 async fn very_long_tool_name_in_path() {
653 let long_name = "x".repeat(10_000);
654 let (status, json) = post_json(
655 make_app(None),
656 &format!("/api/tools/{long_name}"),
657 serde_json::json!({}),
658 None,
659 )
660 .await;
661 assert_eq!(status, 200);
662 assert!(json["error"].as_str().unwrap().contains("unknown tool"));
663 }
664
665 #[tokio::test]
666 async fn tool_name_with_path_traversal() {
667 let (status, json) = post_json(
668 make_app(None),
669 "/api/tools/../../../etc/passwd",
670 serde_json::json!({}),
671 None,
672 )
673 .await;
674 assert!(status == 200 || status == 404);
676 if status == 200 {
677 assert!(json.get("error").is_some());
678 }
679 }
680
681 #[tokio::test]
682 async fn security_headers_on_all_responses() {
683 let app = make_app(None);
684
685 for path in ["/health", "/info", "/api/tools"] {
686 let req = req(path).body(Body::empty()).unwrap();
687 let resp = app.clone().oneshot(req).await.unwrap();
688 assert_eq!(
689 resp.headers().get("x-content-type-options").unwrap(),
690 "nosniff",
691 "missing header on {path}"
692 );
693 assert_eq!(
694 resp.headers().get("cache-control").unwrap(),
695 "no-store",
696 "missing cache header on {path}"
697 );
698 }
699 }
700
701 #[tokio::test]
702 async fn concurrent_health_checks_100() {
703 let app = make_app(None);
704 let mut handles = vec![];
705
706 for _ in 0..100 {
707 let a = app.clone();
708 handles.push(tokio::spawn(async move {
709 let req = req("/health").body(Body::empty()).unwrap();
710 a.oneshot(req).await.unwrap().status()
711 }));
712 }
713
714 for h in handles {
715 assert_eq!(h.await.unwrap(), 200);
716 }
717 }
718
719 #[tokio::test]
720 async fn method_not_allowed_on_get_to_tool() {
721 let app = make_app(None);
722 let req = req("/api/tools/eval_js")
723 .method("GET")
724 .body(Body::empty())
725 .unwrap();
726 let resp = app.oneshot(req).await.unwrap();
727 assert_eq!(resp.status(), 405);
728 }
729
730 #[tokio::test]
731 async fn put_method_on_health() {
732 let app = make_app(None);
733 let req = req("/health").method("PUT").body(Body::empty()).unwrap();
734 let resp = app.oneshot(req).await.unwrap();
735 assert_eq!(resp.status(), 405);
736 }
737
738 #[tokio::test]
739 async fn nonexistent_path_returns_404() {
740 let app = make_app(None);
741 let req = req("/nonexistent").body(Body::empty()).unwrap();
742 let resp = app.oneshot(req).await.unwrap();
743 assert_eq!(resp.status(), 404);
744 }
745
746 #[tokio::test]
749 async fn auth_token_with_unicode_rejected() {
750 let token = "valid-token";
751 let app = make_app(Some(token.to_string()));
752 let req = req("/info")
753 .header("authorization", "Bearer valid-token\u{200B}")
754 .body(Body::empty())
755 .unwrap();
756 let resp = app.oneshot(req).await.unwrap();
757 assert_eq!(resp.status(), 401);
759 }
760
761 #[tokio::test]
762 async fn auth_token_with_newline_rejected() {
763 let token = "valid-token";
764 let app = make_app(Some(token.to_string()));
765 let req = req("/info")
767 .header("authorization", "Bearer valid-token\r\nX-Evil: injected")
768 .body(Body::empty());
769 if let Ok(req) = req {
770 let resp = app.oneshot(req).await.unwrap();
771 assert_eq!(resp.status(), 401);
772 }
773 }
775
776 #[tokio::test]
777 async fn content_type_missing_on_post_tool() {
778 let app = make_app(None);
779 let req = req("/api/tools/get_plugin_info")
780 .method("POST")
781 .body(Body::from("{}"))
782 .unwrap();
783 let resp = app.oneshot(req).await.unwrap();
784 assert!(
786 resp.status() == 415 || resp.status() == 400,
787 "expected 415 or 400, got {}",
788 resp.status()
789 );
790 }
791
792 #[tokio::test]
793 async fn content_type_wrong_on_post_tool() {
794 let app = make_app(None);
795 let req = req("/api/tools/get_plugin_info")
796 .method("POST")
797 .header("content-type", "text/plain")
798 .body(Body::from("{}"))
799 .unwrap();
800 let resp = app.oneshot(req).await.unwrap();
801 assert!(
802 resp.status() == 415 || resp.status() == 400,
803 "expected 415 or 400, got {}",
804 resp.status()
805 );
806 }
807
808 #[tokio::test]
809 async fn concurrent_tool_calls_all_return() {
810 let app = make_app(None);
811 let mut handles = vec![];
812 for _ in 0..50 {
813 let a = app.clone();
814 handles.push(tokio::spawn(async move {
815 let req = req("/api/tools/get_plugin_info")
816 .method("POST")
817 .header("content-type", "application/json")
818 .body(Body::from("{}"))
819 .unwrap();
820 let resp = a.oneshot(req).await.unwrap();
821 let status = resp.status();
822 let body = resp.into_body().collect().await.unwrap().to_bytes();
823 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
824 (status, json)
825 }));
826 }
827 for h in handles {
828 let (status, json) = h.await.unwrap();
829 assert_eq!(status, 200);
830 assert_eq!(json["result"]["name"], "victauri-browser");
831 }
832 }
833
834 #[tokio::test]
835 async fn tool_name_with_url_encoded_chars() {
836 let app = make_app(None);
837 let req = req("/api/tools/get%5Fplugin%5Finfo")
839 .method("POST")
840 .header("content-type", "application/json")
841 .body(Body::from("{}"))
842 .unwrap();
843 let resp = app.oneshot(req).await.unwrap();
844 let status = resp.status();
845 let body = resp.into_body().collect().await.unwrap().to_bytes();
846 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
847 assert_eq!(status, 200);
848 assert_eq!(json["result"]["name"], "victauri-browser");
850 }
851
852 #[tokio::test]
853 async fn auth_token_timing_attack_resistance() {
854 let token = "a".repeat(64);
856 let app = make_app(Some(token.clone()));
857
858 let wrong_same_len = "b".repeat(64);
860 let r = req("/info")
861 .header("authorization", format!("Bearer {wrong_same_len}"))
862 .body(Body::empty())
863 .unwrap();
864 let resp = app.clone().oneshot(r).await.unwrap();
865 assert_eq!(resp.status(), 401);
866
867 let r = req("/info")
869 .header("authorization", "Bearer short")
870 .body(Body::empty())
871 .unwrap();
872 let resp = app.clone().oneshot(r).await.unwrap();
873 assert_eq!(resp.status(), 401);
874
875 let r = req("/info")
877 .header("authorization", format!("Bearer {token}"))
878 .body(Body::empty())
879 .unwrap();
880 let resp = app.oneshot(r).await.unwrap();
881 assert_eq!(resp.status(), 200);
882 }
883
884 #[tokio::test]
885 async fn origin_with_credentials_in_url() {
886 let app = make_app(None);
887 let req = req("/health")
888 .header("origin", "http://user:pass@evil.com")
889 .body(Body::empty())
890 .unwrap();
891 let resp = app.oneshot(req).await.unwrap();
892 assert_eq!(resp.status(), 403);
893 }
894
895 #[tokio::test]
896 async fn origin_localhost_with_credentials() {
897 let app = make_app(None);
898 let req = req("/health")
899 .header("origin", "http://user:pass@localhost:7474")
900 .body(Body::empty())
901 .unwrap();
902 let resp = app.oneshot(req).await.unwrap();
903 assert_eq!(resp.status(), 403);
907 }
908
909 #[tokio::test]
910 async fn multiple_authorization_headers() {
911 let token = "real-token";
912 let app = make_app(Some(token.to_string()));
913 let req = req("/info")
915 .header("authorization", "Bearer wrong-token")
916 .header("authorization", format!("Bearer {token}"))
917 .body(Body::empty())
918 .unwrap();
919 let resp = app.oneshot(req).await.unwrap();
920 assert_eq!(resp.status(), 401);
922 }
923
924 #[tokio::test]
925 async fn json_body_with_duplicate_keys() {
926 let app = make_app(None);
927 let req = req("/api/tools/tabs")
929 .method("POST")
930 .header("content-type", "application/json")
931 .body(Body::from(r#"{"action": "get_state", "action": "list"}"#))
932 .unwrap();
933 let resp = app.oneshot(req).await.unwrap();
934 let status = resp.status();
935 let body = resp.into_body().collect().await.unwrap().to_bytes();
936 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
937 assert_eq!(status, 200);
938 assert!(json["result"].is_array());
940 }
941
942 #[tokio::test]
943 async fn very_large_json_within_limit() {
944 let app = make_app(None);
945 let padding = "x".repeat(1_500_000);
948 let body = serde_json::json!({"unused_field": padding});
949 let req = req("/api/tools/get_plugin_info")
950 .method("POST")
951 .header("content-type", "application/json")
952 .body(Body::from(serde_json::to_vec(&body).unwrap()))
953 .unwrap();
954 let resp = app.oneshot(req).await.unwrap();
955 assert_eq!(resp.status(), 200);
956 let body = resp.into_body().collect().await.unwrap().to_bytes();
957 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
958 assert_eq!(json["result"]["name"], "victauri-browser");
959 }
960
961 #[tokio::test]
962 async fn head_request_on_health() {
963 let app = make_app(None);
964 let req = req("/health").method("HEAD").body(Body::empty()).unwrap();
965 let resp = app.oneshot(req).await.unwrap();
966 assert_eq!(resp.status(), 200);
968 }
969
970 #[tokio::test]
971 async fn options_request_on_tool() {
972 let app = make_app(None);
973 let req = req("/api/tools/eval_js")
974 .method("OPTIONS")
975 .body(Body::empty())
976 .unwrap();
977 let resp = app.oneshot(req).await.unwrap();
978 assert!(resp.status() == 200 || resp.status() == 405);
980 }
981
982 #[tokio::test]
983 async fn rapid_auth_failures_dont_leak_info() {
984 let token = "secret-token-value";
985 let app = make_app(Some(token.to_string()));
986
987 for attempt in [
988 "",
989 "wrong",
990 "secret-token-valu",
991 "secret-token-value!",
992 &"x".repeat(1000),
993 ] {
994 let req = req("/info")
995 .header("authorization", format!("Bearer {attempt}"))
996 .body(Body::empty())
997 .unwrap();
998 let resp = app.clone().oneshot(req).await.unwrap();
999 assert_eq!(resp.status(), 401, "should reject: {attempt:?}");
1000 let body = resp.into_body().collect().await.unwrap().to_bytes();
1001 let body_str = String::from_utf8_lossy(&body);
1003 assert!(!body_str.contains(token), "response leaked the token");
1004 }
1005 }
1006}