use std::collections::HashMap;
#[derive(Debug, Clone)]
pub struct SecurityHeaders {
pub csp: ContentSecurityPolicy,
pub frame_options: FrameOptions,
pub referrer_policy: ReferrerPolicy,
pub permissions_policy: String,
}
impl Default for SecurityHeaders {
fn default() -> Self {
Self {
csp: ContentSecurityPolicy::admin_default(),
frame_options: FrameOptions::Deny,
referrer_policy: ReferrerPolicy::StrictOriginWhenCrossOrigin,
permissions_policy: "camera=(), microphone=(), geolocation=(), payment=()".to_string(),
}
}
}
impl SecurityHeaders {
pub fn to_header_map(&self) -> HashMap<&'static str, String> {
let mut headers = HashMap::new();
headers.insert("Content-Security-Policy", self.csp.to_header_value());
headers.insert("X-Content-Type-Options", "nosniff".to_string());
headers.insert("X-Frame-Options", self.frame_options.to_string());
headers.insert("X-XSS-Protection", "1; mode=block".to_string());
headers.insert("Referrer-Policy", self.referrer_policy.to_string());
headers.insert("Permissions-Policy", self.permissions_policy.clone());
headers
}
}
#[derive(Debug, Clone)]
pub struct ContentSecurityPolicy {
pub default_src: Vec<String>,
pub script_src: Vec<String>,
pub style_src: Vec<String>,
pub img_src: Vec<String>,
pub font_src: Vec<String>,
pub connect_src: Vec<String>,
pub frame_ancestors: Vec<String>,
pub base_uri: Vec<String>,
pub form_action: Vec<String>,
}
impl ContentSecurityPolicy {
pub fn admin_default() -> Self {
Self {
default_src: vec!["'self'".to_string()],
script_src: vec!["'self'".to_string(), "'wasm-unsafe-eval'".to_string()],
style_src: vec!["'self'".to_string(), "'unsafe-inline'".to_string()],
img_src: vec!["'self'".to_string(), "data:".to_string()],
font_src: vec!["'self'".to_string()],
connect_src: vec!["'self'".to_string()],
frame_ancestors: vec!["'none'".to_string()],
base_uri: vec!["'self'".to_string()],
form_action: vec!["'self'".to_string()],
}
}
fn to_header_value(&self) -> String {
let mut directives = Vec::new();
if !self.default_src.is_empty() {
directives.push(format!("default-src {}", self.default_src.join(" ")));
}
if !self.script_src.is_empty() {
directives.push(format!("script-src {}", self.script_src.join(" ")));
}
if !self.style_src.is_empty() {
directives.push(format!("style-src {}", self.style_src.join(" ")));
}
if !self.img_src.is_empty() {
directives.push(format!("img-src {}", self.img_src.join(" ")));
}
if !self.font_src.is_empty() {
directives.push(format!("font-src {}", self.font_src.join(" ")));
}
if !self.connect_src.is_empty() {
directives.push(format!("connect-src {}", self.connect_src.join(" ")));
}
if !self.frame_ancestors.is_empty() {
directives.push(format!(
"frame-ancestors {}",
self.frame_ancestors.join(" ")
));
}
if !self.base_uri.is_empty() {
directives.push(format!("base-uri {}", self.base_uri.join(" ")));
}
if !self.form_action.is_empty() {
directives.push(format!("form-action {}", self.form_action.join(" ")));
}
directives.join("; ")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FrameOptions {
Deny,
SameOrigin,
}
impl std::fmt::Display for FrameOptions {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
FrameOptions::Deny => write!(f, "DENY"),
FrameOptions::SameOrigin => write!(f, "SAMEORIGIN"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReferrerPolicy {
NoReferrer,
StrictOriginWhenCrossOrigin,
SameOrigin,
}
impl std::fmt::Display for ReferrerPolicy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ReferrerPolicy::NoReferrer => write!(f, "no-referrer"),
ReferrerPolicy::StrictOriginWhenCrossOrigin => {
write!(f, "strict-origin-when-cross-origin")
}
ReferrerPolicy::SameOrigin => write!(f, "same-origin"),
}
}
}
impl std::str::FromStr for FrameOptions {
type Err = std::convert::Infallible;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(match s.to_lowercase().as_str() {
"deny" => Self::Deny,
"sameorigin" => Self::SameOrigin,
_ => Self::Deny,
})
}
}
impl std::str::FromStr for ReferrerPolicy {
type Err = std::convert::Infallible;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(match s.to_lowercase().as_str() {
"no-referrer" => Self::NoReferrer,
"strict-origin-when-cross-origin" => Self::StrictOriginWhenCrossOrigin,
"same-origin" => Self::SameOrigin,
_ => Self::StrictOriginWhenCrossOrigin,
})
}
}
const CSRF_TOKEN_BYTES: usize = 32;
pub fn generate_csrf_token() -> String {
use base64::Engine;
let mut bytes = vec![0u8; CSRF_TOKEN_BYTES];
getrandom::fill(&mut bytes).expect("Failed to generate random bytes for CSRF token");
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&bytes)
}
pub fn validate_csrf_token(provided: &str, expected: &str) -> bool {
if provided.is_empty() || expected.is_empty() {
return false;
}
constant_time_eq(provided.as_bytes(), expected.as_bytes())
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;
let hash_a = Sha256::digest(a);
let hash_b = Sha256::digest(b);
hash_a.ct_eq(&hash_b).into()
}
pub const CSRF_HEADER_NAME: &str = "x-csrf-token";
pub const CSRF_COOKIE_NAME: &str = "csrftoken";
#[cfg(server)]
pub fn extract_csrf_header(headers: &hyper::HeaderMap) -> Option<String> {
headers
.get(CSRF_HEADER_NAME)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
}
#[cfg(server)]
pub fn extract_csrf_cookie(headers: &hyper::HeaderMap) -> Option<String> {
headers
.get("cookie")
.and_then(|v| v.to_str().ok())
.and_then(|cookie_header| {
cookie_header.split(';').find_map(|pair| {
let pair = pair.trim();
let (name, value) = pair.split_once('=')?;
if name.trim() == CSRF_COOKIE_NAME {
Some(value.trim().to_string())
} else {
None
}
})
})
}
pub fn build_csrf_cookie(token: &str, is_secure: bool) -> String {
let secure_flag = if is_secure { "; Secure" } else { "" };
format!(
"{}={}; SameSite=Strict; Path=/admin{}",
CSRF_COOKIE_NAME, token, secure_flag
)
}
#[cfg(server)]
pub fn require_csrf_token(
body_token: &str,
headers: &hyper::HeaderMap,
) -> Result<(), reinhardt_pages::server_fn::ServerFnError> {
let expected_token = extract_csrf_cookie(headers)
.or_else(|| extract_csrf_header(headers))
.ok_or_else(|| {
reinhardt_pages::server_fn::ServerFnError::server(
403,
"CSRF token missing from cookie and header",
)
})?;
if !validate_csrf_token(body_token, &expected_token) {
return Err(reinhardt_pages::server_fn::ServerFnError::server(
403,
"CSRF token validation failed",
));
}
Ok(())
}
#[cfg(server)]
pub fn sanitize_mutation_values(data: &mut HashMap<String, serde_json::Value>) {
for value in data.values_mut() {
sanitize_json_value(value);
}
}
#[cfg(server)]
fn sanitize_json_value(value: &mut serde_json::Value) {
match value {
serde_json::Value::String(s) => {
if needs_html_escaping(s) {
*s = escape_html(s);
}
}
serde_json::Value::Array(arr) => {
for item in arr.iter_mut() {
sanitize_json_value(item);
}
}
serde_json::Value::Object(obj) => {
for val in obj.values_mut() {
sanitize_json_value(val);
}
}
_ => {}
}
}
#[cfg(server)]
fn needs_html_escaping(s: &str) -> bool {
s.contains('<') || s.contains('>') || s.contains('&') || s.contains('"') || s.contains('\'')
}
#[cfg(server)]
fn escape_html(input: &str) -> String {
reinhardt_core::security::escape_html(input)
}
pub const ADMIN_AUTH_COOKIE_NAME: &str = "reinhardt_admin_token";
pub fn build_admin_auth_cookie(token: &str, is_secure: bool) -> String {
let secure_flag = if is_secure { "; Secure" } else { "" };
format!(
"{}={}; HttpOnly; SameSite=Strict; Path=/admin; Max-Age=86400{}",
ADMIN_AUTH_COOKIE_NAME, token, secure_flag
)
}
pub fn build_admin_auth_cookie_clear() -> String {
format!(
"{}=; HttpOnly; SameSite=Strict; Path=/admin; Max-Age=0",
ADMIN_AUTH_COOKIE_NAME
)
}
#[cfg(not(target_arch = "wasm32"))]
pub fn extract_admin_auth_cookie(headers: &hyper::HeaderMap) -> Option<String> {
headers
.get("cookie")
.and_then(|v| v.to_str().ok())
.and_then(|cookie_header| {
cookie_header.split(';').find_map(|pair| {
let pair = pair.trim();
let (name, value) = pair.split_once('=')?;
if name.trim() == ADMIN_AUTH_COOKIE_NAME {
Some(value.trim().to_string())
} else {
None
}
})
})
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
#[rstest]
fn test_security_headers_default_contains_all_headers() {
let headers = SecurityHeaders::default();
let map = headers.to_header_map();
assert!(map.contains_key("Content-Security-Policy"));
assert!(map.contains_key("X-Content-Type-Options"));
assert!(map.contains_key("X-Frame-Options"));
assert!(map.contains_key("X-XSS-Protection"));
assert!(map.contains_key("Referrer-Policy"));
assert!(map.contains_key("Permissions-Policy"));
}
#[rstest]
fn test_security_headers_x_content_type_options() {
let headers = SecurityHeaders::default();
let map = headers.to_header_map();
assert_eq!(map.get("X-Content-Type-Options").unwrap(), "nosniff");
}
#[rstest]
fn test_security_headers_x_frame_options_deny() {
let headers = SecurityHeaders::default();
let map = headers.to_header_map();
assert_eq!(map.get("X-Frame-Options").unwrap(), "DENY");
}
#[rstest]
fn test_security_headers_x_xss_protection() {
let headers = SecurityHeaders::default();
let map = headers.to_header_map();
assert_eq!(map.get("X-XSS-Protection").unwrap(), "1; mode=block");
}
#[rstest]
fn test_security_headers_referrer_policy() {
let headers = SecurityHeaders::default();
let map = headers.to_header_map();
assert_eq!(
map.get("Referrer-Policy").unwrap(),
"strict-origin-when-cross-origin"
);
}
#[rstest]
fn test_security_headers_permissions_policy() {
let headers = SecurityHeaders::default();
let map = headers.to_header_map();
let pp = map.get("Permissions-Policy").unwrap();
assert!(pp.contains("camera=()"));
assert!(pp.contains("microphone=()"));
assert!(pp.contains("geolocation=()"));
}
#[rstest]
fn test_csp_admin_default_contains_self() {
let csp = ContentSecurityPolicy::admin_default();
let csp_string = csp.to_header_value();
assert!(csp_string.contains("default-src 'self'"));
assert!(csp_string.contains("script-src 'self'"));
}
#[rstest]
fn test_csp_admin_default_prevents_framing() {
let csp = ContentSecurityPolicy::admin_default();
let csp_string = csp.to_header_value();
assert!(csp_string.contains("frame-ancestors 'none'"));
}
#[rstest]
fn test_csp_admin_default_allows_inline_styles() {
let csp = ContentSecurityPolicy::admin_default();
let csp_string = csp.to_header_value();
assert!(csp_string.contains("style-src 'self' 'unsafe-inline'"));
}
#[rstest]
fn test_csp_admin_default_restricts_form_action() {
let csp = ContentSecurityPolicy::admin_default();
let csp_string = csp.to_header_value();
assert!(csp_string.contains("form-action 'self'"));
}
#[rstest]
fn test_csp_admin_default_allows_wasm_eval() {
let csp = ContentSecurityPolicy::admin_default();
let csp_string = csp.to_header_value();
assert!(
csp_string.contains("'wasm-unsafe-eval'"),
"CSP should allow WASM evaluation for admin SPA, got: {}",
csp_string
);
}
#[rstest]
fn test_frame_options_deny() {
assert_eq!(FrameOptions::Deny.to_string(), "DENY");
}
#[rstest]
fn test_frame_options_same_origin() {
assert_eq!(FrameOptions::SameOrigin.to_string(), "SAMEORIGIN");
}
#[rstest]
fn test_referrer_policy_no_referrer() {
assert_eq!(ReferrerPolicy::NoReferrer.to_string(), "no-referrer");
}
#[rstest]
fn test_referrer_policy_strict_origin() {
assert_eq!(
ReferrerPolicy::StrictOriginWhenCrossOrigin.to_string(),
"strict-origin-when-cross-origin"
);
}
#[rstest]
fn test_referrer_policy_same_origin() {
assert_eq!(ReferrerPolicy::SameOrigin.to_string(), "same-origin");
}
#[rstest]
fn test_generate_csrf_token_is_non_empty() {
let token = generate_csrf_token();
assert!(!token.is_empty());
assert!(token.len() >= 32);
}
#[rstest]
fn test_generate_csrf_token_is_unique() {
let token1 = generate_csrf_token();
let token2 = generate_csrf_token();
assert_ne!(token1, token2);
}
#[rstest]
fn test_validate_csrf_token_with_matching_tokens() {
let token = generate_csrf_token();
assert!(validate_csrf_token(&token, &token));
}
#[rstest]
fn test_validate_csrf_token_with_mismatching_tokens() {
let token = generate_csrf_token();
assert!(!validate_csrf_token("invalid-token", &token));
}
#[rstest]
fn test_validate_csrf_token_rejects_empty_provided() {
let token = generate_csrf_token();
assert!(!validate_csrf_token("", &token));
}
#[rstest]
fn test_validate_csrf_token_rejects_empty_expected() {
assert!(!validate_csrf_token("some-token", ""));
}
#[rstest]
fn test_validate_csrf_token_rejects_both_empty() {
assert!(!validate_csrf_token("", ""));
}
#[rstest]
fn test_constant_time_eq_equal() {
assert!(constant_time_eq(b"hello", b"hello"));
}
#[rstest]
fn test_constant_time_eq_different_content() {
assert!(!constant_time_eq(b"hello", b"world"));
}
#[rstest]
fn test_constant_time_eq_different_length() {
assert!(!constant_time_eq(b"hello", b"hi"));
}
#[rstest]
fn test_constant_time_eq_empty() {
assert!(constant_time_eq(b"", b""));
}
#[rstest]
fn test_sanitize_mutation_values_escapes_script_tags() {
let mut data = HashMap::new();
data.insert(
"name".to_string(),
serde_json::json!("<script>alert('xss')</script>"),
);
sanitize_mutation_values(&mut data);
let name = data.get("name").unwrap().as_str().unwrap();
assert!(!name.contains("<script>"));
assert!(name.contains("<script>"));
}
#[rstest]
fn test_sanitize_mutation_values_preserves_non_string_values() {
let mut data = HashMap::new();
data.insert("age".to_string(), serde_json::json!(25));
data.insert("active".to_string(), serde_json::json!(true));
data.insert("tags".to_string(), serde_json::json!(null));
sanitize_mutation_values(&mut data);
assert_eq!(data.get("age").unwrap().as_i64().unwrap(), 25);
assert_eq!(data.get("active").unwrap().as_bool().unwrap(), true);
assert!(data.get("tags").unwrap().is_null());
}
#[rstest]
fn test_sanitize_mutation_values_handles_nested_arrays() {
let mut data = HashMap::new();
data.insert(
"items".to_string(),
serde_json::json!(["<b>bold</b>", "safe text"]),
);
sanitize_mutation_values(&mut data);
let items = data.get("items").unwrap().as_array().unwrap();
assert_eq!(items[0].as_str().unwrap(), "<b>bold</b>");
assert_eq!(items[1].as_str().unwrap(), "safe text");
}
#[rstest]
fn test_sanitize_mutation_values_handles_nested_objects() {
let mut data = HashMap::new();
data.insert(
"metadata".to_string(),
serde_json::json!({"bio": "<img onerror=alert(1)>"}),
);
sanitize_mutation_values(&mut data);
let meta = data.get("metadata").unwrap().as_object().unwrap();
let bio = meta.get("bio").unwrap().as_str().unwrap();
assert!(!bio.contains("<img"));
assert!(bio.contains("<img"));
}
#[rstest]
fn test_sanitize_mutation_values_safe_strings_unchanged() {
let mut data = HashMap::new();
data.insert("name".to_string(), serde_json::json!("Alice Johnson"));
data.insert("email".to_string(), serde_json::json!("alice@example.com"));
sanitize_mutation_values(&mut data);
assert_eq!(data.get("name").unwrap().as_str().unwrap(), "Alice Johnson");
assert_eq!(
data.get("email").unwrap().as_str().unwrap(),
"alice@example.com"
);
}
#[rstest]
fn test_escape_html_special_characters() {
assert_eq!(escape_html("<"), "<");
assert_eq!(escape_html(">"), ">");
assert_eq!(escape_html("&"), "&");
assert_eq!(escape_html("\""), """);
assert_eq!(escape_html("'"), "'");
}
#[rstest]
fn test_needs_html_escaping_detects_dangerous_chars() {
assert!(needs_html_escaping("<script>"));
assert!(needs_html_escaping("a > b"));
assert!(needs_html_escaping("a & b"));
assert!(needs_html_escaping("a\"b"));
assert!(needs_html_escaping("a'b"));
assert!(!needs_html_escaping("safe text"));
assert!(!needs_html_escaping("hello world 123"));
}
#[rstest]
fn test_extract_csrf_header_present() {
let mut headers = hyper::HeaderMap::new();
headers.insert("x-csrf-token", "test-token".parse().unwrap());
let result = extract_csrf_header(&headers);
assert_eq!(result, Some("test-token".to_string()));
}
#[rstest]
fn test_extract_csrf_header_missing() {
let headers = hyper::HeaderMap::new();
let result = extract_csrf_header(&headers);
assert_eq!(result, None);
}
#[rstest]
fn test_extract_csrf_cookie_present() {
let mut headers = hyper::HeaderMap::new();
headers.insert(
"cookie",
"session=abc; csrftoken=test-token-value; other=xyz"
.parse()
.unwrap(),
);
let result = extract_csrf_cookie(&headers);
assert_eq!(result, Some("test-token-value".to_string()));
}
#[rstest]
fn test_extract_csrf_cookie_missing() {
let mut headers = hyper::HeaderMap::new();
headers.insert("cookie", "session=abc; other=xyz".parse().unwrap());
let result = extract_csrf_cookie(&headers);
assert_eq!(result, None);
}
#[rstest]
fn test_extract_csrf_cookie_no_cookie_header() {
let headers = hyper::HeaderMap::new();
let result = extract_csrf_cookie(&headers);
assert_eq!(result, None);
}
#[rstest]
fn test_extract_csrf_cookie_only_csrf() {
let mut headers = hyper::HeaderMap::new();
headers.insert("cookie", "csrftoken=solo-value".parse().unwrap());
let result = extract_csrf_cookie(&headers);
assert_eq!(result, Some("solo-value".to_string()));
}
#[rstest]
fn test_build_csrf_cookie_secure() {
let cookie = build_csrf_cookie("token123", true);
assert_eq!(
cookie,
"csrftoken=token123; SameSite=Strict; Path=/admin; Secure"
);
}
#[rstest]
fn test_build_csrf_cookie_insecure() {
let cookie = build_csrf_cookie("token123", false);
assert_eq!(cookie, "csrftoken=token123; SameSite=Strict; Path=/admin");
}
#[rstest]
fn test_require_csrf_token_matching_cookie() {
let token = generate_csrf_token();
let mut headers = hyper::HeaderMap::new();
let cookie_value = format!("csrftoken={}", token);
headers.insert("cookie", cookie_value.parse().unwrap());
require_csrf_token(&token, &headers).unwrap();
}
#[rstest]
fn test_require_csrf_token_mismatching_cookie() {
let body_token = generate_csrf_token();
let cookie_token = generate_csrf_token();
let mut headers = hyper::HeaderMap::new();
let cookie_value = format!("csrftoken={}", cookie_token);
headers.insert("cookie", cookie_value.parse().unwrap());
let result = require_csrf_token(&body_token, &headers);
let err = result.unwrap_err();
match err {
reinhardt_pages::server_fn::ServerFnError::Server { status, message } => {
assert_eq!(status, 403);
assert_eq!(message, "CSRF token validation failed");
}
other => panic!("Expected Server error with status 403, got: {:?}", other),
}
}
#[rstest]
fn test_require_csrf_token_missing_cookie() {
let body_token = generate_csrf_token();
let headers = hyper::HeaderMap::new();
let result = require_csrf_token(&body_token, &headers);
let err = result.unwrap_err();
match err {
reinhardt_pages::server_fn::ServerFnError::Server { status, message } => {
assert_eq!(status, 403);
assert_eq!(message, "CSRF token missing from cookie and header");
}
other => panic!("Expected Server error with status 403, got: {:?}", other),
}
}
#[rstest]
fn test_require_csrf_token_empty_body_token() {
let cookie_token = generate_csrf_token();
let mut headers = hyper::HeaderMap::new();
let cookie_value = format!("csrftoken={}", cookie_token);
headers.insert("cookie", cookie_value.parse().unwrap());
let result = require_csrf_token("", &headers);
let err = result.unwrap_err();
match err {
reinhardt_pages::server_fn::ServerFnError::Server { status, message } => {
assert_eq!(status, 403);
assert_eq!(message, "CSRF token validation failed");
}
other => panic!("Expected Server error with status 403, got: {:?}", other),
}
}
#[rstest]
fn test_csrf_token_generation_uniqueness() {
let mut tokens = std::collections::HashSet::new();
for _ in 0..100 {
let token = generate_csrf_token();
tokens.insert(token);
}
assert_eq!(
tokens.len(),
100,
"All 100 generated CSRF tokens should be unique"
);
}
#[rstest]
fn test_csrf_token_minimum_entropy() {
let token = generate_csrf_token();
assert!(
token.len() >= 32,
"CSRF token length {} should be at least 32 characters for sufficient entropy",
token.len()
);
}
#[rstest]
fn test_csrf_validation_accepts_matching_tokens() {
let token = generate_csrf_token();
let mut headers = hyper::HeaderMap::new();
let cookie_value = format!("csrftoken={}", token);
headers.insert("cookie", cookie_value.parse().unwrap());
let result = require_csrf_token(&token, &headers);
assert!(
result.is_ok(),
"Matching tokens should pass CSRF validation"
);
}
#[rstest]
fn test_csrf_validation_rejects_empty_token() {
let cookie_token = generate_csrf_token();
let mut headers = hyper::HeaderMap::new();
let cookie_value = format!("csrftoken={}", cookie_token);
headers.insert("cookie", cookie_value.parse().unwrap());
let result = require_csrf_token("", &headers);
assert!(result.is_err(), "Empty body token should be rejected");
let err = result.unwrap_err();
match err {
reinhardt_pages::server_fn::ServerFnError::Server { status, .. } => {
assert_eq!(status, 403);
}
other => panic!("Expected Server error with status 403, got: {:?}", other),
}
}
#[rstest]
fn test_csrf_validation_rejects_whitespace_only_token() {
let cookie_token = generate_csrf_token();
let mut headers = hyper::HeaderMap::new();
let cookie_value = format!("csrftoken={}", cookie_token);
headers.insert("cookie", cookie_value.parse().unwrap());
let result = require_csrf_token(" ", &headers);
assert!(
result.is_err(),
"Whitespace-only body token should be rejected"
);
}
#[rstest]
fn test_sanitize_html_removes_script_tags() {
let mut data = HashMap::new();
data.insert(
"content".to_string(),
serde_json::json!("<script>document.cookie</script>"),
);
sanitize_mutation_values(&mut data);
let content = data.get("content").unwrap().as_str().unwrap();
assert!(
!content.contains("<script>"),
"Script tags should be escaped, got: {}",
content
);
assert!(
content.contains("<script>"),
"Script tags should be HTML-escaped, got: {}",
content
);
}
#[rstest]
#[case("hello world", "hello world")]
#[case("", "")]
#[case(
"normal text without special chars",
"normal text without special chars"
)]
fn test_sanitize_html_idempotent_safe_strings(#[case] input: &str, #[case] expected: &str) {
let mut data = HashMap::new();
data.insert("val".to_string(), serde_json::json!(input));
sanitize_mutation_values(&mut data);
let after_first = data.get("val").unwrap().as_str().unwrap().to_string();
let mut data2 = HashMap::new();
data2.insert("val".to_string(), serde_json::json!(after_first));
sanitize_mutation_values(&mut data2);
let after_second = data2.get("val").unwrap().as_str().unwrap().to_string();
assert_eq!(after_first, expected);
assert_eq!(after_first, after_second);
}
#[rstest]
#[case("<b>bold</b>", "<b>bold</b>")]
#[case("<script>alert(1)</script>", "<script>alert(1)</script>")]
fn test_sanitize_html_escapes_dangerous_input(
#[case] input: &str,
#[case] expected_escaped: &str,
) {
let mut data = HashMap::new();
data.insert("val".to_string(), serde_json::json!(input));
sanitize_mutation_values(&mut data);
let result = data.get("val").unwrap().as_str().unwrap();
assert_eq!(result, expected_escaped);
}
#[rstest]
fn test_security_headers_count() {
let headers = SecurityHeaders::default();
let map = headers.to_header_map();
assert_eq!(
map.len(),
6,
"SecurityHeaders should produce exactly 6 headers: CSP, X-Content-Type-Options, X-Frame-Options, X-XSS-Protection, Referrer-Policy, Permissions-Policy"
);
}
#[rstest]
fn test_frame_options_from_str_deny() {
assert_eq!("deny".parse::<FrameOptions>().unwrap(), FrameOptions::Deny);
}
#[rstest]
fn test_frame_options_from_str_deny_uppercase() {
assert_eq!("DENY".parse::<FrameOptions>().unwrap(), FrameOptions::Deny);
}
#[rstest]
fn test_frame_options_from_str_sameorigin() {
assert_eq!(
"sameorigin".parse::<FrameOptions>().unwrap(),
FrameOptions::SameOrigin
);
}
#[rstest]
fn test_frame_options_from_str_unknown_falls_back_to_deny() {
assert_eq!(
"invalid".parse::<FrameOptions>().unwrap(),
FrameOptions::Deny
);
}
#[rstest]
fn test_referrer_policy_from_str_no_referrer() {
assert_eq!(
"no-referrer".parse::<ReferrerPolicy>().unwrap(),
ReferrerPolicy::NoReferrer
);
}
#[rstest]
fn test_referrer_policy_from_str_strict_origin() {
assert_eq!(
"strict-origin-when-cross-origin"
.parse::<ReferrerPolicy>()
.unwrap(),
ReferrerPolicy::StrictOriginWhenCrossOrigin
);
}
#[rstest]
fn test_referrer_policy_from_str_same_origin() {
assert_eq!(
"same-origin".parse::<ReferrerPolicy>().unwrap(),
ReferrerPolicy::SameOrigin
);
}
#[rstest]
fn test_referrer_policy_from_str_unknown_falls_back() {
assert_eq!(
"invalid".parse::<ReferrerPolicy>().unwrap(),
ReferrerPolicy::StrictOriginWhenCrossOrigin
);
}
}