1use std::{collections::HashMap, time::Duration};
2
3use ic_http_certification::HeaderField;
4
5pub struct CacheControl {
13 pub static_assets: String,
16
17 pub dynamic_assets: String,
20}
21
22impl Default for CacheControl {
23 fn default() -> Self {
24 Self {
25 static_assets: "public, max-age=31536000, immutable".into(),
26 dynamic_assets: "public, no-cache, no-store".into(),
27 }
28 }
29}
30
31#[derive(Default)]
36pub struct CacheConfig {
37 pub default_ttl: Option<Duration>,
40 pub per_route_ttl: HashMap<String, Duration>,
43}
44
45impl CacheConfig {
46 pub fn effective_ttl(&self, path: &str) -> Option<Duration> {
50 self.per_route_ttl.get(path).copied().or(self.default_ttl)
51 }
52}
53
54pub struct SecurityHeaders {
64 pub hsts: Option<String>,
67
68 pub csp: Option<String>,
72
73 pub content_type_options: Option<String>,
76
77 pub frame_options: Option<String>,
80
81 pub referrer_policy: Option<String>,
84
85 pub permissions_policy: Option<String>,
89
90 pub coep: Option<String>,
94
95 pub coop: Option<String>,
98
99 pub corp: Option<String>,
103
104 pub dns_prefetch_control: Option<String>,
107
108 pub permitted_cross_domain_policies: Option<String>,
111}
112
113impl SecurityHeaders {
114 pub fn strict() -> Self {
133 Self {
134 hsts: Some("max-age=31536000; includeSubDomains".into()),
135 csp: None,
136 content_type_options: Some("nosniff".into()),
137 frame_options: Some("DENY".into()),
138 referrer_policy: Some("no-referrer".into()),
139 permissions_policy: Some(
140 "accelerometer=(), camera=(), geolocation=(), gyroscope=(), \
141 magnetometer=(), microphone=(), payment=(), usb=(), interest-cohort=()"
142 .into(),
143 ),
144 coep: Some("require-corp".into()),
145 coop: Some("same-origin".into()),
146 corp: Some("same-origin".into()),
147 dns_prefetch_control: Some("off".into()),
148 permitted_cross_domain_policies: Some("none".into()),
149 }
150 }
151
152 pub fn permissive() -> Self {
172 Self {
173 hsts: Some("max-age=31536000; includeSubDomains".into()),
174 csp: None,
175 content_type_options: Some("nosniff".into()),
176 frame_options: Some("SAMEORIGIN".into()),
177 referrer_policy: Some("strict-origin-when-cross-origin".into()),
178 permissions_policy: None,
179 coep: None,
180 coop: Some("same-origin-allow-popups".into()),
181 corp: Some("cross-origin".into()),
182 dns_prefetch_control: None,
183 permitted_cross_domain_policies: Some("none".into()),
184 }
185 }
186
187 pub fn none() -> Self {
198 Self {
199 hsts: None,
200 csp: None,
201 content_type_options: None,
202 frame_options: None,
203 referrer_policy: None,
204 permissions_policy: None,
205 coep: None,
206 coop: None,
207 corp: None,
208 dns_prefetch_control: None,
209 permitted_cross_domain_policies: None,
210 }
211 }
212
213 pub fn to_headers(&self) -> Vec<HeaderField> {
217 let mut headers = Vec::new();
218
219 if let Some(ref v) = self.hsts {
220 headers.push(("strict-transport-security".to_string(), v.clone()));
221 }
222 if let Some(ref v) = self.csp {
223 headers.push(("content-security-policy".to_string(), v.clone()));
224 }
225 if let Some(ref v) = self.content_type_options {
226 headers.push(("x-content-type-options".to_string(), v.clone()));
227 }
228 if let Some(ref v) = self.frame_options {
229 headers.push(("x-frame-options".to_string(), v.clone()));
230 }
231 if let Some(ref v) = self.referrer_policy {
232 headers.push(("referrer-policy".to_string(), v.clone()));
233 }
234 if let Some(ref v) = self.permissions_policy {
235 headers.push(("permissions-policy".to_string(), v.clone()));
236 }
237 if let Some(ref v) = self.coep {
238 headers.push(("cross-origin-embedder-policy".to_string(), v.clone()));
239 }
240 if let Some(ref v) = self.coop {
241 headers.push(("cross-origin-opener-policy".to_string(), v.clone()));
242 }
243 if let Some(ref v) = self.corp {
244 headers.push(("cross-origin-resource-policy".to_string(), v.clone()));
245 }
246 if let Some(ref v) = self.dns_prefetch_control {
247 headers.push(("x-dns-prefetch-control".to_string(), v.clone()));
248 }
249 if let Some(ref v) = self.permitted_cross_domain_policies {
250 headers.push(("x-permitted-cross-domain-policies".to_string(), v.clone()));
251 }
252
253 headers
254 }
255}
256
257impl Default for SecurityHeaders {
258 fn default() -> Self {
261 Self::permissive()
262 }
263}
264
265#[derive(Default)]
285pub struct AssetConfig {
286 pub security_headers: SecurityHeaders,
290
291 pub cache_control: CacheControl,
293
294 pub cache_config: CacheConfig,
296
297 pub custom_headers: Vec<HeaderField>,
302}
303
304impl AssetConfig {
305 pub fn merged_headers(&self, additional_headers: Vec<HeaderField>) -> Vec<HeaderField> {
315 let mut merged: Vec<HeaderField> = Vec::new();
316
317 for h in self.security_headers.to_headers() {
319 merged.push(h);
320 }
321
322 for h in &self.custom_headers {
324 let name_lower = h.0.to_lowercase();
325 merged.retain(|(k, _)| k.to_lowercase() != name_lower);
326 merged.push(h.clone());
327 }
328
329 for h in &additional_headers {
331 let name_lower = h.0.to_lowercase();
332 merged.retain(|(k, _)| k.to_lowercase() != name_lower);
333 merged.push(h.clone());
334 }
335
336 merged
337 }
338}
339
340#[cfg(test)]
354mod tests {
355 use super::*;
356
357 #[test]
360 fn strict_produces_expected_headers() {
361 let headers = SecurityHeaders::strict().to_headers();
362 let names: Vec<&str> = headers.iter().map(|(k, _)| k.as_str()).collect();
363
364 assert!(names.contains(&"strict-transport-security"));
365 assert!(names.contains(&"x-content-type-options"));
366 assert!(names.contains(&"x-frame-options"));
367 assert!(names.contains(&"referrer-policy"));
368 assert!(names.contains(&"permissions-policy"));
369 assert!(names.contains(&"cross-origin-embedder-policy"));
370 assert!(names.contains(&"cross-origin-opener-policy"));
371 assert!(names.contains(&"cross-origin-resource-policy"));
372 assert!(names.contains(&"x-dns-prefetch-control"));
373 assert!(names.contains(&"x-permitted-cross-domain-policies"));
374
375 assert!(!names.contains(&"content-security-policy"));
377
378 let find =
380 |name: &str| -> String { headers.iter().find(|(k, _)| k == name).unwrap().1.clone() };
381
382 assert_eq!(find("x-frame-options"), "DENY");
383 assert_eq!(find("referrer-policy"), "no-referrer");
384 assert_eq!(find("cross-origin-embedder-policy"), "require-corp");
385 assert_eq!(find("cross-origin-opener-policy"), "same-origin");
386 assert_eq!(find("cross-origin-resource-policy"), "same-origin");
387 assert_eq!(find("x-dns-prefetch-control"), "off");
388 assert_eq!(find("x-permitted-cross-domain-policies"), "none");
389 }
390
391 #[test]
394 fn permissive_produces_expected_headers() {
395 let headers = SecurityHeaders::permissive().to_headers();
396 let names: Vec<&str> = headers.iter().map(|(k, _)| k.as_str()).collect();
397
398 assert!(names.contains(&"strict-transport-security"));
399 assert!(names.contains(&"x-content-type-options"));
400 assert!(names.contains(&"x-frame-options"));
401 assert!(names.contains(&"referrer-policy"));
402 assert!(names.contains(&"cross-origin-opener-policy"));
403 assert!(names.contains(&"cross-origin-resource-policy"));
404 assert!(names.contains(&"x-permitted-cross-domain-policies"));
405
406 assert!(!names.contains(&"permissions-policy"));
408 assert!(!names.contains(&"cross-origin-embedder-policy"));
409 assert!(!names.contains(&"content-security-policy"));
410 assert!(!names.contains(&"x-dns-prefetch-control"));
411
412 let find =
413 |name: &str| -> String { headers.iter().find(|(k, _)| k == name).unwrap().1.clone() };
414
415 assert_eq!(find("x-frame-options"), "SAMEORIGIN");
416 assert_eq!(find("referrer-policy"), "strict-origin-when-cross-origin");
417 assert_eq!(
418 find("cross-origin-opener-policy"),
419 "same-origin-allow-popups"
420 );
421 assert_eq!(find("cross-origin-resource-policy"), "cross-origin");
422 }
423
424 #[test]
427 fn none_produces_zero_headers() {
428 let headers = SecurityHeaders::none().to_headers();
429 assert!(headers.is_empty());
430 }
431
432 #[test]
435 fn custom_headers_override_security_headers() {
436 let config = AssetConfig {
437 security_headers: SecurityHeaders::strict(),
438 cache_control: CacheControl::default(),
439 cache_config: CacheConfig::default(),
440 custom_headers: vec![("x-frame-options".to_string(), "SAMEORIGIN".to_string())],
441 };
442 let merged = config.merged_headers(vec![]);
443 let frame_opts: Vec<_> = merged
444 .iter()
445 .filter(|(k, _)| k == "x-frame-options")
446 .collect();
447 assert_eq!(frame_opts.len(), 1);
448 assert_eq!(frame_opts[0].1, "SAMEORIGIN");
449 }
450
451 #[test]
452 fn additional_headers_override_custom_and_security() {
453 let config = AssetConfig {
454 security_headers: SecurityHeaders::strict(),
455 cache_control: CacheControl::default(),
456 cache_config: CacheConfig::default(),
457 custom_headers: vec![("x-frame-options".to_string(), "SAMEORIGIN".to_string())],
458 };
459 let merged = config.merged_headers(vec![(
460 "X-Frame-Options".to_string(),
461 "ALLOW-FROM https://example.com".to_string(),
462 )]);
463 let frame_opts: Vec<_> = merged
464 .iter()
465 .filter(|(k, _)| k.to_lowercase() == "x-frame-options")
466 .collect();
467 assert_eq!(frame_opts.len(), 1);
468 assert_eq!(frame_opts[0].1, "ALLOW-FROM https://example.com");
469 }
470
471 #[test]
474 fn xss_protection_never_set() {
475 for headers in [
476 SecurityHeaders::strict().to_headers(),
477 SecurityHeaders::permissive().to_headers(),
478 SecurityHeaders::none().to_headers(),
479 ] {
480 assert!(
481 headers
482 .iter()
483 .all(|(k, _)| k.to_lowercase() != "x-xss-protection"),
484 "X-XSS-Protection should never be set by any preset"
485 );
486 }
487 }
488
489 #[test]
490 fn default_is_permissive() {
491 let default_headers = SecurityHeaders::default().to_headers();
492 let permissive_headers = SecurityHeaders::permissive().to_headers();
493 assert_eq!(default_headers, permissive_headers);
494 }
495
496 #[test]
499 fn default_cache_control_reproduces_current_behavior() {
500 let cc = CacheControl::default();
501 assert_eq!(cc.static_assets, "public, max-age=31536000, immutable");
502 assert_eq!(cc.dynamic_assets, "public, no-cache, no-store");
503 }
504
505 #[test]
508 fn custom_static_cache_control() {
509 let cc = CacheControl {
510 static_assets: "public, max-age=3600".into(),
511 ..CacheControl::default()
512 };
513 assert_eq!(cc.static_assets, "public, max-age=3600");
514 assert_eq!(cc.dynamic_assets, "public, no-cache, no-store");
516 }
517
518 #[test]
521 fn custom_dynamic_cache_control() {
522 let cc = CacheControl {
523 dynamic_assets: "public, max-age=600".into(),
524 ..CacheControl::default()
525 };
526 assert_eq!(cc.dynamic_assets, "public, max-age=600");
527 assert_eq!(cc.static_assets, "public, max-age=31536000, immutable");
529 }
530
531 #[test]
532 fn asset_config_default_includes_default_cache_control() {
533 let config = AssetConfig::default();
534 assert_eq!(
535 config.cache_control.static_assets,
536 "public, max-age=31536000, immutable"
537 );
538 assert_eq!(
539 config.cache_control.dynamic_assets,
540 "public, no-cache, no-store"
541 );
542 }
543
544 #[test]
547 fn cache_config_default_has_none_and_empty() {
548 let cc = CacheConfig::default();
549 assert!(cc.default_ttl.is_none());
550 assert!(cc.per_route_ttl.is_empty());
551 }
552
553 #[test]
556 fn per_route_ttl_overrides_default_ttl() {
557 let cc = CacheConfig {
558 default_ttl: Some(Duration::from_secs(3600)),
559 per_route_ttl: HashMap::from([("/posts/1".to_string(), Duration::from_secs(60))]),
560 };
561 assert_eq!(cc.effective_ttl("/posts/1"), Some(Duration::from_secs(60)));
563 assert_eq!(cc.effective_ttl("/about"), Some(Duration::from_secs(3600)));
565 let cc_no_default = CacheConfig {
567 default_ttl: None,
568 per_route_ttl: HashMap::from([("/posts/1".to_string(), Duration::from_secs(60))]),
569 };
570 assert_eq!(
571 cc_no_default.effective_ttl("/posts/1"),
572 Some(Duration::from_secs(60))
573 );
574 assert_eq!(cc_no_default.effective_ttl("/about"), None);
575 }
576
577 #[test]
581 fn merged_headers_later_overrides_earlier() {
582 let config = AssetConfig {
583 security_headers: SecurityHeaders::none(),
584 cache_control: CacheControl::default(),
585 cache_config: CacheConfig::default(),
586 custom_headers: vec![
587 ("x-custom".to_string(), "first".to_string()),
588 ("x-custom".to_string(), "second".to_string()),
589 ],
590 };
591 let merged = config.merged_headers(vec![]);
592 let custom: Vec<_> = merged.iter().filter(|(k, _)| k == "x-custom").collect();
593 assert_eq!(custom.len(), 1, "only one x-custom header should remain");
594 assert_eq!(custom[0].1, "second", "later value should win");
595 }
596
597 #[test]
599 fn merged_headers_case_insensitive_override() {
600 let config = AssetConfig {
601 security_headers: SecurityHeaders::none(),
602 cache_control: CacheControl::default(),
603 cache_config: CacheConfig::default(),
604 custom_headers: vec![("content-type".to_string(), "text/plain".to_string())],
605 };
606 let merged = config.merged_headers(vec![(
608 "Content-Type".to_string(),
609 "application/json".to_string(),
610 )]);
611 let ct: Vec<_> = merged
612 .iter()
613 .filter(|(k, _)| k.to_lowercase() == "content-type")
614 .collect();
615 assert_eq!(ct.len(), 1, "only one content-type header should remain");
616 assert_eq!(ct[0].1, "application/json", "additional header should win");
617 assert_eq!(ct[0].0, "Content-Type");
619 }
620}