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
10const 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 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
103pub 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}