use crate::ast::Directive;
use crate::source_list::{Keyword, SourceExpression, SourceList, parse_source_list};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum ValueGrammar {
SourceList,
AncestorSourceList,
SandboxTokens,
Boolean,
Token,
TokenList,
TrustedTypes,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum DirectiveStatus {
Current,
Deprecated,
}
const REGISTRY: &[(&str, ValueGrammar, DirectiveStatus)] = &[
(
"child-src",
ValueGrammar::SourceList,
DirectiveStatus::Current,
),
(
"connect-src",
ValueGrammar::SourceList,
DirectiveStatus::Current,
),
(
"default-src",
ValueGrammar::SourceList,
DirectiveStatus::Current,
),
(
"font-src",
ValueGrammar::SourceList,
DirectiveStatus::Current,
),
(
"frame-src",
ValueGrammar::SourceList,
DirectiveStatus::Current,
),
(
"img-src",
ValueGrammar::SourceList,
DirectiveStatus::Current,
),
(
"manifest-src",
ValueGrammar::SourceList,
DirectiveStatus::Current,
),
(
"media-src",
ValueGrammar::SourceList,
DirectiveStatus::Current,
),
(
"object-src",
ValueGrammar::SourceList,
DirectiveStatus::Current,
),
(
"script-src",
ValueGrammar::SourceList,
DirectiveStatus::Current,
),
(
"script-src-elem",
ValueGrammar::SourceList,
DirectiveStatus::Current,
),
(
"script-src-attr",
ValueGrammar::SourceList,
DirectiveStatus::Current,
),
(
"style-src",
ValueGrammar::SourceList,
DirectiveStatus::Current,
),
(
"style-src-elem",
ValueGrammar::SourceList,
DirectiveStatus::Current,
),
(
"style-src-attr",
ValueGrammar::SourceList,
DirectiveStatus::Current,
),
(
"worker-src",
ValueGrammar::SourceList,
DirectiveStatus::Current,
),
(
"base-uri",
ValueGrammar::SourceList,
DirectiveStatus::Current,
),
(
"sandbox",
ValueGrammar::SandboxTokens,
DirectiveStatus::Current,
),
(
"form-action",
ValueGrammar::SourceList,
DirectiveStatus::Current,
),
(
"frame-ancestors",
ValueGrammar::AncestorSourceList,
DirectiveStatus::Current,
),
("report-to", ValueGrammar::Token, DirectiveStatus::Current),
(
"report-uri",
ValueGrammar::TokenList,
DirectiveStatus::Deprecated,
),
(
"upgrade-insecure-requests",
ValueGrammar::Boolean,
DirectiveStatus::Current,
),
(
"block-all-mixed-content",
ValueGrammar::Boolean,
DirectiveStatus::Deprecated,
),
(
"require-trusted-types-for",
ValueGrammar::Token,
DirectiveStatus::Current,
),
(
"trusted-types",
ValueGrammar::TrustedTypes,
DirectiveStatus::Current,
),
(
"plugin-types",
ValueGrammar::TokenList,
DirectiveStatus::Deprecated,
),
];
pub fn registry_lookup(name: &str) -> Option<(ValueGrammar, DirectiveStatus)> {
REGISTRY
.iter()
.find(|(registered_name, _, _)| registered_name.eq_ignore_ascii_case(name))
.map(|(_, grammar, status)| (*grammar, *status))
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum DirectiveValue {
SourceList(SourceList),
AncestorSourceList(SourceList),
Sandbox(Vec<String>),
Boolean,
Token(Option<String>),
TokenList(Vec<String>),
TrustedTypes(Vec<String>),
Unknown,
}
impl Directive {
pub fn value(&self) -> DirectiveValue {
let Some((grammar, _status)) = registry_lookup(&self.name) else {
return DirectiveValue::Unknown;
};
let raw = self.raw_value.as_deref().unwrap_or("");
match grammar {
ValueGrammar::SourceList => DirectiveValue::SourceList(parse_source_list(raw)),
ValueGrammar::AncestorSourceList => {
DirectiveValue::AncestorSourceList(parse_source_list(raw))
}
ValueGrammar::SandboxTokens => DirectiveValue::Sandbox(tokenize(raw)),
ValueGrammar::Boolean => DirectiveValue::Boolean,
ValueGrammar::Token => {
DirectiveValue::Token(raw.split_ascii_whitespace().next().map(str::to_string))
}
ValueGrammar::TokenList => DirectiveValue::TokenList(tokenize(raw)),
ValueGrammar::TrustedTypes => DirectiveValue::TrustedTypes(tokenize(raw)),
}
}
pub fn boolean_value_is_unexpected(&self) -> bool {
matches!(
registry_lookup(&self.name),
Some((ValueGrammar::Boolean, _))
) && self.raw_value.is_some()
}
}
fn tokenize(raw: &str) -> Vec<String> {
raw.split_ascii_whitespace().map(str::to_string).collect()
}
pub fn ancestor_source_list_is_valid(list: &SourceList) -> bool {
match list {
SourceList::None => true,
SourceList::Sources(entries) => entries.iter().all(|entry| {
matches!(
entry.expression,
Some(SourceExpression::Scheme(_))
| Some(SourceExpression::Host(_))
| Some(SourceExpression::Keyword(Keyword::SelfKeyword))
)
}),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parse_policy_list;
fn directive_value(policy_str: &str) -> DirectiveValue {
parse_policy_list(policy_str).policies[0].directives[0].value()
}
#[test]
fn fetch_directive_is_source_list() {
assert!(matches!(
directive_value("default-src 'self'"),
DirectiveValue::SourceList(_)
));
}
#[test]
fn sandbox_tokens() {
assert_eq!(
directive_value("sandbox allow-scripts allow-forms"),
DirectiveValue::Sandbox(vec!["allow-scripts".to_string(), "allow-forms".to_string()])
);
}
#[test]
fn base_uri_and_form_action_are_source_list() {
assert!(matches!(
directive_value("base-uri 'self'"),
DirectiveValue::SourceList(_)
));
assert!(matches!(
directive_value("form-action 'self'"),
DirectiveValue::SourceList(_)
));
}
#[test]
fn frame_ancestors_accepts_self_and_hosts_but_rejects_unsafe_inline_nonce_hash() {
let allowed = directive_value("frame-ancestors 'self' example.com https:");
match allowed {
DirectiveValue::AncestorSourceList(list) => {
assert!(ancestor_source_list_is_valid(&list));
}
other => panic!("expected AncestorSourceList, got {other:?}"),
}
for rejected_raw in [
"frame-ancestors 'unsafe-inline'",
"frame-ancestors 'nonce-abc123'",
"frame-ancestors 'sha256-abc123'",
] {
match directive_value(rejected_raw) {
DirectiveValue::AncestorSourceList(list) => {
assert!(!ancestor_source_list_is_valid(&list), "{rejected_raw}");
}
other => panic!("expected AncestorSourceList, got {other:?}"),
}
}
}
#[test]
fn report_to_and_report_uri() {
assert_eq!(
directive_value("report-to endpoint-1"),
DirectiveValue::Token(Some("endpoint-1".to_string()))
);
assert_eq!(
directive_value("report-uri https://example.com/csp-report"),
DirectiveValue::TokenList(vec!["https://example.com/csp-report".to_string()])
);
assert_eq!(
registry_lookup("report-uri").map(|(_, status)| status),
Some(DirectiveStatus::Deprecated)
);
}
#[test]
fn boolean_directives() {
let list = parse_policy_list("upgrade-insecure-requests");
let directive = &list.policies[0].directives[0];
assert_eq!(directive.value(), DirectiveValue::Boolean);
assert!(!directive.boolean_value_is_unexpected());
let list = parse_policy_list("upgrade-insecure-requests 'self'");
let directive = &list.policies[0].directives[0];
assert_eq!(directive.value(), DirectiveValue::Boolean);
assert!(directive.boolean_value_is_unexpected());
assert_eq!(
registry_lookup("block-all-mixed-content").map(|(_, status)| status),
Some(DirectiveStatus::Deprecated)
);
}
#[test]
fn trusted_types_directives() {
assert_eq!(
directive_value("require-trusted-types-for 'script'"),
DirectiveValue::Token(Some("'script'".to_string()))
);
assert_eq!(
directive_value("trusted-types my-policy 'allow-duplicates'"),
DirectiveValue::TrustedTypes(vec![
"my-policy".to_string(),
"'allow-duplicates'".to_string(),
])
);
}
#[test]
fn plugin_types_is_deprecated_token_list() {
assert_eq!(
directive_value("plugin-types application/pdf"),
DirectiveValue::TokenList(vec!["application/pdf".to_string()])
);
assert_eq!(
registry_lookup("plugin-types").map(|(_, status)| status),
Some(DirectiveStatus::Deprecated)
);
}
#[test]
fn unknown_directive_stays_syntactically_valid_but_unstructured() {
let list = parse_policy_list("default-src 'self'; totally-unknown-directive foo");
assert_eq!(list.policies[0].directives.len(), 2);
assert_eq!(
list.policies[0].directives[1].value(),
DirectiveValue::Unknown
);
}
#[test]
fn registry_lookup_is_case_insensitive() {
assert!(registry_lookup("Default-Src").is_some());
assert!(registry_lookup("DEFAULT-SRC").is_some());
}
}