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
11const 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 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
192pub 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}