1use std::collections::HashMap;
9
10#[derive(Debug, Clone)]
35pub struct SecurityHeaders {
36 pub csp: ContentSecurityPolicy,
38 pub frame_options: FrameOptions,
40 pub referrer_policy: ReferrerPolicy,
42 pub permissions_policy: String,
44}
45
46impl Default for SecurityHeaders {
47 fn default() -> Self {
48 Self {
49 csp: ContentSecurityPolicy::admin_default(),
50 frame_options: FrameOptions::Deny,
51 referrer_policy: ReferrerPolicy::StrictOriginWhenCrossOrigin,
52 permissions_policy: "camera=(), microphone=(), geolocation=(), payment=()".to_string(),
53 }
54 }
55}
56
57impl SecurityHeaders {
58 pub fn to_header_map(&self) -> HashMap<&'static str, String> {
73 let mut headers = HashMap::new();
74
75 headers.insert("Content-Security-Policy", self.csp.to_header_value());
76 headers.insert("X-Content-Type-Options", "nosniff".to_string());
77 headers.insert("X-Frame-Options", self.frame_options.to_string());
78 headers.insert("X-XSS-Protection", "1; mode=block".to_string());
79 headers.insert("Referrer-Policy", self.referrer_policy.to_string());
80 headers.insert("Permissions-Policy", self.permissions_policy.clone());
81
82 headers
83 }
84}
85
86#[derive(Debug, Clone)]
91pub struct ContentSecurityPolicy {
92 pub default_src: Vec<String>,
94 pub script_src: Vec<String>,
96 pub style_src: Vec<String>,
98 pub img_src: Vec<String>,
100 pub font_src: Vec<String>,
102 pub connect_src: Vec<String>,
104 pub frame_ancestors: Vec<String>,
106 pub base_uri: Vec<String>,
108 pub form_action: Vec<String>,
110}
111
112impl ContentSecurityPolicy {
113 pub fn admin_default() -> Self {
122 Self {
123 default_src: vec!["'self'".to_string()],
124 script_src: vec!["'self'".to_string(), "'wasm-unsafe-eval'".to_string()],
125 style_src: vec!["'self'".to_string(), "'unsafe-inline'".to_string()],
126 img_src: vec!["'self'".to_string(), "data:".to_string()],
127 font_src: vec!["'self'".to_string()],
128 connect_src: vec!["'self'".to_string()],
129 frame_ancestors: vec!["'none'".to_string()],
130 base_uri: vec!["'self'".to_string()],
131 form_action: vec!["'self'".to_string()],
132 }
133 }
134
135 fn to_header_value(&self) -> String {
137 let mut directives = Vec::new();
138
139 if !self.default_src.is_empty() {
140 directives.push(format!("default-src {}", self.default_src.join(" ")));
141 }
142 if !self.script_src.is_empty() {
143 directives.push(format!("script-src {}", self.script_src.join(" ")));
144 }
145 if !self.style_src.is_empty() {
146 directives.push(format!("style-src {}", self.style_src.join(" ")));
147 }
148 if !self.img_src.is_empty() {
149 directives.push(format!("img-src {}", self.img_src.join(" ")));
150 }
151 if !self.font_src.is_empty() {
152 directives.push(format!("font-src {}", self.font_src.join(" ")));
153 }
154 if !self.connect_src.is_empty() {
155 directives.push(format!("connect-src {}", self.connect_src.join(" ")));
156 }
157 if !self.frame_ancestors.is_empty() {
158 directives.push(format!(
159 "frame-ancestors {}",
160 self.frame_ancestors.join(" ")
161 ));
162 }
163 if !self.base_uri.is_empty() {
164 directives.push(format!("base-uri {}", self.base_uri.join(" ")));
165 }
166 if !self.form_action.is_empty() {
167 directives.push(format!("form-action {}", self.form_action.join(" ")));
168 }
169
170 directives.join("; ")
171 }
172}
173
174#[derive(Debug, Clone, Copy, PartialEq, Eq)]
176pub enum FrameOptions {
177 Deny,
179 SameOrigin,
181}
182
183impl std::fmt::Display for FrameOptions {
184 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
185 match self {
186 FrameOptions::Deny => write!(f, "DENY"),
187 FrameOptions::SameOrigin => write!(f, "SAMEORIGIN"),
188 }
189 }
190}
191
192#[derive(Debug, Clone, Copy, PartialEq, Eq)]
194pub enum ReferrerPolicy {
195 NoReferrer,
197 StrictOriginWhenCrossOrigin,
199 SameOrigin,
201}
202
203impl std::fmt::Display for ReferrerPolicy {
204 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
205 match self {
206 ReferrerPolicy::NoReferrer => write!(f, "no-referrer"),
207 ReferrerPolicy::StrictOriginWhenCrossOrigin => {
208 write!(f, "strict-origin-when-cross-origin")
209 }
210 ReferrerPolicy::SameOrigin => write!(f, "same-origin"),
211 }
212 }
213}
214
215impl std::str::FromStr for FrameOptions {
216 type Err = std::convert::Infallible;
217
218 fn from_str(s: &str) -> Result<Self, Self::Err> {
220 Ok(match s.to_lowercase().as_str() {
221 "deny" => Self::Deny,
222 "sameorigin" => Self::SameOrigin,
223 _ => Self::Deny,
224 })
225 }
226}
227
228impl std::str::FromStr for ReferrerPolicy {
229 type Err = std::convert::Infallible;
230
231 fn from_str(s: &str) -> Result<Self, Self::Err> {
234 Ok(match s.to_lowercase().as_str() {
235 "no-referrer" => Self::NoReferrer,
236 "strict-origin-when-cross-origin" => Self::StrictOriginWhenCrossOrigin,
237 "same-origin" => Self::SameOrigin,
238 _ => Self::StrictOriginWhenCrossOrigin,
239 })
240 }
241}
242
243const CSRF_TOKEN_BYTES: usize = 32;
245
246pub fn generate_csrf_token() -> String {
265 use base64::Engine;
266 let mut bytes = vec![0u8; CSRF_TOKEN_BYTES];
267 getrandom::fill(&mut bytes).expect("Failed to generate random bytes for CSRF token");
269 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&bytes)
270}
271
272pub fn validate_csrf_token(provided: &str, expected: &str) -> bool {
293 if provided.is_empty() || expected.is_empty() {
295 return false;
296 }
297
298 constant_time_eq(provided.as_bytes(), expected.as_bytes())
300}
301
302fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
308 use sha2::{Digest, Sha256};
309 use subtle::ConstantTimeEq;
310
311 let hash_a = Sha256::digest(a);
312 let hash_b = Sha256::digest(b);
313 hash_a.ct_eq(&hash_b).into()
314}
315
316pub const CSRF_HEADER_NAME: &str = "x-csrf-token";
318
319pub const PAGES_CSRF_HEADER_NAME: &str = "X-CSRFToken";
321
322pub const CSRF_COOKIE_NAME: &str = "csrftoken";
324
325#[cfg(server)]
333pub fn extract_csrf_header(headers: &hyper::HeaderMap) -> Option<String> {
334 headers
335 .get(CSRF_HEADER_NAME)
336 .or_else(|| headers.get(PAGES_CSRF_HEADER_NAME))
337 .and_then(|v| v.to_str().ok())
338 .map(|s| s.to_string())
339}
340
341#[cfg(server)]
350pub fn extract_csrf_cookie(headers: &hyper::HeaderMap) -> Option<String> {
351 headers
352 .get("cookie")
353 .and_then(|v| v.to_str().ok())
354 .and_then(|cookie_header| {
355 cookie_header.split(';').find_map(|pair| {
356 let pair = pair.trim();
357 let (name, value) = pair.split_once('=')?;
358 if name.trim() == CSRF_COOKIE_NAME {
359 Some(value.trim().to_string())
360 } else {
361 None
362 }
363 })
364 })
365}
366
367pub fn build_csrf_cookie(token: &str, is_secure: bool) -> String {
380 let secure_flag = if is_secure { "; Secure" } else { "" };
381 format!(
382 "{}={}; SameSite=Strict; Path=/admin{}",
383 CSRF_COOKIE_NAME, token, secure_flag
384 )
385}
386
387#[cfg(server)]
406pub fn require_csrf_token(
407 body_token: &str,
408 headers: &hyper::HeaderMap,
409) -> Result<(), reinhardt_pages::server_fn::ServerFnError> {
410 let expected_token = extract_csrf_cookie(headers)
414 .or_else(|| extract_csrf_header(headers))
415 .ok_or_else(|| {
416 reinhardt_pages::server_fn::ServerFnError::server(
417 403,
418 "CSRF token missing from cookie and header",
419 )
420 })?;
421
422 if !validate_csrf_token(body_token, &expected_token) {
423 return Err(reinhardt_pages::server_fn::ServerFnError::server(
424 403,
425 "CSRF token validation failed",
426 ));
427 }
428
429 Ok(())
430}
431
432#[cfg(server)]
434pub fn require_csrf_header(
435 headers: &hyper::HeaderMap,
436) -> Result<(), reinhardt_pages::server_fn::ServerFnError> {
437 let header_token = extract_csrf_header(headers).ok_or_else(|| {
438 reinhardt_pages::server_fn::ServerFnError::server(403, "CSRF token missing from header")
439 })?;
440
441 let expected_token = extract_csrf_cookie(headers).ok_or_else(|| {
442 reinhardt_pages::server_fn::ServerFnError::server(403, "CSRF token missing from cookie")
443 })?;
444 if !validate_csrf_token(&header_token, &expected_token) {
445 return Err(reinhardt_pages::server_fn::ServerFnError::server(
446 403,
447 "CSRF token validation failed",
448 ));
449 }
450
451 Ok(())
452}
453
454#[cfg(server)]
485pub fn sanitize_mutation_values(data: &mut HashMap<String, serde_json::Value>) {
486 for value in data.values_mut() {
487 sanitize_json_value(value);
488 }
489}
490
491#[cfg(server)]
493fn sanitize_json_value(value: &mut serde_json::Value) {
494 match value {
495 serde_json::Value::String(s) => {
496 if needs_html_escaping(s) {
497 *s = escape_html(s);
498 }
499 }
500 serde_json::Value::Array(arr) => {
501 for item in arr.iter_mut() {
502 sanitize_json_value(item);
503 }
504 }
505 serde_json::Value::Object(obj) => {
506 for val in obj.values_mut() {
507 sanitize_json_value(val);
508 }
509 }
510 _ => {}
512 }
513}
514
515#[cfg(server)]
517fn needs_html_escaping(s: &str) -> bool {
518 s.contains('<') || s.contains('>') || s.contains('&') || s.contains('"') || s.contains('\'')
519}
520
521#[cfg(server)]
523fn escape_html(input: &str) -> String {
524 reinhardt_core::security::escape_html(input)
525}
526
527pub const ADMIN_AUTH_COOKIE_NAME: &str = "reinhardt_admin_token";
535
536pub fn build_admin_auth_cookie(token: &str, is_secure: bool) -> String {
562 let secure_flag = if is_secure { "; Secure" } else { "" };
563 format!(
564 "{}={}; HttpOnly; SameSite=Strict; Path=/admin; Max-Age=86400{}",
565 ADMIN_AUTH_COOKIE_NAME, token, secure_flag
566 )
567}
568
569pub fn build_admin_auth_cookie_clear() -> String {
582 format!(
583 "{}=; HttpOnly; SameSite=Strict; Path=/admin; Max-Age=0",
584 ADMIN_AUTH_COOKIE_NAME
585 )
586}
587
588#[cfg(not(target_arch = "wasm32"))]
597pub fn extract_admin_auth_cookie(headers: &hyper::HeaderMap) -> Option<String> {
598 headers
599 .get("cookie")
600 .and_then(|v| v.to_str().ok())
601 .and_then(|cookie_header| {
602 cookie_header.split(';').find_map(|pair| {
603 let pair = pair.trim();
604 let (name, value) = pair.split_once('=')?;
605 if name.trim() == ADMIN_AUTH_COOKIE_NAME {
606 Some(value.trim().to_string())
607 } else {
608 None
609 }
610 })
611 })
612}
613
614#[cfg(all(test, server))]
615mod tests {
616 use super::*;
617 use rstest::rstest;
618
619 #[rstest]
624 fn test_security_headers_default_contains_all_headers() {
625 let headers = SecurityHeaders::default();
627
628 let map = headers.to_header_map();
630
631 assert!(map.contains_key("Content-Security-Policy"));
633 assert!(map.contains_key("X-Content-Type-Options"));
634 assert!(map.contains_key("X-Frame-Options"));
635 assert!(map.contains_key("X-XSS-Protection"));
636 assert!(map.contains_key("Referrer-Policy"));
637 assert!(map.contains_key("Permissions-Policy"));
638 }
639
640 #[rstest]
641 fn test_security_headers_x_content_type_options() {
642 let headers = SecurityHeaders::default();
644
645 let map = headers.to_header_map();
647
648 assert_eq!(map.get("X-Content-Type-Options").unwrap(), "nosniff");
650 }
651
652 #[rstest]
653 fn test_security_headers_x_frame_options_deny() {
654 let headers = SecurityHeaders::default();
656
657 let map = headers.to_header_map();
659
660 assert_eq!(map.get("X-Frame-Options").unwrap(), "DENY");
662 }
663
664 #[rstest]
665 fn test_security_headers_x_xss_protection() {
666 let headers = SecurityHeaders::default();
668
669 let map = headers.to_header_map();
671
672 assert_eq!(map.get("X-XSS-Protection").unwrap(), "1; mode=block");
674 }
675
676 #[rstest]
677 fn test_security_headers_referrer_policy() {
678 let headers = SecurityHeaders::default();
680
681 let map = headers.to_header_map();
683
684 assert_eq!(
686 map.get("Referrer-Policy").unwrap(),
687 "strict-origin-when-cross-origin"
688 );
689 }
690
691 #[rstest]
692 fn test_security_headers_permissions_policy() {
693 let headers = SecurityHeaders::default();
695
696 let map = headers.to_header_map();
698
699 let pp = map.get("Permissions-Policy").unwrap();
701 assert!(pp.contains("camera=()"));
702 assert!(pp.contains("microphone=()"));
703 assert!(pp.contains("geolocation=()"));
704 }
705
706 #[rstest]
711 fn test_csp_admin_default_contains_self() {
712 let csp = ContentSecurityPolicy::admin_default();
714
715 let csp_string = csp.to_header_value();
717
718 assert!(csp_string.contains("default-src 'self'"));
720 assert!(csp_string.contains("script-src 'self'"));
721 }
722
723 #[rstest]
724 fn test_csp_admin_default_prevents_framing() {
725 let csp = ContentSecurityPolicy::admin_default();
727
728 let csp_string = csp.to_header_value();
730
731 assert!(csp_string.contains("frame-ancestors 'none'"));
733 }
734
735 #[rstest]
736 fn test_csp_admin_default_allows_inline_styles() {
737 let csp = ContentSecurityPolicy::admin_default();
739
740 let csp_string = csp.to_header_value();
742
743 assert!(csp_string.contains("style-src 'self' 'unsafe-inline'"));
745 }
746
747 #[rstest]
748 fn test_csp_admin_default_restricts_form_action() {
749 let csp = ContentSecurityPolicy::admin_default();
751
752 let csp_string = csp.to_header_value();
754
755 assert!(csp_string.contains("form-action 'self'"));
757 }
758
759 #[rstest]
760 fn test_csp_admin_default_allows_wasm_eval() {
761 let csp = ContentSecurityPolicy::admin_default();
763
764 let csp_string = csp.to_header_value();
766
767 assert!(
769 csp_string.contains("'wasm-unsafe-eval'"),
770 "CSP should allow WASM evaluation for admin SPA, got: {}",
771 csp_string
772 );
773 }
774
775 #[rstest]
780 fn test_frame_options_deny() {
781 assert_eq!(FrameOptions::Deny.to_string(), "DENY");
783 }
784
785 #[rstest]
786 fn test_frame_options_same_origin() {
787 assert_eq!(FrameOptions::SameOrigin.to_string(), "SAMEORIGIN");
789 }
790
791 #[rstest]
796 fn test_referrer_policy_no_referrer() {
797 assert_eq!(ReferrerPolicy::NoReferrer.to_string(), "no-referrer");
799 }
800
801 #[rstest]
802 fn test_referrer_policy_strict_origin() {
803 assert_eq!(
805 ReferrerPolicy::StrictOriginWhenCrossOrigin.to_string(),
806 "strict-origin-when-cross-origin"
807 );
808 }
809
810 #[rstest]
811 fn test_referrer_policy_same_origin() {
812 assert_eq!(ReferrerPolicy::SameOrigin.to_string(), "same-origin");
814 }
815
816 #[rstest]
821 fn test_generate_csrf_token_is_non_empty() {
822 let token = generate_csrf_token();
824
825 assert!(!token.is_empty());
827 assert!(token.len() >= 32);
828 }
829
830 #[rstest]
831 fn test_generate_csrf_token_is_unique() {
832 let token1 = generate_csrf_token();
834 let token2 = generate_csrf_token();
835
836 assert_ne!(token1, token2);
838 }
839
840 #[rstest]
841 fn test_validate_csrf_token_with_matching_tokens() {
842 let token = generate_csrf_token();
844
845 assert!(validate_csrf_token(&token, &token));
847 }
848
849 #[rstest]
850 fn test_validate_csrf_token_with_mismatching_tokens() {
851 let token = generate_csrf_token();
853
854 assert!(!validate_csrf_token("invalid-token", &token));
856 }
857
858 #[rstest]
859 fn test_validate_csrf_token_rejects_empty_provided() {
860 let token = generate_csrf_token();
862
863 assert!(!validate_csrf_token("", &token));
865 }
866
867 #[rstest]
868 fn test_validate_csrf_token_rejects_empty_expected() {
869 assert!(!validate_csrf_token("some-token", ""));
871 }
872
873 #[rstest]
874 fn test_validate_csrf_token_rejects_both_empty() {
875 assert!(!validate_csrf_token("", ""));
877 }
878
879 #[rstest]
884 fn test_constant_time_eq_equal() {
885 assert!(constant_time_eq(b"hello", b"hello"));
887 }
888
889 #[rstest]
890 fn test_constant_time_eq_different_content() {
891 assert!(!constant_time_eq(b"hello", b"world"));
893 }
894
895 #[rstest]
896 fn test_constant_time_eq_different_length() {
897 assert!(!constant_time_eq(b"hello", b"hi"));
899 }
900
901 #[rstest]
902 fn test_constant_time_eq_empty() {
903 assert!(constant_time_eq(b"", b""));
905 }
906
907 #[rstest]
912 fn test_sanitize_mutation_values_escapes_script_tags() {
913 let mut data = HashMap::new();
915 data.insert(
916 "name".to_string(),
917 serde_json::json!("<script>alert('xss')</script>"),
918 );
919
920 sanitize_mutation_values(&mut data);
922
923 let name = data.get("name").unwrap().as_str().unwrap();
925 assert!(!name.contains("<script>"));
926 assert!(name.contains("<script>"));
927 }
928
929 #[rstest]
930 fn test_sanitize_mutation_values_preserves_non_string_values() {
931 let mut data = HashMap::new();
933 data.insert("age".to_string(), serde_json::json!(25));
934 data.insert("active".to_string(), serde_json::json!(true));
935 data.insert("tags".to_string(), serde_json::json!(null));
936
937 sanitize_mutation_values(&mut data);
939
940 assert_eq!(data.get("age").unwrap().as_i64().unwrap(), 25);
942 assert_eq!(data.get("active").unwrap().as_bool().unwrap(), true);
943 assert!(data.get("tags").unwrap().is_null());
944 }
945
946 #[rstest]
947 fn test_sanitize_mutation_values_handles_nested_arrays() {
948 let mut data = HashMap::new();
950 data.insert(
951 "items".to_string(),
952 serde_json::json!(["<b>bold</b>", "safe text"]),
953 );
954
955 sanitize_mutation_values(&mut data);
957
958 let items = data.get("items").unwrap().as_array().unwrap();
960 assert_eq!(items[0].as_str().unwrap(), "<b>bold</b>");
961 assert_eq!(items[1].as_str().unwrap(), "safe text");
962 }
963
964 #[rstest]
965 fn test_sanitize_mutation_values_handles_nested_objects() {
966 let mut data = HashMap::new();
968 data.insert(
969 "metadata".to_string(),
970 serde_json::json!({"bio": "<img onerror=alert(1)>"}),
971 );
972
973 sanitize_mutation_values(&mut data);
975
976 let meta = data.get("metadata").unwrap().as_object().unwrap();
978 let bio = meta.get("bio").unwrap().as_str().unwrap();
979 assert!(!bio.contains("<img"));
980 assert!(bio.contains("<img"));
981 }
982
983 #[rstest]
984 fn test_sanitize_mutation_values_safe_strings_unchanged() {
985 let mut data = HashMap::new();
987 data.insert("name".to_string(), serde_json::json!("Alice Johnson"));
988 data.insert("email".to_string(), serde_json::json!("alice@example.com"));
989
990 sanitize_mutation_values(&mut data);
992
993 assert_eq!(data.get("name").unwrap().as_str().unwrap(), "Alice Johnson");
995 assert_eq!(
996 data.get("email").unwrap().as_str().unwrap(),
997 "alice@example.com"
998 );
999 }
1000
1001 #[rstest]
1002 fn test_escape_html_special_characters() {
1003 assert_eq!(escape_html("<"), "<");
1005 assert_eq!(escape_html(">"), ">");
1006 assert_eq!(escape_html("&"), "&");
1007 assert_eq!(escape_html("\""), """);
1008 assert_eq!(escape_html("'"), "'");
1009 }
1010
1011 #[rstest]
1012 fn test_needs_html_escaping_detects_dangerous_chars() {
1013 assert!(needs_html_escaping("<script>"));
1015 assert!(needs_html_escaping("a > b"));
1016 assert!(needs_html_escaping("a & b"));
1017 assert!(needs_html_escaping("a\"b"));
1018 assert!(needs_html_escaping("a'b"));
1019 assert!(!needs_html_escaping("safe text"));
1020 assert!(!needs_html_escaping("hello world 123"));
1021 }
1022
1023 #[rstest]
1028 fn test_extract_csrf_header_present() {
1029 let mut headers = hyper::HeaderMap::new();
1031 headers.insert("x-csrf-token", "test-token".parse().unwrap());
1032
1033 let result = extract_csrf_header(&headers);
1035
1036 assert_eq!(result, Some("test-token".to_string()));
1038 }
1039
1040 #[rstest]
1041 fn test_extract_csrf_header_missing() {
1042 let headers = hyper::HeaderMap::new();
1044
1045 let result = extract_csrf_header(&headers);
1047
1048 assert_eq!(result, None);
1050 }
1051
1052 #[rstest]
1057 fn test_extract_csrf_cookie_present() {
1058 let mut headers = hyper::HeaderMap::new();
1060 headers.insert(
1061 "cookie",
1062 "session=abc; csrftoken=test-token-value; other=xyz"
1063 .parse()
1064 .unwrap(),
1065 );
1066
1067 let result = extract_csrf_cookie(&headers);
1069
1070 assert_eq!(result, Some("test-token-value".to_string()));
1072 }
1073
1074 #[rstest]
1075 fn test_extract_csrf_cookie_missing() {
1076 let mut headers = hyper::HeaderMap::new();
1078 headers.insert("cookie", "session=abc; other=xyz".parse().unwrap());
1079
1080 let result = extract_csrf_cookie(&headers);
1082
1083 assert_eq!(result, None);
1085 }
1086
1087 #[rstest]
1088 fn test_extract_csrf_cookie_no_cookie_header() {
1089 let headers = hyper::HeaderMap::new();
1091
1092 let result = extract_csrf_cookie(&headers);
1094
1095 assert_eq!(result, None);
1097 }
1098
1099 #[rstest]
1100 fn test_extract_csrf_cookie_only_csrf() {
1101 let mut headers = hyper::HeaderMap::new();
1103 headers.insert("cookie", "csrftoken=solo-value".parse().unwrap());
1104
1105 let result = extract_csrf_cookie(&headers);
1107
1108 assert_eq!(result, Some("solo-value".to_string()));
1110 }
1111
1112 #[rstest]
1117 fn test_build_csrf_cookie_secure() {
1118 let cookie = build_csrf_cookie("token123", true);
1120
1121 assert_eq!(
1123 cookie,
1124 "csrftoken=token123; SameSite=Strict; Path=/admin; Secure"
1125 );
1126 }
1127
1128 #[rstest]
1129 fn test_build_csrf_cookie_insecure() {
1130 let cookie = build_csrf_cookie("token123", false);
1132
1133 assert_eq!(cookie, "csrftoken=token123; SameSite=Strict; Path=/admin");
1135 }
1136
1137 #[rstest]
1142 fn test_require_csrf_token_matching_cookie() {
1143 let token = generate_csrf_token();
1145 let mut headers = hyper::HeaderMap::new();
1146 let cookie_value = format!("csrftoken={}", token);
1147 headers.insert("cookie", cookie_value.parse().unwrap());
1148
1149 require_csrf_token(&token, &headers).unwrap();
1152 }
1153
1154 #[rstest]
1155 fn test_require_csrf_token_mismatching_cookie() {
1156 let body_token = generate_csrf_token();
1158 let cookie_token = generate_csrf_token();
1159 let mut headers = hyper::HeaderMap::new();
1160 let cookie_value = format!("csrftoken={}", cookie_token);
1161 headers.insert("cookie", cookie_value.parse().unwrap());
1162
1163 let result = require_csrf_token(&body_token, &headers);
1165
1166 let err = result.unwrap_err();
1168 match err {
1169 reinhardt_pages::server_fn::ServerFnError::Server { status, message } => {
1170 assert_eq!(status, 403);
1171 assert_eq!(message, "CSRF token validation failed");
1172 }
1173 other => panic!("Expected Server error with status 403, got: {:?}", other),
1174 }
1175 }
1176
1177 #[rstest]
1178 fn test_require_csrf_token_missing_cookie() {
1179 let body_token = generate_csrf_token();
1181 let headers = hyper::HeaderMap::new();
1182
1183 let result = require_csrf_token(&body_token, &headers);
1185
1186 let err = result.unwrap_err();
1188 match err {
1189 reinhardt_pages::server_fn::ServerFnError::Server { status, message } => {
1190 assert_eq!(status, 403);
1191 assert_eq!(message, "CSRF token missing from cookie and header");
1192 }
1193 other => panic!("Expected Server error with status 403, got: {:?}", other),
1194 }
1195 }
1196
1197 #[rstest]
1198 fn test_require_csrf_token_empty_body_token() {
1199 let cookie_token = generate_csrf_token();
1201 let mut headers = hyper::HeaderMap::new();
1202 let cookie_value = format!("csrftoken={}", cookie_token);
1203 headers.insert("cookie", cookie_value.parse().unwrap());
1204
1205 let result = require_csrf_token("", &headers);
1207
1208 let err = result.unwrap_err();
1210 match err {
1211 reinhardt_pages::server_fn::ServerFnError::Server { status, message } => {
1212 assert_eq!(status, 403);
1213 assert_eq!(message, "CSRF token validation failed");
1214 }
1215 other => panic!("Expected Server error with status 403, got: {:?}", other),
1216 }
1217 }
1218
1219 #[rstest]
1220 fn test_require_csrf_header_matching_cookie() {
1221 let token = generate_csrf_token();
1223 let mut headers = hyper::HeaderMap::new();
1224 let cookie_value = format!("csrftoken={}", token);
1225 headers.insert("cookie", cookie_value.parse().unwrap());
1226 headers.insert("X-CSRFToken", token.parse().unwrap());
1227
1228 require_csrf_header(&headers).unwrap();
1230 }
1231
1232 #[rstest]
1233 fn test_require_csrf_header_rejects_mismatching_cookie() {
1234 let header_token = generate_csrf_token();
1236 let cookie_token = generate_csrf_token();
1237 let mut headers = hyper::HeaderMap::new();
1238 let cookie_value = format!("csrftoken={}", cookie_token);
1239 headers.insert("cookie", cookie_value.parse().unwrap());
1240 headers.insert("X-CSRFToken", header_token.parse().unwrap());
1241
1242 let result = require_csrf_header(&headers);
1244
1245 let err = result.unwrap_err();
1247 match err {
1248 reinhardt_pages::server_fn::ServerFnError::Server { status, message } => {
1249 assert_eq!(status, 403);
1250 assert_eq!(message, "CSRF token validation failed");
1251 }
1252 other => panic!("Expected Server error with status 403, got: {:?}", other),
1253 }
1254 }
1255
1256 #[rstest]
1257 fn test_require_csrf_header_rejects_missing_cookie() {
1258 let token = generate_csrf_token();
1260 let mut headers = hyper::HeaderMap::new();
1261 headers.insert("X-CSRFToken", token.parse().unwrap());
1262
1263 let result = require_csrf_header(&headers);
1265
1266 let err = result.unwrap_err();
1268 match err {
1269 reinhardt_pages::server_fn::ServerFnError::Server { status, message } => {
1270 assert_eq!(status, 403);
1271 assert_eq!(message, "CSRF token missing from cookie");
1272 }
1273 other => panic!("Expected Server error with status 403, got: {:?}", other),
1274 }
1275 }
1276
1277 #[rstest]
1282 fn test_csrf_token_generation_uniqueness() {
1283 let mut tokens = std::collections::HashSet::new();
1285
1286 for _ in 0..100 {
1288 let token = generate_csrf_token();
1289 tokens.insert(token);
1290 }
1291
1292 assert_eq!(
1294 tokens.len(),
1295 100,
1296 "All 100 generated CSRF tokens should be unique"
1297 );
1298 }
1299
1300 #[rstest]
1301 fn test_csrf_token_minimum_entropy() {
1302 let token = generate_csrf_token();
1304
1305 assert!(
1308 token.len() >= 32,
1309 "CSRF token length {} should be at least 32 characters for sufficient entropy",
1310 token.len()
1311 );
1312 }
1313
1314 #[rstest]
1319 fn test_csrf_validation_accepts_matching_tokens() {
1320 let token = generate_csrf_token();
1322 let mut headers = hyper::HeaderMap::new();
1323 let cookie_value = format!("csrftoken={}", token);
1324 headers.insert("cookie", cookie_value.parse().unwrap());
1325
1326 let result = require_csrf_token(&token, &headers);
1328
1329 assert!(
1331 result.is_ok(),
1332 "Matching tokens should pass CSRF validation"
1333 );
1334 }
1335
1336 #[rstest]
1337 fn test_csrf_validation_rejects_empty_token() {
1338 let cookie_token = generate_csrf_token();
1340 let mut headers = hyper::HeaderMap::new();
1341 let cookie_value = format!("csrftoken={}", cookie_token);
1342 headers.insert("cookie", cookie_value.parse().unwrap());
1343
1344 let result = require_csrf_token("", &headers);
1346
1347 assert!(result.is_err(), "Empty body token should be rejected");
1349 let err = result.unwrap_err();
1350 match err {
1351 reinhardt_pages::server_fn::ServerFnError::Server { status, .. } => {
1352 assert_eq!(status, 403);
1353 }
1354 other => panic!("Expected Server error with status 403, got: {:?}", other),
1355 }
1356 }
1357
1358 #[rstest]
1359 fn test_csrf_validation_rejects_whitespace_only_token() {
1360 let cookie_token = generate_csrf_token();
1362 let mut headers = hyper::HeaderMap::new();
1363 let cookie_value = format!("csrftoken={}", cookie_token);
1364 headers.insert("cookie", cookie_value.parse().unwrap());
1365
1366 let result = require_csrf_token(" ", &headers);
1368
1369 assert!(
1371 result.is_err(),
1372 "Whitespace-only body token should be rejected"
1373 );
1374 }
1375
1376 #[rstest]
1381 fn test_sanitize_html_removes_script_tags() {
1382 let mut data = HashMap::new();
1384 data.insert(
1385 "content".to_string(),
1386 serde_json::json!("<script>document.cookie</script>"),
1387 );
1388
1389 sanitize_mutation_values(&mut data);
1391
1392 let content = data.get("content").unwrap().as_str().unwrap();
1394 assert!(
1395 !content.contains("<script>"),
1396 "Script tags should be escaped, got: {}",
1397 content
1398 );
1399 assert!(
1400 content.contains("<script>"),
1401 "Script tags should be HTML-escaped, got: {}",
1402 content
1403 );
1404 }
1405
1406 #[rstest]
1407 #[case("hello world", "hello world")]
1408 #[case("", "")]
1409 #[case(
1410 "normal text without special chars",
1411 "normal text without special chars"
1412 )]
1413 fn test_sanitize_html_idempotent_safe_strings(#[case] input: &str, #[case] expected: &str) {
1414 let mut data = HashMap::new();
1416 data.insert("val".to_string(), serde_json::json!(input));
1417
1418 sanitize_mutation_values(&mut data);
1420 let after_first = data.get("val").unwrap().as_str().unwrap().to_string();
1421
1422 let mut data2 = HashMap::new();
1424 data2.insert("val".to_string(), serde_json::json!(after_first));
1425 sanitize_mutation_values(&mut data2);
1426 let after_second = data2.get("val").unwrap().as_str().unwrap().to_string();
1427
1428 assert_eq!(after_first, expected);
1430 assert_eq!(after_first, after_second);
1431 }
1432
1433 #[rstest]
1434 #[case("<b>bold</b>", "<b>bold</b>")]
1435 #[case("<script>alert(1)</script>", "<script>alert(1)</script>")]
1436 fn test_sanitize_html_escapes_dangerous_input(
1437 #[case] input: &str,
1438 #[case] expected_escaped: &str,
1439 ) {
1440 let mut data = HashMap::new();
1442 data.insert("val".to_string(), serde_json::json!(input));
1443
1444 sanitize_mutation_values(&mut data);
1446
1447 let result = data.get("val").unwrap().as_str().unwrap();
1449 assert_eq!(result, expected_escaped);
1450 }
1451
1452 #[rstest]
1457 fn test_security_headers_count() {
1458 let headers = SecurityHeaders::default();
1460
1461 let map = headers.to_header_map();
1463
1464 assert_eq!(
1466 map.len(),
1467 6,
1468 "SecurityHeaders should produce exactly 6 headers: CSP, X-Content-Type-Options, X-Frame-Options, X-XSS-Protection, Referrer-Policy, Permissions-Policy"
1469 );
1470 }
1471
1472 #[rstest]
1477 fn test_frame_options_from_str_deny() {
1478 assert_eq!("deny".parse::<FrameOptions>().unwrap(), FrameOptions::Deny);
1480 }
1481
1482 #[rstest]
1483 fn test_frame_options_from_str_deny_uppercase() {
1484 assert_eq!("DENY".parse::<FrameOptions>().unwrap(), FrameOptions::Deny);
1486 }
1487
1488 #[rstest]
1489 fn test_frame_options_from_str_sameorigin() {
1490 assert_eq!(
1492 "sameorigin".parse::<FrameOptions>().unwrap(),
1493 FrameOptions::SameOrigin
1494 );
1495 }
1496
1497 #[rstest]
1498 fn test_frame_options_from_str_unknown_falls_back_to_deny() {
1499 assert_eq!(
1501 "invalid".parse::<FrameOptions>().unwrap(),
1502 FrameOptions::Deny
1503 );
1504 }
1505
1506 #[rstest]
1511 fn test_referrer_policy_from_str_no_referrer() {
1512 assert_eq!(
1514 "no-referrer".parse::<ReferrerPolicy>().unwrap(),
1515 ReferrerPolicy::NoReferrer
1516 );
1517 }
1518
1519 #[rstest]
1520 fn test_referrer_policy_from_str_strict_origin() {
1521 assert_eq!(
1523 "strict-origin-when-cross-origin"
1524 .parse::<ReferrerPolicy>()
1525 .unwrap(),
1526 ReferrerPolicy::StrictOriginWhenCrossOrigin
1527 );
1528 }
1529
1530 #[rstest]
1531 fn test_referrer_policy_from_str_same_origin() {
1532 assert_eq!(
1534 "same-origin".parse::<ReferrerPolicy>().unwrap(),
1535 ReferrerPolicy::SameOrigin
1536 );
1537 }
1538
1539 #[rstest]
1540 fn test_referrer_policy_from_str_unknown_falls_back() {
1541 assert_eq!(
1543 "invalid".parse::<ReferrerPolicy>().unwrap(),
1544 ReferrerPolicy::StrictOriginWhenCrossOrigin
1545 );
1546 }
1547}