Skip to content

Commit f405e05

Browse files
committed
Extend CSP Configuration to handle user-supplied values that can contain {NONCE}
1 parent f205b65 commit f405e05

File tree

5 files changed

+96
-25
lines changed

5 files changed

+96
-25
lines changed

src/app_config.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use crate::webserver::content_security_policy::ContentSecurityPolicy;
12
use crate::webserver::routing::RoutingConfig;
23
use anyhow::Context;
34
use clap::Parser;
@@ -245,7 +246,8 @@ pub struct AppConfig {
245246

246247
/// Content-Security-Policy header to send to the client.
247248
/// If not set, a default policy allowing scripts from the same origin is used and from jsdelivr.net
248-
pub content_security_policy: Option<String>,
249+
#[serde(default = "default_content_security_policy")]
250+
pub content_security_policy: ContentSecurityPolicy,
249251

250252
/// Whether `sqlpage.fetch` should load trusted certificates from the operating system's certificate store
251253
/// By default, it loads Mozilla's root certificates that are embedded in the `SQLPage` binary, or the ones pointed to by the
@@ -511,6 +513,10 @@ fn default_compress_responses() -> bool {
511513
true
512514
}
513515

516+
fn default_content_security_policy() -> ContentSecurityPolicy {
517+
ContentSecurityPolicy::default()
518+
}
519+
514520
fn default_system_root_ca_certificates() -> bool {
515521
std::env::var("SSL_CERT_FILE").is_ok_and(|x| !x.is_empty())
516522
|| std::env::var("SSL_CERT_DIR").is_ok_and(|x| !x.is_empty())

src/render.rs

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,9 +92,6 @@ impl HeaderContext {
9292
) -> Self {
9393
let mut response = HttpResponseBuilder::new(StatusCode::OK);
9494
response.content_type("text/html; charset=utf-8");
95-
if app_state.config.content_security_policy.is_none() {
96-
response.insert_header(&request_context.content_security_policy);
97-
}
9895
Self {
9996
app_state,
10097
request_context,
Lines changed: 85 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,107 @@
1-
use std::fmt::Display;
2-
1+
use actix_web::http::header::{
2+
HeaderName, HeaderValue, TryIntoHeaderPair, CONTENT_SECURITY_POLICY,
3+
};
34
use awc::http::header::InvalidHeaderValue;
45
use rand::random;
6+
use serde::Deserialize;
7+
use std::fmt::{Display, Formatter};
58

6-
#[derive(Debug, Clone, Copy)]
9+
#[derive(Debug, Deserialize, Clone, PartialEq)]
10+
#[serde(from = "String")]
711
pub struct ContentSecurityPolicy {
812
pub nonce: u64,
13+
value: String,
14+
}
15+
16+
impl ContentSecurityPolicy {
17+
#[must_use]
18+
pub fn is_enabled(&self) -> bool {
19+
!self.value.is_empty()
20+
}
21+
22+
fn new<S: Into<String>>(value: S) -> Self {
23+
Self {
24+
nonce: random(),
25+
value: value.into(),
26+
}
27+
}
28+
29+
#[allow(dead_code)]
30+
fn set_nonce(&mut self, nonce: u64) {
31+
self.nonce = nonce;
32+
}
933
}
1034

1135
impl Default for ContentSecurityPolicy {
1236
fn default() -> Self {
13-
Self { nonce: random() }
37+
Self::new("script-src 'self' 'nonce-{NONCE}'")
1438
}
1539
}
1640

1741
impl Display for ContentSecurityPolicy {
18-
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
19-
write!(f, "script-src 'self' 'nonce-{}'", self.nonce)
42+
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
43+
let value = self
44+
.value
45+
.replace("{NONCE}", self.nonce.to_string().as_str());
46+
47+
write!(f, "{value}")
48+
}
49+
}
50+
51+
impl From<String> for ContentSecurityPolicy {
52+
fn from(input: String) -> Self {
53+
ContentSecurityPolicy::new(input)
2054
}
2155
}
2256

23-
impl actix_web::http::header::TryIntoHeaderPair for &ContentSecurityPolicy {
57+
impl TryIntoHeaderPair for &ContentSecurityPolicy {
2458
type Error = InvalidHeaderValue;
2559

26-
fn try_into_pair(
27-
self,
28-
) -> Result<
29-
(
30-
actix_web::http::header::HeaderName,
31-
actix_web::http::header::HeaderValue,
32-
),
33-
Self::Error,
34-
> {
60+
fn try_into_pair(self) -> Result<(HeaderName, HeaderValue), Self::Error> {
3561
Ok((
36-
actix_web::http::header::CONTENT_SECURITY_POLICY,
37-
actix_web::http::header::HeaderValue::from_str(&self.to_string())?,
62+
CONTENT_SECURITY_POLICY,
63+
HeaderValue::from_str(&self.to_string())?,
3864
))
3965
}
4066
}
67+
68+
#[cfg(test)]
69+
mod tests {
70+
use super::*;
71+
72+
#[test]
73+
fn default_csp_contains_random_nonce() {
74+
let mut csp = ContentSecurityPolicy::default();
75+
csp.set_nonce(0);
76+
77+
assert_eq!(csp.to_string().as_str(), "script-src 'self' 'nonce-0'");
78+
assert!(csp.is_enabled());
79+
}
80+
81+
#[test]
82+
fn custom_csp_without_nonce() {
83+
let csp: ContentSecurityPolicy = String::from("object-src 'none';").into();
84+
assert_eq!("object-src 'none';", csp.to_string().as_str());
85+
assert!(csp.is_enabled());
86+
}
87+
88+
#[test]
89+
fn blank_csp() {
90+
let csp: ContentSecurityPolicy = String::from("").into();
91+
assert_eq!("", csp.to_string().as_str());
92+
assert!(!csp.is_enabled());
93+
}
94+
95+
#[test]
96+
fn custom_csp_with_nonce() {
97+
let mut csp: ContentSecurityPolicy =
98+
String::from("script-src 'self' 'nonce-{NONCE}'; object-src 'none';").into();
99+
csp.set_nonce(0);
100+
101+
assert_eq!(
102+
"script-src 'self' 'nonce-0'; object-src 'none';",
103+
csp.to_string().as_str()
104+
);
105+
assert!(csp.is_enabled());
106+
}
107+
}

src/webserver/http.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -507,8 +507,9 @@ pub fn payload_config(app_state: &web::Data<AppState>) -> PayloadConfig {
507507
fn default_headers(app_state: &web::Data<AppState>) -> middleware::DefaultHeaders {
508508
let server_header = format!("{} v{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"));
509509
let mut headers = middleware::DefaultHeaders::new().add(("Server", server_header));
510-
if let Some(csp) = &app_state.config.content_security_policy {
511-
headers = headers.add(("Content-Security-Policy", csp.as_str()));
510+
let csp = &app_state.config.content_security_policy;
511+
if csp.is_enabled() {
512+
headers = headers.add(csp);
512513
}
513514
headers
514515
}

src/webserver/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
//! - [`static_content`]: Static asset handling (JS, CSS, icons)
3030
//!
3131
32-
mod content_security_policy;
32+
pub mod content_security_policy;
3333
pub mod database;
3434
pub mod error_with_status;
3535
pub mod http;

0 commit comments

Comments
 (0)