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