Skip to main content

victauri_browser/
server.rs

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
10/// Build the axum router for the browser MCP server.
11///
12/// Mirrors `victauri-plugin`'s server pattern: `/mcp` for MCP Streamable HTTP,
13/// `/api/tools` for REST, `/health` and `/info` for diagnostics.
14pub fn build_app(handler: VictauriBrowserHandler, auth_token: Option<String>) -> axum::Router {
15    build_app_full(handler, auth_token, None)
16}
17
18/// Build the axum router with full control over auth and rate limiting.
19pub 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    // --- Adversarial stress tests ---
477
478    // SECURITY: Origin guard bypass attempts — all must be BLOCKED
479    #[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        // Extra space becomes part of the token — should reject
555        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        // Trailing space: "test-token " != "test-token"
568        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        // axum returns 400 Bad Request for malformed JSON
635        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        // axum returns 400 for empty body (can't parse as JSON)
648        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        // axum normalizes paths, so this should be a 404 or match a different route
675        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    // --- Deep challenger tests ---
747
748    #[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        // Zero-width space appended — must reject
758        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        // Newline in token value — a header injection attempt
766        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        // If Err: http crate rejects headers with CRLF — attack blocked at protocol level
774    }
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        // axum requires content-type for JSON extraction — 415 Unsupported Media Type
785        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        // %5F = underscore. "get%5Fplugin%5Finfo" → "get_plugin_info"
838        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        // Should route to get_plugin_info (URL decoded)
849        assert_eq!(json["result"]["name"], "victauri-browser");
850    }
851
852    #[tokio::test]
853    async fn auth_token_timing_attack_resistance() {
854        // Verify that wrong tokens of different lengths both fail
855        let token = "a".repeat(64);
856        let app = make_app(Some(token.clone()));
857
858        // Wrong token same length
859        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        // Wrong token different length
868        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        // Correct token works
876        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        // URL with credentials: "user:pass@localhost" — after stripping scheme,
904        // the host extraction splits on ':' and gets "user" (not "localhost")
905        // This is actually a legitimate security concern — should be blocked
906        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        // HTTP allows multiple header values; axum takes the first
914        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        // Should reject — first header wins, and it's wrong
921        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        // JSON with duplicate "action" keys — serde takes the last one
928        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        // serde_json takes the last key, so action="list" wins
939        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        // 1.5MB of JSON body — under the 2MB limit
946        // Use get_plugin_info which doesn't dispatch to bridge (avoids 30s timeout)
947        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        // HEAD on a GET route should return 200 with no body
967        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        // OPTIONS typically returns 200 or 405 depending on CORS config
979        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            // Body should not contain the actual token or any hint
1002            let body_str = String::from_utf8_lossy(&body);
1003            assert!(!body_str.contains(token), "response leaked the token");
1004        }
1005    }
1006}