diff --git a/configuration.md b/configuration.md index 798a97d1..7eae6a5b 100644 --- a/configuration.md +++ b/configuration.md @@ -36,7 +36,7 @@ Here are the available configuration options and their default values: | `https_certificate_cache_dir` | ./sqlpage/https | A writeable directory where to cache the certificates, so that SQLPage can serve https traffic immediately when it restarts. | | `https_acme_directory_url` | https://acme-v02.api.letsencrypt.org/directory | The URL of the ACME directory to use when requesting a certificate. | | `environment` | development | The environment in which SQLPage is running. Can be either `development` or `production`. In `production` mode, SQLPage will hide error messages and stack traces from the user, and will cache sql files in memory to avoid reloading them from disk. | -| `content_security_policy` | `script-src 'self' 'nonce-XXX` | The [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) to set in the HTTP headers. If you get CSP errors in the browser console, you can set this to the empty string to disable CSP. | +| `content_security_policy` | `script-src 'self' 'nonce-{NONCE}'` | The [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) to set in the HTTP headers. If you get CSP errors in the browser console, you can set this to the empty string to disable CSP. If you want a custom CSP that contains a nonce, include the `'nonce-{NONCE}'` directive in your configuration string and it will be populated with a random value per request. | | `system_root_ca_certificates` | false | Whether to use the system root CA certificates to validate SSL certificates when making http requests with `sqlpage.fetch`. If set to false, SQLPage will use its own set of root CA certificates. If the `SSL_CERT_FILE` or `SSL_CERT_DIR` environment variables are set, they will be used instead of the system root CA certificates. | | `max_recursion_depth` | 10 | Maximum depth of recursion allowed in the `run_sql` function. Maximum value is 255. | | `markdown_allow_dangerous_html` | false | Whether to allow raw HTML in markdown content. Only enable this if the markdown content is fully trusted (not user generated). | diff --git a/src/app_config.rs b/src/app_config.rs index 462591aa..584d32a2 100644 --- a/src/app_config.rs +++ b/src/app_config.rs @@ -1,3 +1,4 @@ +use crate::webserver::content_security_policy::ContentSecurityPolicyTemplate; use crate::webserver::routing::RoutingConfig; use anyhow::Context; use clap::Parser; @@ -264,8 +265,11 @@ pub struct AppConfig { pub compress_responses: bool, /// Content-Security-Policy header to send to the client. - /// If not set, a default policy allowing scripts from the same origin is used and from jsdelivr.net - pub content_security_policy: Option, + /// If not set, a default policy allowing + /// - scripts from the same origin, + /// - script elements with the `nonce="{{@csp_nonce}}"` attribute, + #[serde(default)] + pub content_security_policy: ContentSecurityPolicyTemplate, /// Whether `sqlpage.fetch` should load trusted certificates from the operating system's certificate store /// By default, it loads Mozilla's root certificates that are embedded in the `SQLPage` binary, or the ones pointed to by the diff --git a/src/render.rs b/src/render.rs index aa2cdd14..02995ad1 100644 --- a/src/render.rs +++ b/src/render.rs @@ -92,9 +92,9 @@ impl HeaderContext { ) -> Self { let mut response = HttpResponseBuilder::new(StatusCode::OK); response.content_type("text/html; charset=utf-8"); - if app_state.config.content_security_policy.is_none() { - response.insert_header(&request_context.content_security_policy); - } + request_context + .content_security_policy + .apply_to_response(&mut response); Self { app_state, request_context, diff --git a/src/webserver/content_security_policy.rs b/src/webserver/content_security_policy.rs index ecfb5edb..af9623d7 100644 --- a/src/webserver/content_security_policy.rs +++ b/src/webserver/content_security_policy.rs @@ -1,40 +1,123 @@ -use std::fmt::Display; - +use actix_web::http::header::{ + HeaderName, HeaderValue, TryIntoHeaderPair, CONTENT_SECURITY_POLICY, +}; +use actix_web::HttpResponseBuilder; use awc::http::header::InvalidHeaderValue; use rand::random; +use serde::Deserialize; +use std::fmt::{Display, Formatter}; +use std::sync::Arc; + +pub const DEFAULT_CONTENT_SECURITY_POLICY: &str = "script-src 'self' 'nonce-{NONCE}'"; -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone)] pub struct ContentSecurityPolicy { pub nonce: u64, + template: ContentSecurityPolicyTemplate, +} + +/// A template for the Content Security Policy header. +/// The template is a string that contains the nonce placeholder. +/// The nonce placeholder is replaced with the nonce value when the Content Security Policy is applied to a response. +/// This struct is cheap to clone. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ContentSecurityPolicyTemplate { + pub before_nonce: Arc, + pub after_nonce: Option>, } -impl Default for ContentSecurityPolicy { +impl Default for ContentSecurityPolicyTemplate { fn default() -> Self { - Self { nonce: random() } + Self::from(DEFAULT_CONTENT_SECURITY_POLICY) } } -impl Display for ContentSecurityPolicy { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "script-src 'self' 'nonce-{}'", self.nonce) +impl From<&str> for ContentSecurityPolicyTemplate { + fn from(s: &str) -> Self { + if let Some((before, after)) = s.split_once("{NONCE}") { + Self { + before_nonce: Arc::from(before), + after_nonce: Some(Arc::from(after)), + } + } else { + Self { + before_nonce: Arc::from(s), + after_nonce: None, + } + } + } +} + +impl<'de> Deserialize<'de> for ContentSecurityPolicyTemplate { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s: &str = Deserialize::deserialize(deserializer)?; + Ok(Self::from(s)) + } +} + +impl ContentSecurityPolicy { + #[must_use] + pub fn new(template: ContentSecurityPolicyTemplate) -> Self { + Self { + nonce: random(), + template, + } + } + + pub fn apply_to_response(&self, response: &mut HttpResponseBuilder) { + if self.is_enabled() { + response.insert_header(self); + } + } + + fn is_enabled(&self) -> bool { + !self.template.before_nonce.is_empty() || self.template.after_nonce.is_some() } } -impl actix_web::http::header::TryIntoHeaderPair for &ContentSecurityPolicy { +impl Display for ContentSecurityPolicy { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let before = self.template.before_nonce.as_ref(); + if let Some(after) = &self.template.after_nonce { + let nonce = self.nonce; + write!(f, "{before}{nonce}{after}") + } else { + write!(f, "{before}") + } + } +} +impl TryIntoHeaderPair for &ContentSecurityPolicy { type Error = InvalidHeaderValue; - fn try_into_pair( - self, - ) -> Result< - ( - actix_web::http::header::HeaderName, - actix_web::http::header::HeaderValue, - ), - Self::Error, - > { + fn try_into_pair(self) -> Result<(HeaderName, HeaderValue), Self::Error> { Ok(( - actix_web::http::header::CONTENT_SECURITY_POLICY, - actix_web::http::header::HeaderValue::from_str(&self.to_string())?, + CONTENT_SECURITY_POLICY, + HeaderValue::from_maybe_shared(self.to_string())?, )) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_content_security_policy_display() { + let template = ContentSecurityPolicyTemplate::from( + "script-src 'self' 'nonce-{NONCE}' 'unsafe-inline'", + ); + let csp = ContentSecurityPolicy::new(template.clone()); + let csp_str = csp.to_string(); + assert!(csp_str.starts_with("script-src 'self' 'nonce-")); + assert!(csp_str.ends_with("' 'unsafe-inline'")); + let second_csp = ContentSecurityPolicy::new(template); + let second_csp_str = second_csp.to_string(); + assert_ne!( + csp_str, second_csp_str, + "We should not generate the same nonce twice" + ); + } +} diff --git a/src/webserver/http.rs b/src/webserver/http.rs index 19e5c7a5..30dadb12 100644 --- a/src/webserver/http.rs +++ b/src/webserver/http.rs @@ -177,7 +177,9 @@ async fn render_sql( actix_web::rt::spawn(async move { let request_context = RequestContext { is_embedded: req_param.get_variables.contains_key("_sqlpage_embed"), - content_security_policy: ContentSecurityPolicy::default(), + content_security_policy: ContentSecurityPolicy::new( + app_state.config.content_security_policy.clone(), + ), }; let mut conn = None; let database_entries_stream = @@ -470,7 +472,7 @@ pub fn create_app( .default_service(fn_service(default_prefix_redirect)) .wrap(OidcMiddleware::new(&app_state)) .wrap(Logger::default()) - .wrap(default_headers(&app_state)) + .wrap(default_headers()) .wrap(middleware::Condition::new( app_state.config.compress_responses, middleware::Compress::default(), @@ -508,13 +510,9 @@ pub fn payload_config(app_state: &web::Data) -> PayloadConfig { PayloadConfig::default().limit(app_state.config.max_uploaded_file_size * 2) } -fn default_headers(app_state: &web::Data) -> middleware::DefaultHeaders { +fn default_headers() -> middleware::DefaultHeaders { let server_header = format!("{} v{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")); - let mut headers = middleware::DefaultHeaders::new().add(("Server", server_header)); - if let Some(csp) = &app_state.config.content_security_policy { - headers = headers.add(("Content-Security-Policy", csp.as_str())); - } - headers + middleware::DefaultHeaders::new().add(("Server", server_header)) } pub async fn run_server(config: &AppConfig, state: AppState) -> anyhow::Result<()> { diff --git a/src/webserver/mod.rs b/src/webserver/mod.rs index 4de28ead..47c33df7 100644 --- a/src/webserver/mod.rs +++ b/src/webserver/mod.rs @@ -29,7 +29,7 @@ //! - [`static_content`]: Static asset handling (JS, CSS, icons) //! -mod content_security_policy; +pub mod content_security_policy; pub mod database; pub mod error_with_status; pub mod http; diff --git a/tests/end-to-end/official-site.spec.ts b/tests/end-to-end/official-site.spec.ts index 27a75844..f7999523 100644 --- a/tests/end-to-end/official-site.spec.ts +++ b/tests/end-to-end/official-site.spec.ts @@ -158,6 +158,17 @@ test("no console errors on card page", async ({ page }) => { await checkNoConsoleErrors(page, "card"); }); +test("CSP issues unique nonces per request", async ({ page }) => { + const csp1 = await (await page.goto(BASE)).headerValue( + "content-security-policy", + ); + const csp2 = await (await page.reload()).headerValue( + "content-security-policy", + ); + + expect(csp1, `check if ${csp1} != ${csp2}`).not.toEqual(csp2); +}); + test("form component documentation", async ({ page }) => { await page.goto(`${BASE}/component.sql?component=form`);