Skip to main content

bhtune_server/
security.rs

1use axum::{
2    body::Body,
3    extract::State,
4    http::{HeaderMap, HeaderValue, Method, Request, header, uri::Authority},
5    middleware::Next,
6    response::{IntoResponse, Response},
7};
8use bhtune_runtime::config::ServerMode;
9use std::str::FromStr;
10
11// React's tag tree and uPlot set layout values through element `style` attributes. Keep
12// stylesheet sources self-only while permitting only that narrow inline-style surface.
13const CONTENT_SECURITY_POLICY: &str = "default-src 'self'; script-src 'self'; \
14    style-src 'self'; style-src-attr 'unsafe-inline'; connect-src 'self'; img-src 'self'; \
15    font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; \
16    form-action 'self'";
17const PERMISSIONS_POLICY: &str = "accelerometer=(), autoplay=(), camera=(), \
18    clipboard-read=(), clipboard-write=(), display-capture=(), encrypted-media=(), \
19    fullscreen=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), midi=(), \
20    payment=(), picture-in-picture=(), publickey-credentials-get=(), screen-wake-lock=(), \
21    usb=(), web-share=(), xr-spatial-tracking=()";
22
23fn is_state_changing(method: &Method) -> bool {
24    !matches!(*method, Method::GET | Method::HEAD | Method::OPTIONS)
25}
26
27fn origin_is_allowed(
28    mode: ServerMode,
29    headers: &HeaderMap,
30    configured_origin: Option<&str>,
31) -> bool {
32    let mut origins = headers.get_all(header::ORIGIN).iter();
33    let origin = match (origins.next(), origins.next()) {
34        (Some(origin), None) => origin.to_str().ok(),
35        (None, None) => None,
36        _ => return false,
37    };
38
39    if mode == ServerMode::Demo {
40        return origin.is_some_and(|origin| configured_origin == Some(origin));
41    }
42
43    // Full mode predates browser-only operation and must retain CLI/curl compatibility. A Vite
44    // development page reaches the backend through its same-origin proxy, so its browser
45    // Origin is the Vite origin rather than the backend's configured origin when no explicit
46    // origin pin is configured. Fetch Metadata is browser-controlled; accept that narrow
47    // same-origin case while rejecting explicit cross-site requests. Without an explicit
48    // configured origin, compare the browser origin with the request Host instead of
49    // synthesizing an origin from the bind address, which is often 0.0.0.0 in a container.
50    let fetch_site = headers
51        .get("sec-fetch-site")
52        .and_then(|value| value.to_str().ok());
53    if fetch_site.is_some_and(|value| value.eq_ignore_ascii_case("cross-site")) {
54        return false;
55    }
56    if fetch_site.is_some_and(|value| value.eq_ignore_ascii_case("same-origin")) {
57        return match configured_origin {
58            Some(configured_origin) => {
59                parse_http_origin(configured_origin).is_some()
60                    && origin.is_some_and(|origin| configured_origin == origin)
61            }
62            None => origin.is_some_and(|origin| parse_http_origin(origin).is_some()),
63        };
64    }
65    if origin.is_none() {
66        return true;
67    }
68
69    match configured_origin {
70        Some(configured_origin) => {
71            parse_http_origin(configured_origin).is_some()
72                && origin.is_some_and(|origin| configured_origin == origin)
73        }
74        None => origin.is_some_and(|origin| {
75            let Some(host) = headers.get(header::HOST).and_then(parse_host_header) else {
76                return false;
77            };
78            origin_matches_host(origin, &host)
79        }),
80    }
81}
82
83#[derive(Debug, PartialEq, Eq)]
84struct ParsedAuthority {
85    host: String,
86    port: Option<u16>,
87}
88
89#[derive(Debug, PartialEq, Eq)]
90struct ParsedHttpOrigin {
91    https: bool,
92    authority: ParsedAuthority,
93}
94
95fn parse_http_origin(origin: &str) -> Option<ParsedHttpOrigin> {
96    if origin.trim() != origin || origin.chars().any(char::is_whitespace) {
97        return None;
98    }
99    let (scheme, authority) = origin.split_once("://")?;
100    let https = if scheme.eq_ignore_ascii_case("https") {
101        true
102    } else if scheme.eq_ignore_ascii_case("http") {
103        false
104    } else {
105        return None;
106    };
107    if authority.is_empty() || authority.contains(['/', '?', '#', '@']) {
108        return None;
109    }
110    Some(ParsedHttpOrigin {
111        https,
112        authority: parse_authority(authority)?,
113    })
114}
115
116fn parse_host_header(value: &HeaderValue) -> Option<ParsedAuthority> {
117    parse_authority(value.to_str().ok()?)
118}
119
120fn parse_authority(value: &str) -> Option<ParsedAuthority> {
121    if value.trim() != value || value.is_empty() {
122        return None;
123    }
124    let authority = Authority::from_str(value).ok()?;
125    let host = authority.host();
126    if host.is_empty() {
127        return None;
128    }
129    Some(ParsedAuthority {
130        host: host.to_ascii_lowercase(),
131        port: authority.port_u16(),
132    })
133}
134
135fn origin_matches_host(origin: &str, host: &ParsedAuthority) -> bool {
136    let Some(origin) = parse_http_origin(origin) else {
137        return false;
138    };
139    if origin.authority.host != host.host {
140        return false;
141    }
142    let default_port = if origin.https { 443 } else { 80 };
143    origin.authority.port.unwrap_or(default_port) == host.port.unwrap_or(default_port)
144}
145
146fn response_is_private(mode: ServerMode, path: &str) -> bool {
147    mode == ServerMode::Demo || path == "/api" || path.starts_with("/api/")
148}
149
150fn is_scalar_docs(path: &str) -> bool {
151    path == "/api/docs" || path.starts_with("/api/docs/")
152}
153
154fn apply_security_headers(response: &mut Response, include_csp: bool) {
155    let headers = response.headers_mut();
156    if include_csp {
157        headers.insert(
158            "content-security-policy",
159            HeaderValue::from_static(CONTENT_SECURITY_POLICY),
160        );
161    }
162    headers.insert("x-frame-options", HeaderValue::from_static("DENY"));
163    headers.insert(
164        "x-content-type-options",
165        HeaderValue::from_static("nosniff"),
166    );
167    headers.insert("referrer-policy", HeaderValue::from_static("no-referrer"));
168    headers.insert(
169        "cross-origin-resource-policy",
170        HeaderValue::from_static("same-origin"),
171    );
172    headers.insert(
173        "cross-origin-opener-policy",
174        HeaderValue::from_static("same-origin"),
175    );
176    headers.insert(
177        "permissions-policy",
178        HeaderValue::from_static(PERMISSIONS_POLICY),
179    );
180}
181
182fn apply_private_response_headers(response: &mut Response) {
183    let headers = response.headers_mut();
184    headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
185    headers.append(header::VARY, HeaderValue::from_static("Cookie"));
186    headers.insert(
187        "x-robots-tag",
188        HeaderValue::from_static("noindex, nofollow, noarchive"),
189    );
190}
191
192/// Reject cross-site state-changing browser requests and protect public Demo/private API
193/// responses.
194///
195/// Demo mode requires the exact configured Origin on every state-changing request. Full mode
196/// retains CLI/curl compatibility, accepts a browser-controlled `same-origin` request from the
197/// Vite development proxy when no explicit origin pin is configured, and otherwise requires
198/// either the explicitly configured Origin or an automatic same-host match. This middleware
199/// never emits CORS response headers.
200///
201/// Baseline browser protections apply to every response. Demo responses additionally receive
202/// private/no-index caching headers on every path, including the embedded SPA; Full mode
203/// receives those privacy headers on `/api` only so immutable static-asset caching is preserved.
204/// Full mode's existing Scalar documentation page is the sole CSP exception because its upstream
205/// HTML loads the Scalar bundle from jsDelivr; Demo mode does not expose that page.
206pub async fn origin_and_security_headers(
207    State(state): State<crate::state::AppState>,
208    request: Request<Body>,
209    next: Next,
210) -> Response {
211    let path = request.uri().path();
212    let private_response = response_is_private(state.mode, path);
213    let include_csp = state.mode == ServerMode::Demo || !is_scalar_docs(path);
214    if is_state_changing(request.method())
215        && !origin_is_allowed(
216            state.mode,
217            request.headers(),
218            state.allowed_origin.as_deref(),
219        )
220    {
221        let mut response =
222            crate::error::ApiError::Forbidden("cross-origin request rejected".into())
223                .into_response();
224        apply_security_headers(&mut response, include_csp);
225        if private_response {
226            apply_private_response_headers(&mut response);
227        }
228        return response;
229    }
230
231    let mut response = next.run(request).await;
232    apply_security_headers(&mut response, include_csp);
233    if private_response {
234        apply_private_response_headers(&mut response);
235    }
236    response
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242    use axum::{
243        Router,
244        body::to_bytes,
245        http::{Request, StatusCode},
246        middleware,
247        routing::{get, post},
248    };
249    use tower::ServiceExt;
250
251    #[test]
252    fn demo_origin_check_requires_an_exact_match_and_rejects_missing_origin() {
253        let mut headers = HeaderMap::new();
254        headers.insert(
255            header::ORIGIN,
256            HeaderValue::from_static("https://bhtunedemo.bytehound.ca"),
257        );
258        assert!(origin_is_allowed(
259            ServerMode::Demo,
260            &headers,
261            Some("https://bhtunedemo.bytehound.ca")
262        ));
263
264        headers.insert(
265            header::ORIGIN,
266            HeaderValue::from_static("https://bhtunedemo.bytehound.ca.attacker.example"),
267        );
268        assert!(!origin_is_allowed(
269            ServerMode::Demo,
270            &headers,
271            Some("https://bhtunedemo.bytehound.ca")
272        ));
273
274        headers.insert(
275            header::ORIGIN,
276            HeaderValue::from_static("http://bhtunedemo.bytehound.ca"),
277        );
278        assert!(!origin_is_allowed(
279            ServerMode::Demo,
280            &headers,
281            Some("https://bhtunedemo.bytehound.ca")
282        ));
283        assert!(!origin_is_allowed(ServerMode::Demo, &headers, None));
284
285        headers.remove(header::ORIGIN);
286        assert!(!origin_is_allowed(
287            ServerMode::Demo,
288            &headers,
289            Some("https://bhtunedemo.bytehound.ca")
290        ));
291
292        headers.append(
293            header::ORIGIN,
294            HeaderValue::from_static("https://bhtunedemo.bytehound.ca"),
295        );
296        headers.append(
297            header::ORIGIN,
298            HeaderValue::from_static("https://bhtunedemo.bytehound.ca"),
299        );
300        assert!(!origin_is_allowed(
301            ServerMode::Demo,
302            &headers,
303            Some("https://bhtunedemo.bytehound.ca")
304        ));
305    }
306
307    #[test]
308    fn full_mode_preserves_cli_and_vite_proxy_mutations() {
309        let mut headers = HeaderMap::new();
310        assert!(origin_is_allowed(
311            ServerMode::Full,
312            &headers,
313            Some("http://127.0.0.1:8787")
314        ));
315
316        headers.insert("sec-fetch-site", HeaderValue::from_static("same-origin"));
317        assert!(!origin_is_allowed(
318            ServerMode::Full,
319            &headers,
320            Some("http://127.0.0.1:8787")
321        ));
322
323        headers.insert(header::ORIGIN, HeaderValue::from_static("http://asus:5173"));
324        assert!(origin_is_allowed(ServerMode::Full, &headers, None));
325
326        headers.insert("sec-fetch-site", HeaderValue::from_static("cross-site"));
327        assert!(!origin_is_allowed(ServerMode::Full, &headers, None));
328
329        headers.insert("sec-fetch-site", HeaderValue::from_static("same-origin"));
330        headers.remove(header::ORIGIN);
331        assert!(!origin_is_allowed(
332            ServerMode::Full,
333            &headers,
334            Some("http://127.0.0.1:8787")
335        ));
336    }
337
338    #[test]
339    fn full_mode_without_configured_origin_matches_the_request_host() {
340        let mut headers = HeaderMap::new();
341        headers.insert(header::HOST, HeaderValue::from_static("goa1:8787"));
342        headers.insert(header::ORIGIN, HeaderValue::from_static("http://GOA1:8787"));
343        assert!(origin_is_allowed(ServerMode::Full, &headers, None));
344
345        headers.insert("sec-fetch-site", HeaderValue::from_static("same-site"));
346        assert!(origin_is_allowed(ServerMode::Full, &headers, None));
347
348        headers.insert(header::ORIGIN, HeaderValue::from_static("http://goa1:80"));
349        assert!(!origin_is_allowed(ServerMode::Full, &headers, None));
350
351        headers.insert(header::HOST, HeaderValue::from_static("other.example:8787"));
352        headers.insert(header::ORIGIN, HeaderValue::from_static("http://goa1:8787"));
353        assert!(!origin_is_allowed(ServerMode::Full, &headers, None));
354    }
355
356    #[test]
357    fn full_mode_rejects_malformed_or_duplicate_automatic_origin_headers() {
358        let mut headers = HeaderMap::new();
359        headers.insert(header::HOST, HeaderValue::from_static("goa1:8787"));
360        headers.insert(header::ORIGIN, HeaderValue::from_static("goa1"));
361        assert!(!origin_is_allowed(ServerMode::Full, &headers, None));
362
363        headers.insert(header::ORIGIN, HeaderValue::from_static("http://goa1"));
364        headers.append(header::ORIGIN, HeaderValue::from_static("http://goa1:8787"));
365        assert!(!origin_is_allowed(ServerMode::Full, &headers, None));
366
367        headers.clear();
368        headers.insert(header::HOST, HeaderValue::from_static("["));
369        headers.insert(header::ORIGIN, HeaderValue::from_static("http://goa1:8787"));
370        assert!(!origin_is_allowed(ServerMode::Full, &headers, None));
371    }
372
373    #[test]
374    fn origin_parsers_reject_invalid_syntax() {
375        assert!(parse_http_origin(" http://goa1:8787").is_none());
376        assert!(parse_http_origin("ftp://goa1:8787").is_none());
377        assert!(parse_http_origin("http://").is_none());
378        assert!(parse_http_origin("http://goa1:8787/path").is_none());
379        assert!(parse_http_origin("http://goa1:8787?query").is_none());
380        assert!(parse_http_origin("http://goa1:8787#fragment").is_none());
381        assert!(parse_http_origin("http://user@goa1:8787").is_none());
382
383        assert!(parse_authority("").is_none());
384        assert!(parse_authority(" ").is_none());
385        assert!(parse_authority(":8787").is_none());
386    }
387
388    #[test]
389    fn full_mode_explicit_origin_remains_a_strict_override() {
390        let mut headers = HeaderMap::new();
391        headers.insert(header::HOST, HeaderValue::from_static("goa1:8787"));
392        headers.insert(
393            header::ORIGIN,
394            HeaderValue::from_static("https://public.example"),
395        );
396        assert!(origin_is_allowed(
397            ServerMode::Full,
398            &headers,
399            Some("https://public.example")
400        ));
401        assert!(!origin_is_allowed(
402            ServerMode::Full,
403            &headers,
404            Some("https://other.example")
405        ));
406
407        headers.insert(header::ORIGIN, HeaderValue::from_static("garbage"));
408        assert!(!origin_is_allowed(
409            ServerMode::Full,
410            &headers,
411            Some("garbage")
412        ));
413
414        headers.insert("sec-fetch-site", HeaderValue::from_static("same-origin"));
415        headers.insert(
416            header::ORIGIN,
417            HeaderValue::from_static("https://public.example"),
418        );
419        assert!(origin_is_allowed(
420            ServerMode::Full,
421            &headers,
422            Some("https://public.example")
423        ));
424        headers.insert(header::ORIGIN, HeaderValue::from_static("http://goa1:8787"));
425        assert!(!origin_is_allowed(
426            ServerMode::Full,
427            &headers,
428            Some("https://public.example")
429        ));
430    }
431
432    #[test]
433    fn full_mode_accepts_cli_requests_without_origin_but_rejects_explicit_cross_site() {
434        let mut headers = HeaderMap::new();
435        assert!(origin_is_allowed(ServerMode::Full, &headers, None));
436
437        headers.insert("sec-fetch-site", HeaderValue::from_static("same-site"));
438        assert!(origin_is_allowed(ServerMode::Full, &headers, None));
439
440        headers.insert("sec-fetch-site", HeaderValue::from_static("cross-site"));
441        assert!(!origin_is_allowed(ServerMode::Full, &headers, None));
442    }
443
444    async fn app_with_origin(mode: ServerMode, allowed_origin: Option<&str>) -> Router {
445        let mut state = crate::test_support::in_memory_state().await;
446        state.mode = mode;
447        state.allowed_origin = allowed_origin.map(str::to_owned);
448        Router::new()
449            .route("/api/change", post(|| async { StatusCode::NO_CONTENT }))
450            .route(
451                "/api/protected",
452                get(|| async {
453                    (
454                        [
455                            (header::CACHE_CONTROL, "public, max-age=60"),
456                            (header::VARY, "Accept-Encoding"),
457                        ],
458                        "ok",
459                    )
460                }),
461            )
462            .route(
463                "/asset.js",
464                get(|| async { ([(header::CACHE_CONTROL, "public, immutable")], "asset") }),
465            )
466            .route("/api/docs", get(|| async { "Scalar" }))
467            .layer(middleware::from_fn_with_state(
468                state.clone(),
469                origin_and_security_headers,
470            ))
471            .with_state(state)
472    }
473
474    async fn app(mode: ServerMode) -> Router {
475        let allowed_origin =
476            (mode == ServerMode::Demo).then_some("https://bhtunedemo.bytehound.ca");
477        app_with_origin(mode, allowed_origin).await
478    }
479
480    #[tokio::test]
481    async fn demo_mutation_requires_the_configured_origin() {
482        let app = app(ServerMode::Demo).await;
483        let missing = app
484            .clone()
485            .oneshot(Request::post("/api/change").body(Body::empty()).unwrap())
486            .await
487            .unwrap();
488        assert_eq!(missing.status(), StatusCode::FORBIDDEN);
489        assert_eq!(missing.headers()[header::CACHE_CONTROL], "no-store");
490        assert_eq!(missing.headers()["x-frame-options"], "DENY");
491        assert_eq!(
492            serde_json::from_slice::<serde_json::Value>(
493                &to_bytes(missing.into_body(), usize::MAX).await.unwrap()
494            )
495            .unwrap(),
496            serde_json::json!({"error": "cross-origin request rejected"})
497        );
498
499        let exact = app
500            .oneshot(
501                Request::post("/api/change")
502                    .header(header::ORIGIN, "https://bhtunedemo.bytehound.ca")
503                    .body(Body::empty())
504                    .unwrap(),
505            )
506            .await
507            .unwrap();
508        assert_eq!(exact.status(), StatusCode::NO_CONTENT);
509    }
510
511    #[tokio::test]
512    async fn full_mode_allows_vite_proxy_mutations_without_widening_cross_site_access() {
513        let app = app(ServerMode::Full).await;
514        let cli = app
515            .clone()
516            .oneshot(Request::post("/api/change").body(Body::empty()).unwrap())
517            .await
518            .unwrap();
519        assert_eq!(cli.status(), StatusCode::NO_CONTENT);
520
521        let vite = app
522            .clone()
523            .oneshot(
524                Request::post("/api/change")
525                    .header("sec-fetch-site", "same-origin")
526                    .header(header::ORIGIN, "http://asus:5173")
527                    .body(Body::empty())
528                    .unwrap(),
529            )
530            .await
531            .unwrap();
532        assert_eq!(vite.status(), StatusCode::NO_CONTENT);
533
534        let cross_site = app
535            .oneshot(
536                Request::post("/api/change")
537                    .header("sec-fetch-site", "cross-site")
538                    .header(header::ORIGIN, "https://attacker.example")
539                    .body(Body::empty())
540                    .unwrap(),
541            )
542            .await
543            .unwrap();
544        assert_eq!(cross_site.status(), StatusCode::FORBIDDEN);
545    }
546
547    #[tokio::test]
548    async fn full_mode_automatic_origin_allows_same_host_mutations() {
549        let app = app(ServerMode::Full).await;
550        let same_host = app
551            .clone()
552            .oneshot(
553                Request::post("/api/change")
554                    .header(header::HOST, "goa1:8787")
555                    .header(header::ORIGIN, "http://goa1:8787")
556                    .body(Body::empty())
557                    .unwrap(),
558            )
559            .await
560            .unwrap();
561        assert_eq!(same_host.status(), StatusCode::NO_CONTENT);
562
563        let wrong_host = app
564            .oneshot(
565                Request::post("/api/change")
566                    .header(header::HOST, "other.example:8787")
567                    .header(header::ORIGIN, "http://goa1:8787")
568                    .body(Body::empty())
569                    .unwrap(),
570            )
571            .await
572            .unwrap();
573        assert_eq!(wrong_host.status(), StatusCode::FORBIDDEN);
574    }
575
576    #[tokio::test]
577    async fn full_mode_explicit_origin_stays_pinned_in_the_middleware() {
578        let app = app_with_origin(ServerMode::Full, Some("https://public.example")).await;
579        let exact = app
580            .clone()
581            .oneshot(
582                Request::post("/api/change")
583                    .header(header::ORIGIN, "https://public.example")
584                    .body(Body::empty())
585                    .unwrap(),
586            )
587            .await
588            .unwrap();
589        assert_eq!(exact.status(), StatusCode::NO_CONTENT);
590
591        let same_host_but_not_pinned = app
592            .oneshot(
593                Request::post("/api/change")
594                    .header(header::HOST, "goa1:8787")
595                    .header(header::ORIGIN, "http://goa1:8787")
596                    .body(Body::empty())
597                    .unwrap(),
598            )
599            .await
600            .unwrap();
601        assert_eq!(same_host_but_not_pinned.status(), StatusCode::FORBIDDEN);
602    }
603
604    #[tokio::test]
605    async fn protected_responses_receive_security_headers_without_enabling_cors() {
606        let response = app(ServerMode::Demo)
607            .await
608            .oneshot(Request::get("/api/protected").body(Body::empty()).unwrap())
609            .await
610            .unwrap();
611        let headers = response.headers();
612        assert_eq!(headers[header::CACHE_CONTROL], "no-store");
613        let vary = headers
614            .get_all(header::VARY)
615            .iter()
616            .map(|value| value.to_str().unwrap())
617            .collect::<Vec<_>>();
618        assert_eq!(vary, ["Accept-Encoding", "Cookie"]);
619        assert_eq!(headers["x-frame-options"], "DENY");
620        assert_eq!(headers["x-content-type-options"], "nosniff");
621        assert_eq!(headers["referrer-policy"], "no-referrer");
622        assert_eq!(headers["cross-origin-resource-policy"], "same-origin");
623        assert_eq!(headers["cross-origin-opener-policy"], "same-origin");
624        assert_eq!(headers["x-robots-tag"], "noindex, nofollow, noarchive");
625        assert!(
626            headers["permissions-policy"]
627                .to_str()
628                .unwrap()
629                .contains("camera=()")
630        );
631        let csp = headers["content-security-policy"].to_str().unwrap();
632        for directive in [
633            "script-src 'self'",
634            "style-src 'self'",
635            "style-src-attr 'unsafe-inline'",
636            "connect-src 'self'",
637            "img-src 'self'",
638            "font-src 'self'",
639            "object-src 'none'",
640            "base-uri 'self'",
641            "frame-ancestors 'none'",
642        ] {
643            assert!(
644                csp.contains(directive),
645                "missing CSP directive: {directive}"
646            );
647        }
648        assert!(!headers.contains_key(header::ACCESS_CONTROL_ALLOW_ORIGIN));
649        assert!(!headers.contains_key(header::ACCESS_CONTROL_ALLOW_CREDENTIALS));
650    }
651
652    #[tokio::test]
653    async fn full_mode_preserves_scalar_without_weakening_demo_csp() {
654        let full = app(ServerMode::Full)
655            .await
656            .oneshot(Request::get("/api/docs").body(Body::empty()).unwrap())
657            .await
658            .unwrap();
659        assert!(!full.headers().contains_key("content-security-policy"));
660        assert_eq!(full.headers()["x-frame-options"], "DENY");
661
662        let demo = app(ServerMode::Demo)
663            .await
664            .oneshot(Request::get("/api/docs").body(Body::empty()).unwrap())
665            .await
666            .unwrap();
667        assert!(demo.headers().contains_key("content-security-policy"));
668    }
669
670    #[tokio::test]
671    async fn full_mode_api_responses_receive_private_response_headers() {
672        let response = app(ServerMode::Full)
673            .await
674            .oneshot(Request::get("/api/protected").body(Body::empty()).unwrap())
675            .await
676            .unwrap();
677        assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
678        assert_eq!(
679            response.headers()["x-robots-tag"],
680            "noindex, nofollow, noarchive"
681        );
682    }
683
684    #[tokio::test]
685    async fn demo_static_responses_are_private_and_no_index() {
686        let response = app(ServerMode::Demo)
687            .await
688            .oneshot(Request::get("/asset.js").body(Body::empty()).unwrap())
689            .await
690            .unwrap();
691        assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
692        assert_eq!(
693            response.headers()["x-robots-tag"],
694            "noindex, nofollow, noarchive"
695        );
696        assert!(response.headers().contains_key("content-security-policy"));
697    }
698
699    #[tokio::test]
700    async fn demo_router_protects_the_embedded_spa_fallback() {
701        let mut state = crate::test_support::in_memory_state().await;
702        state.mode = ServerMode::Demo;
703        state.allowed_origin = Some("https://bhtunedemo.bytehound.ca".into());
704        let response = crate::build_router(state)
705            .oneshot(
706                Request::get("/client-side-route")
707                    .body(Body::empty())
708                    .unwrap(),
709            )
710            .await
711            .unwrap();
712        assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
713        assert_eq!(response.headers()["x-frame-options"], "DENY");
714        assert!(response.headers().contains_key("content-security-policy"));
715    }
716
717    #[tokio::test]
718    async fn full_mode_keeps_non_api_asset_caching_unchanged() {
719        let response = app(ServerMode::Full)
720            .await
721            .oneshot(Request::get("/asset.js").body(Body::empty()).unwrap())
722            .await
723            .unwrap();
724        assert_eq!(
725            response.headers()[header::CACHE_CONTROL],
726            "public, immutable"
727        );
728        assert!(response.headers().contains_key("content-security-policy"));
729        assert!(!response.headers().contains_key("x-robots-tag"));
730    }
731}