use base64::{engine::general_purpose::STANDARD, Engine as _};
use ring::rand::{SecureRandom, SystemRandom};
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CspNonce(String);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InvalidNonce;
impl fmt::Display for InvalidNonce {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"invalid CSP nonce: must be at least 22 characters of [A-Za-z0-9+/=_-]"
)
}
}
impl std::error::Error for InvalidNonce {}
const MIN_NONCE_LEN: usize = 22;
impl CspNonce {
pub fn generate() -> Self {
let rng = SystemRandom::new();
let mut bytes = [0u8; 32];
rng.fill(&mut bytes).expect("RNG failure");
CspNonce(STANDARD.encode(bytes))
}
pub fn parse(value: &str) -> Result<Self, InvalidNonce> {
if value.len() < MIN_NONCE_LEN {
return Err(InvalidNonce);
}
if !value
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'/' | b'=' | b'_' | b'-'))
{
return Err(InvalidNonce);
}
Ok(CspNonce(value.to_string()))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for CspNonce {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
pub fn generate_csp_nonce() -> CspNonce {
CspNonce::generate()
}
pub fn build_csp_header(nonce: &CspNonce) -> String {
let nonce = nonce.as_str();
format!(
"default-src 'none'; \
script-src 'nonce-{nonce}' 'strict-dynamic' 'self'; \
style-src 'nonce-{nonce}' 'self'; \
style-src-attr 'unsafe-inline'; \
worker-src 'self'; \
object-src 'none'; \
connect-src 'self'; \
img-src 'self' data:; \
font-src 'self'; \
frame-ancestors 'none'; \
base-uri 'none'; \
form-action 'self'"
)
}
#[cfg(test)]
mod tests {
use super::*;
fn directive<'a>(csp: &'a str, name: &str) -> Option<&'a str> {
csp.split(';')
.map(str::trim)
.find_map(|d| d.strip_prefix(name).filter(|rest| rest.starts_with(' ')))
.map(str::trim)
}
#[test]
fn nonce_is_unique() {
let a = generate_csp_nonce();
let b = generate_csp_nonce();
assert_ne!(a, b);
}
#[test]
fn nonce_is_base64() {
let nonce = generate_csp_nonce();
assert_eq!(nonce.as_str().len(), 44); assert!(STANDARD.decode(nonce.as_str()).is_ok());
}
#[test]
fn csp_contains_nonce() {
let nonce = generate_csp_nonce();
let csp = build_csp_header(&nonce);
assert!(csp.contains(&format!("'nonce-{nonce}'")));
}
#[test]
fn parse_rejects_attribute_and_directive_breakout() {
for bad in [
"\" onload=\"alert(1)",
"x' 'unsafe-inline",
"short",
"has spaces in it here",
"semi;colon;value;padding",
] {
assert_eq!(CspNonce::parse(bad), Err(InvalidNonce), "accepted {bad:?}");
}
}
#[test]
fn parse_accepts_a_generated_nonce() {
let generated = CspNonce::generate();
assert_eq!(CspNonce::parse(generated.as_str()), Ok(generated));
}
#[test]
fn csp_is_strict() {
let csp = build_csp_header(&CspNonce::parse("test-nonce-value-0123456789").unwrap());
assert_eq!(directive(&csp, "default-src"), Some("'none'"));
let script = directive(&csp, "script-src").expect("script-src");
assert!(script.contains("'nonce-"));
assert!(
script.contains("'strict-dynamic'"),
"without strict-dynamic the 'self' host-source is still honoured"
);
assert!(!script.contains("unsafe-inline"));
assert!(!script.contains("unsafe-eval"));
let style = directive(&csp, "style-src").expect("style-src");
assert!(style.contains("'nonce-"));
assert!(!style.contains("unsafe-inline"));
assert_eq!(directive(&csp, "style-src-attr"), Some("'unsafe-inline'"));
assert_eq!(directive(&csp, "style-src-elem"), None);
assert_eq!(directive(&csp, "worker-src"), Some("'self'"));
assert_eq!(directive(&csp, "object-src"), Some("'none'"));
}
}