use actix_web::HttpResponseBuilder;
use actix_web::http::header::CONTENT_SECURITY_POLICY;
use rand::random;
use serde::Deserialize;
pub const DEFAULT_CONTENT_SECURITY_POLICY: &str = "script-src 'self' 'nonce-{NONCE}'";
pub const NONCE_PLACEHOLDER: &str = "{NONCE}";
#[derive(Debug, Clone)]
pub struct ContentSecurityPolicy {
pub nonce: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ContentSecurityPolicyTemplate {
pub template: String,
pub nonce_position: Option<usize>,
}
impl ContentSecurityPolicyTemplate {
#[must_use]
pub fn is_enabled(&self) -> bool {
!self.template.is_empty()
}
fn format_nonce(&self, nonce: u64) -> String {
if let Some(pos) = self.nonce_position {
format!(
"{}{}{}",
&self.template[..pos],
nonce,
&self.template[pos + NONCE_PLACEHOLDER.len()..]
)
} else {
self.template.clone()
}
}
}
impl Default for ContentSecurityPolicyTemplate {
fn default() -> Self {
Self::from(DEFAULT_CONTENT_SECURITY_POLICY)
}
}
impl From<&str> for ContentSecurityPolicyTemplate {
fn from(s: &str) -> Self {
let nonce_position = s.find(NONCE_PLACEHOLDER);
Self {
template: s.to_owned(),
nonce_position,
}
}
}
impl<'de> Deserialize<'de> for ContentSecurityPolicyTemplate {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let s: String = Deserialize::deserialize(deserializer)?;
Ok(Self::from(s.as_str()))
}
}
impl ContentSecurityPolicy {
#[must_use]
pub fn with_random_nonce() -> Self {
Self { nonce: random() }
}
pub fn apply_to_response(
&self,
template: &ContentSecurityPolicyTemplate,
response: &mut HttpResponseBuilder,
) {
if template.is_enabled() {
response.insert_header((CONTENT_SECURITY_POLICY, template.format_nonce(self.nonce)));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use actix_web::http::StatusCode;
fn header_for(template: &str) -> Option<String> {
let mut response = HttpResponseBuilder::new(StatusCode::OK);
ContentSecurityPolicy::with_random_nonce().apply_to_response(
&ContentSecurityPolicyTemplate::from(template),
&mut response,
);
response
.finish()
.headers()
.get(CONTENT_SECURITY_POLICY)
.map(|value| value.to_str().unwrap().to_owned())
}
#[test]
fn default_policy_substitutes_a_nonce() {
let header = header_for(DEFAULT_CONTENT_SECURITY_POLICY).unwrap();
assert!(header.starts_with("script-src 'self' 'nonce-"));
assert!(!header.contains(NONCE_PLACEHOLDER));
}
#[test]
fn custom_policy_without_nonce_placeholder_is_sent_verbatim() {
assert_eq!(
header_for("default-src 'self'").as_deref(),
Some("default-src 'self'")
);
}
#[test]
fn empty_policy_disables_the_header() {
assert_eq!(header_for(""), None);
}
#[test]
fn test_content_security_policy_display() {
let template = ContentSecurityPolicyTemplate::from(
"script-src 'self' 'nonce-{NONCE}' 'unsafe-inline'",
);
let csp = ContentSecurityPolicy::with_random_nonce();
let csp_str = template.format_nonce(csp.nonce);
assert!(csp_str.starts_with("script-src 'self' 'nonce-"));
assert!(csp_str.ends_with("' 'unsafe-inline'"));
let second_csp = ContentSecurityPolicy::with_random_nonce();
let second_csp_str = template.format_nonce(second_csp.nonce);
assert_ne!(
csp_str, second_csp_str,
"We should not generate the same nonce twice"
);
}
}