Skip to main content

bhtune_server/
security.rs

1use axum::{
2    body::Body,
3    extract::State,
4    http::{HeaderMap, HeaderValue, Method, Request, header},
5    middleware::Next,
6    response::{IntoResponse, Response},
7};
8use bhtune_cli::config::ServerMode;
9
10// React's tag tree and uPlot set layout values through element `style` attributes. Keep
11// stylesheet sources self-only while permitting only that narrow inline-style surface.
12const CONTENT_SECURITY_POLICY: &str = "default-src 'self'; script-src 'self'; \
13    style-src 'self'; style-src-attr 'unsafe-inline'; connect-src 'self'; img-src 'self'; \
14    font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; \
15    form-action 'self'";
16const PERMISSIONS_POLICY: &str = "accelerometer=(), autoplay=(), camera=(), \
17    clipboard-read=(), clipboard-write=(), display-capture=(), encrypted-media=(), \
18    fullscreen=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), midi=(), \
19    payment=(), picture-in-picture=(), publickey-credentials-get=(), screen-wake-lock=(), \
20    usb=(), web-share=(), xr-spatial-tracking=()";
21
22fn is_state_changing(method: &Method) -> bool {
23    !matches!(*method, Method::GET | Method::HEAD | Method::OPTIONS)
24}
25
26fn origin_is_allowed(
27    mode: ServerMode,
28    headers: &HeaderMap,
29    configured_origin: Option<&str>,
30) -> bool {
31    let mut origins = headers.get_all(header::ORIGIN).iter();
32    let origin = match (origins.next(), origins.next()) {
33        (Some(origin), None) => origin.to_str().ok(),
34        (None, None) => None,
35        _ => return false,
36    };
37
38    if mode == ServerMode::Demo {
39        return origin.is_some_and(|origin| configured_origin == Some(origin));
40    }
41
42    // Full mode predates browser-only operation and must retain CLI/curl compatibility. A Vite
43    // development page reaches the backend through its same-origin proxy, so its browser
44    // Origin is the Vite origin rather than the backend's configured origin. Fetch Metadata is
45    // browser-controlled; accept that narrow same-origin case while retaining the exact-origin
46    // check for clients that do not provide it and rejecting cross-site browser requests.
47    match headers
48        .get("sec-fetch-site")
49        .and_then(|value| value.to_str().ok())
50    {
51        Some(fetch_site) if fetch_site.eq_ignore_ascii_case("same-origin") => origin.is_some(),
52        Some(_) => origin.is_some_and(|origin| configured_origin == Some(origin)),
53        None => origin.is_none_or(|origin| configured_origin == Some(origin)),
54    }
55}
56
57fn response_is_private(mode: ServerMode, path: &str) -> bool {
58    mode == ServerMode::Demo || path == "/api" || path.starts_with("/api/")
59}
60
61fn is_scalar_docs(path: &str) -> bool {
62    path == "/api/docs" || path.starts_with("/api/docs/")
63}
64
65fn apply_security_headers(response: &mut Response, include_csp: bool) {
66    let headers = response.headers_mut();
67    if include_csp {
68        headers.insert(
69            "content-security-policy",
70            HeaderValue::from_static(CONTENT_SECURITY_POLICY),
71        );
72    }
73    headers.insert("x-frame-options", HeaderValue::from_static("DENY"));
74    headers.insert(
75        "x-content-type-options",
76        HeaderValue::from_static("nosniff"),
77    );
78    headers.insert("referrer-policy", HeaderValue::from_static("no-referrer"));
79    headers.insert(
80        "cross-origin-resource-policy",
81        HeaderValue::from_static("same-origin"),
82    );
83    headers.insert(
84        "cross-origin-opener-policy",
85        HeaderValue::from_static("same-origin"),
86    );
87    headers.insert(
88        "permissions-policy",
89        HeaderValue::from_static(PERMISSIONS_POLICY),
90    );
91}
92
93fn apply_private_response_headers(response: &mut Response) {
94    let headers = response.headers_mut();
95    headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
96    headers.append(header::VARY, HeaderValue::from_static("Cookie"));
97    headers.insert(
98        "x-robots-tag",
99        HeaderValue::from_static("noindex, nofollow, noarchive"),
100    );
101}
102
103/// Reject cross-site state-changing browser requests and protect public Demo/private API
104/// responses.
105///
106/// Demo mode requires the exact configured Origin on every state-changing request. Full mode
107/// retains CLI/curl compatibility, accepts a browser-controlled `same-origin` request from the
108/// Vite development proxy, and otherwise requires the configured Origin. This middleware never
109/// emits CORS response headers.
110///
111/// Baseline browser protections apply to every response. Demo responses additionally receive
112/// private/no-index caching headers on every path, including the embedded SPA; Full mode
113/// receives those privacy headers on `/api` only so immutable static-asset caching is preserved.
114/// Full mode's existing Scalar documentation page is the sole CSP exception because its upstream
115/// HTML loads the Scalar bundle from jsDelivr; Demo mode does not expose that page.
116pub async fn origin_and_security_headers(
117    State(state): State<crate::state::AppState>,
118    request: Request<Body>,
119    next: Next,
120) -> Response {
121    let path = request.uri().path();
122    let private_response = response_is_private(state.mode, path);
123    let include_csp = state.mode == ServerMode::Demo || !is_scalar_docs(path);
124    if is_state_changing(request.method())
125        && !origin_is_allowed(
126            state.mode,
127            request.headers(),
128            state.allowed_origin.as_deref(),
129        )
130    {
131        let mut response =
132            crate::error::ApiError::Forbidden("cross-origin request rejected".into())
133                .into_response();
134        apply_security_headers(&mut response, include_csp);
135        if private_response {
136            apply_private_response_headers(&mut response);
137        }
138        return response;
139    }
140
141    let mut response = next.run(request).await;
142    apply_security_headers(&mut response, include_csp);
143    if private_response {
144        apply_private_response_headers(&mut response);
145    }
146    response
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use axum::{
153        Router,
154        body::to_bytes,
155        http::{Request, StatusCode},
156        middleware,
157        routing::{get, post},
158    };
159    use tower::ServiceExt;
160
161    #[test]
162    fn demo_origin_check_requires_an_exact_match_and_rejects_missing_origin() {
163        let mut headers = HeaderMap::new();
164        headers.insert(
165            header::ORIGIN,
166            HeaderValue::from_static("https://bhtunedemo.bytehound.ca"),
167        );
168        assert!(origin_is_allowed(
169            ServerMode::Demo,
170            &headers,
171            Some("https://bhtunedemo.bytehound.ca")
172        ));
173
174        headers.insert(
175            header::ORIGIN,
176            HeaderValue::from_static("https://bhtunedemo.bytehound.ca.attacker.example"),
177        );
178        assert!(!origin_is_allowed(
179            ServerMode::Demo,
180            &headers,
181            Some("https://bhtunedemo.bytehound.ca")
182        ));
183
184        headers.insert(
185            header::ORIGIN,
186            HeaderValue::from_static("http://bhtunedemo.bytehound.ca"),
187        );
188        assert!(!origin_is_allowed(
189            ServerMode::Demo,
190            &headers,
191            Some("https://bhtunedemo.bytehound.ca")
192        ));
193        assert!(!origin_is_allowed(ServerMode::Demo, &headers, None));
194
195        headers.remove(header::ORIGIN);
196        assert!(!origin_is_allowed(
197            ServerMode::Demo,
198            &headers,
199            Some("https://bhtunedemo.bytehound.ca")
200        ));
201
202        headers.append(
203            header::ORIGIN,
204            HeaderValue::from_static("https://bhtunedemo.bytehound.ca"),
205        );
206        headers.append(
207            header::ORIGIN,
208            HeaderValue::from_static("https://bhtunedemo.bytehound.ca"),
209        );
210        assert!(!origin_is_allowed(
211            ServerMode::Demo,
212            &headers,
213            Some("https://bhtunedemo.bytehound.ca")
214        ));
215    }
216
217    #[test]
218    fn full_mode_preserves_cli_and_vite_proxy_mutations() {
219        let mut headers = HeaderMap::new();
220        assert!(origin_is_allowed(
221            ServerMode::Full,
222            &headers,
223            Some("http://127.0.0.1:8787")
224        ));
225
226        headers.insert("sec-fetch-site", HeaderValue::from_static("same-origin"));
227        assert!(!origin_is_allowed(
228            ServerMode::Full,
229            &headers,
230            Some("http://127.0.0.1:8787")
231        ));
232
233        headers.insert(header::ORIGIN, HeaderValue::from_static("http://asus:5173"));
234        assert!(origin_is_allowed(
235            ServerMode::Full,
236            &headers,
237            Some("http://127.0.0.1:8787")
238        ));
239
240        headers.insert("sec-fetch-site", HeaderValue::from_static("cross-site"));
241        assert!(!origin_is_allowed(
242            ServerMode::Full,
243            &headers,
244            Some("http://127.0.0.1:8787")
245        ));
246
247        headers.insert("sec-fetch-site", HeaderValue::from_static("same-origin"));
248        headers.remove(header::ORIGIN);
249        assert!(!origin_is_allowed(
250            ServerMode::Full,
251            &headers,
252            Some("http://127.0.0.1:8787")
253        ));
254    }
255
256    async fn app(mode: ServerMode) -> Router {
257        let mut state = crate::test_support::in_memory_state().await;
258        state.mode = mode;
259        state.allowed_origin = Some("https://bhtunedemo.bytehound.ca".into());
260        Router::new()
261            .route("/api/change", post(|| async { StatusCode::NO_CONTENT }))
262            .route(
263                "/api/protected",
264                get(|| async {
265                    (
266                        [
267                            (header::CACHE_CONTROL, "public, max-age=60"),
268                            (header::VARY, "Accept-Encoding"),
269                        ],
270                        "ok",
271                    )
272                }),
273            )
274            .route(
275                "/asset.js",
276                get(|| async { ([(header::CACHE_CONTROL, "public, immutable")], "asset") }),
277            )
278            .route("/api/docs", get(|| async { "Scalar" }))
279            .layer(middleware::from_fn_with_state(
280                state.clone(),
281                origin_and_security_headers,
282            ))
283            .with_state(state)
284    }
285
286    #[tokio::test]
287    async fn demo_mutation_requires_the_configured_origin() {
288        let app = app(ServerMode::Demo).await;
289        let missing = app
290            .clone()
291            .oneshot(Request::post("/api/change").body(Body::empty()).unwrap())
292            .await
293            .unwrap();
294        assert_eq!(missing.status(), StatusCode::FORBIDDEN);
295        assert_eq!(missing.headers()[header::CACHE_CONTROL], "no-store");
296        assert_eq!(missing.headers()["x-frame-options"], "DENY");
297        assert_eq!(
298            serde_json::from_slice::<serde_json::Value>(
299                &to_bytes(missing.into_body(), usize::MAX).await.unwrap()
300            )
301            .unwrap(),
302            serde_json::json!({"error": "cross-origin request rejected"})
303        );
304
305        let exact = app
306            .oneshot(
307                Request::post("/api/change")
308                    .header(header::ORIGIN, "https://bhtunedemo.bytehound.ca")
309                    .body(Body::empty())
310                    .unwrap(),
311            )
312            .await
313            .unwrap();
314        assert_eq!(exact.status(), StatusCode::NO_CONTENT);
315    }
316
317    #[tokio::test]
318    async fn full_mode_allows_vite_proxy_mutations_without_widening_cross_site_access() {
319        let app = app(ServerMode::Full).await;
320        let cli = app
321            .clone()
322            .oneshot(Request::post("/api/change").body(Body::empty()).unwrap())
323            .await
324            .unwrap();
325        assert_eq!(cli.status(), StatusCode::NO_CONTENT);
326
327        let vite = app
328            .clone()
329            .oneshot(
330                Request::post("/api/change")
331                    .header("sec-fetch-site", "same-origin")
332                    .header(header::ORIGIN, "http://asus:5173")
333                    .body(Body::empty())
334                    .unwrap(),
335            )
336            .await
337            .unwrap();
338        assert_eq!(vite.status(), StatusCode::NO_CONTENT);
339
340        let cross_site = app
341            .oneshot(
342                Request::post("/api/change")
343                    .header("sec-fetch-site", "cross-site")
344                    .header(header::ORIGIN, "https://attacker.example")
345                    .body(Body::empty())
346                    .unwrap(),
347            )
348            .await
349            .unwrap();
350        assert_eq!(cross_site.status(), StatusCode::FORBIDDEN);
351    }
352
353    #[tokio::test]
354    async fn protected_responses_receive_security_headers_without_enabling_cors() {
355        let response = app(ServerMode::Demo)
356            .await
357            .oneshot(Request::get("/api/protected").body(Body::empty()).unwrap())
358            .await
359            .unwrap();
360        let headers = response.headers();
361        assert_eq!(headers[header::CACHE_CONTROL], "no-store");
362        let vary = headers
363            .get_all(header::VARY)
364            .iter()
365            .map(|value| value.to_str().unwrap())
366            .collect::<Vec<_>>();
367        assert_eq!(vary, ["Accept-Encoding", "Cookie"]);
368        assert_eq!(headers["x-frame-options"], "DENY");
369        assert_eq!(headers["x-content-type-options"], "nosniff");
370        assert_eq!(headers["referrer-policy"], "no-referrer");
371        assert_eq!(headers["cross-origin-resource-policy"], "same-origin");
372        assert_eq!(headers["cross-origin-opener-policy"], "same-origin");
373        assert_eq!(headers["x-robots-tag"], "noindex, nofollow, noarchive");
374        assert!(
375            headers["permissions-policy"]
376                .to_str()
377                .unwrap()
378                .contains("camera=()")
379        );
380        let csp = headers["content-security-policy"].to_str().unwrap();
381        for directive in [
382            "script-src 'self'",
383            "style-src 'self'",
384            "style-src-attr 'unsafe-inline'",
385            "connect-src 'self'",
386            "img-src 'self'",
387            "font-src 'self'",
388            "object-src 'none'",
389            "base-uri 'self'",
390            "frame-ancestors 'none'",
391        ] {
392            assert!(
393                csp.contains(directive),
394                "missing CSP directive: {directive}"
395            );
396        }
397        assert!(!headers.contains_key(header::ACCESS_CONTROL_ALLOW_ORIGIN));
398        assert!(!headers.contains_key(header::ACCESS_CONTROL_ALLOW_CREDENTIALS));
399    }
400
401    #[tokio::test]
402    async fn full_mode_preserves_scalar_without_weakening_demo_csp() {
403        let full = app(ServerMode::Full)
404            .await
405            .oneshot(Request::get("/api/docs").body(Body::empty()).unwrap())
406            .await
407            .unwrap();
408        assert!(!full.headers().contains_key("content-security-policy"));
409        assert_eq!(full.headers()["x-frame-options"], "DENY");
410
411        let demo = app(ServerMode::Demo)
412            .await
413            .oneshot(Request::get("/api/docs").body(Body::empty()).unwrap())
414            .await
415            .unwrap();
416        assert!(demo.headers().contains_key("content-security-policy"));
417    }
418
419    #[tokio::test]
420    async fn full_mode_api_responses_receive_private_response_headers() {
421        let response = app(ServerMode::Full)
422            .await
423            .oneshot(Request::get("/api/protected").body(Body::empty()).unwrap())
424            .await
425            .unwrap();
426        assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
427        assert_eq!(
428            response.headers()["x-robots-tag"],
429            "noindex, nofollow, noarchive"
430        );
431    }
432
433    #[tokio::test]
434    async fn demo_static_responses_are_private_and_no_index() {
435        let response = app(ServerMode::Demo)
436            .await
437            .oneshot(Request::get("/asset.js").body(Body::empty()).unwrap())
438            .await
439            .unwrap();
440        assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
441        assert_eq!(
442            response.headers()["x-robots-tag"],
443            "noindex, nofollow, noarchive"
444        );
445        assert!(response.headers().contains_key("content-security-policy"));
446    }
447
448    #[tokio::test]
449    async fn demo_router_protects_the_embedded_spa_fallback() {
450        let mut state = crate::test_support::in_memory_state().await;
451        state.mode = ServerMode::Demo;
452        state.allowed_origin = Some("https://bhtunedemo.bytehound.ca".into());
453        let response = crate::build_router(state)
454            .oneshot(
455                Request::get("/client-side-route")
456                    .body(Body::empty())
457                    .unwrap(),
458            )
459            .await
460            .unwrap();
461        assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
462        assert_eq!(response.headers()["x-frame-options"], "DENY");
463        assert!(response.headers().contains_key("content-security-policy"));
464    }
465
466    #[tokio::test]
467    async fn full_mode_keeps_non_api_asset_caching_unchanged() {
468        let response = app(ServerMode::Full)
469            .await
470            .oneshot(Request::get("/asset.js").body(Body::empty()).unwrap())
471            .await
472            .unwrap();
473        assert_eq!(
474            response.headers()[header::CACHE_CONTROL],
475            "public, immutable"
476        );
477        assert!(response.headers().contains_key("content-security-policy"));
478        assert!(!response.headers().contains_key("x-robots-tag"));
479    }
480}