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}
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    // --- Adversarial stress tests ---
486
487    // SECURITY: Origin guard bypass attempts — all must be BLOCKED
488    #[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        // Extra space becomes part of the token — should reject
571        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        // Trailing space: "test-token " != "test-token"
585        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        // axum returns 400 Bad Request for malformed JSON
658        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        // axum returns 400 for empty body (can't parse as JSON)
672        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        // axum normalizes paths, so this should be a 404 or match a different route
699        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    // --- Deep challenger tests ---
785
786    #[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        // Zero-width space appended — must reject
797        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        // Newline in token value — a header injection attempt
805        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        // If Err: http crate rejects headers with CRLF — attack blocked at protocol level
814    }
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        // axum requires content-type for JSON extraction — 415 Unsupported Media Type
826        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        // %5F = underscore. "get%5Fplugin%5Finfo" → "get_plugin_info"
881        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        // Should route to get_plugin_info (URL decoded)
893        assert_eq!(json["result"]["name"], "victauri-browser");
894    }
895
896    #[tokio::test]
897    async fn auth_token_timing_attack_resistance() {
898        // Verify that wrong tokens of different lengths both fail
899        let token = "a".repeat(64);
900        let app = make_app(Some(token.clone()));
901
902        // Wrong token same length
903        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        // Wrong token different length
913        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        // Correct token works
922        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        // URL with credentials: "user:pass@localhost" — after stripping scheme,
953        // the host extraction splits on ':' and gets "user" (not "localhost")
954        // This is actually a legitimate security concern — should be blocked
955        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        // HTTP allows multiple header values; axum takes the first
963        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        // Should reject — first header wins, and it's wrong
971        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        // JSON with duplicate "action" keys — serde takes the last one
978        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        // serde_json takes the last key, so action="list" wins
990        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        // 1.5MB of JSON body — under the 2MB limit
997        // Use get_plugin_info which doesn't dispatch to bridge (avoids 30s timeout)
998        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        // HEAD on a GET route should return 200 with no body
1023        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        // OPTIONS typically returns 200 or 405 depending on CORS config
1036        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            // Body should not contain the actual token or any hint
1060            let body_str = String::from_utf8_lossy(&body);
1061            assert!(!body_str.contains(token), "response leaked the token");
1062        }
1063    }
1064}