From f405e05f0ec727003c692b448024ca886396ca35 Mon Sep 17 00:00:00 2001 From: Gus Power Date: Sun, 4 May 2025 13:19:39 +0100 Subject: [PATCH 01/13] Extend CSP Configuration to handle user-supplied values that can contain {NONCE} --- src/app_config.rs | 8 +- src/render.rs | 3 - src/webserver/content_security_policy.rs | 103 +++++++++++++++++++---- src/webserver/http.rs | 5 +- src/webserver/mod.rs | 2 +- 5 files changed, 96 insertions(+), 25 deletions(-) diff --git a/src/app_config.rs b/src/app_config.rs index 12bc1463..6424a926 100644 --- a/src/app_config.rs +++ b/src/app_config.rs @@ -1,3 +1,4 @@ +use crate::webserver::content_security_policy::ContentSecurityPolicy; use crate::webserver::routing::RoutingConfig; use anyhow::Context; use clap::Parser; @@ -245,7 +246,8 @@ pub struct AppConfig { /// 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, + #[serde(default = "default_content_security_policy")] + pub content_security_policy: ContentSecurityPolicy, /// 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 @@ -511,6 +513,10 @@ fn default_compress_responses() -> bool { true } +fn default_content_security_policy() -> ContentSecurityPolicy { + ContentSecurityPolicy::default() +} + fn default_system_root_ca_certificates() -> bool { std::env::var("SSL_CERT_FILE").is_ok_and(|x| !x.is_empty()) || std::env::var("SSL_CERT_DIR").is_ok_and(|x| !x.is_empty()) diff --git a/src/render.rs b/src/render.rs index aa2cdd14..a24b157f 100644 --- a/src/render.rs +++ b/src/render.rs @@ -92,9 +92,6 @@ 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); - } Self { app_state, request_context, diff --git a/src/webserver/content_security_policy.rs b/src/webserver/content_security_policy.rs index ecfb5edb..2db2e3b2 100644 --- a/src/webserver/content_security_policy.rs +++ b/src/webserver/content_security_policy.rs @@ -1,40 +1,107 @@ -use std::fmt::Display; - +use actix_web::http::header::{ + HeaderName, HeaderValue, TryIntoHeaderPair, CONTENT_SECURITY_POLICY, +}; use awc::http::header::InvalidHeaderValue; use rand::random; +use serde::Deserialize; +use std::fmt::{Display, Formatter}; -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Deserialize, Clone, PartialEq)] +#[serde(from = "String")] pub struct ContentSecurityPolicy { pub nonce: u64, + value: String, +} + +impl ContentSecurityPolicy { + #[must_use] + pub fn is_enabled(&self) -> bool { + !self.value.is_empty() + } + + fn new>(value: S) -> Self { + Self { + nonce: random(), + value: value.into(), + } + } + + #[allow(dead_code)] + fn set_nonce(&mut self, nonce: u64) { + self.nonce = nonce; + } } impl Default for ContentSecurityPolicy { fn default() -> Self { - Self { nonce: random() } + Self::new("script-src 'self' 'nonce-{NONCE}'") } } impl Display for ContentSecurityPolicy { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "script-src 'self' 'nonce-{}'", self.nonce) + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let value = self + .value + .replace("{NONCE}", self.nonce.to_string().as_str()); + + write!(f, "{value}") + } +} + +impl From for ContentSecurityPolicy { + fn from(input: String) -> Self { + ContentSecurityPolicy::new(input) } } -impl actix_web::http::header::TryIntoHeaderPair for &ContentSecurityPolicy { +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_str(&self.to_string())?, )) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_csp_contains_random_nonce() { + let mut csp = ContentSecurityPolicy::default(); + csp.set_nonce(0); + + assert_eq!(csp.to_string().as_str(), "script-src 'self' 'nonce-0'"); + assert!(csp.is_enabled()); + } + + #[test] + fn custom_csp_without_nonce() { + let csp: ContentSecurityPolicy = String::from("object-src 'none';").into(); + assert_eq!("object-src 'none';", csp.to_string().as_str()); + assert!(csp.is_enabled()); + } + + #[test] + fn blank_csp() { + let csp: ContentSecurityPolicy = String::from("").into(); + assert_eq!("", csp.to_string().as_str()); + assert!(!csp.is_enabled()); + } + + #[test] + fn custom_csp_with_nonce() { + let mut csp: ContentSecurityPolicy = + String::from("script-src 'self' 'nonce-{NONCE}'; object-src 'none';").into(); + csp.set_nonce(0); + + assert_eq!( + "script-src 'self' 'nonce-0'; object-src 'none';", + csp.to_string().as_str() + ); + assert!(csp.is_enabled()); + } +} diff --git a/src/webserver/http.rs b/src/webserver/http.rs index fc1ecea9..d1d688d0 100644 --- a/src/webserver/http.rs +++ b/src/webserver/http.rs @@ -507,8 +507,9 @@ pub fn payload_config(app_state: &web::Data) -> PayloadConfig { fn default_headers(app_state: &web::Data) -> 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())); + let csp = &app_state.config.content_security_policy; + if csp.is_enabled() { + headers = headers.add(csp); } headers } diff --git a/src/webserver/mod.rs b/src/webserver/mod.rs index 1393d9e6..040881f3 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; From b82ddfbc53f2b9b9b8564f26c609ba15f511c2fe Mon Sep 17 00:00:00 2001 From: Gus Power Date: Sun, 4 May 2025 15:07:56 +0100 Subject: [PATCH 02/13] fix RequestContext to use CSP value from AppConfig --- src/webserver/http.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/webserver/http.rs b/src/webserver/http.rs index d1d688d0..b0d7215a 100644 --- a/src/webserver/http.rs +++ b/src/webserver/http.rs @@ -175,7 +175,7 @@ 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: app_state.config.content_security_policy.clone(), }; let mut conn = None; let database_entries_stream = From e2d44d746664067f3b23e5358183a27bf5a0ea71 Mon Sep 17 00:00:00 2001 From: Gus Power Date: Sun, 4 May 2025 15:30:16 +0100 Subject: [PATCH 03/13] update documentation to describe usage --- configuration.md | 58 ++++++++++++++++++++++++------------------------ 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/configuration.md b/configuration.md index 344048d6..49c009e5 100644 --- a/configuration.md +++ b/configuration.md @@ -6,36 +6,36 @@ or a [JSON](https://en.wikipedia.org/wiki/JSON) file placed in `sqlpage/sqlpage. You can find an example configuration file in [`sqlpage/sqlpage.json`](./sqlpage/sqlpage.json). Here are the available configuration options and their default values: -| variable | default | description | -| --------------------------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `listen_on` | 0.0.0.0:8080 | Interface and port on which the web server should listen | -| `database_url` | sqlite://sqlpage.db?mode=rwc | Database connection URL, in the form `dbengine://user:password@host:port/dbname`. Special characters in user and password should be [percent-encoded](https://developer.mozilla.org/en-US/docs/Glossary/percent-encoding). | -| `database_password` | | Database password. If set, this will override any password specified in the `database_url`. This allows you to keep the password separate from the connection string for better security. | -| `port` | 8080 | Like listen_on, but specifies only the port. | -| `unix_socket` | | Path to a UNIX socket to listen on instead of the TCP port. If specified, SQLPage will accept HTTP connections only on this socket and not on any TCP port. This option is mutually exclusive with `listen_on` and `port`. -| `max_database_pool_connections` | PostgreSQL: 50
MySql: 75
SQLite: 16
MSSQL: 100 | How many simultaneous database connections to open at most | -| `database_connection_idle_timeout_seconds` | SQLite: None
All other: 30 minutes | Automatically close database connections after this period of inactivity | -| `database_connection_max_lifetime_seconds` | SQLite: None
All other: 60 minutes | Always close database connections after this amount of time | -| `database_connection_retries` | 6 | Database connection attempts before giving up. Retries will happen every 5 seconds. | -| `database_connection_acquire_timeout_seconds` | 10 | How long to wait when acquiring a database connection from the pool before giving up and returning an error. | -| `sqlite_extensions` | | An array of SQLite extensions to load, such as `mod_spatialite` | -| `web_root` | `.` | The root directory of the web server, where the `index.sql` file is located. | -| `site_prefix` | `/` | Base path of the site. If you want to host SQLPage at `https://example.com/sqlpage/`, set this to `/sqlpage/`. When using a reverse proxy, this allows hosting SQLPage together with other applications on the same subdomain. | +| variable | default | description | +|-----------------------------------------------|-------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `listen_on` | 0.0.0.0:8080 | Interface and port on which the web server should listen | +| `database_url` | sqlite://sqlpage.db?mode=rwc | Database connection URL, in the form `dbengine://user:password@host:port/dbname`. Special characters in user and password should be [percent-encoded](https://developer.mozilla.org/en-US/docs/Glossary/percent-encoding). | +| `database_password` | | Database password. If set, this will override any password specified in the `database_url`. This allows you to keep the password separate from the connection string for better security. | +| `port` | 8080 | Like listen_on, but specifies only the port. | +| `unix_socket` | | Path to a UNIX socket to listen on instead of the TCP port. If specified, SQLPage will accept HTTP connections only on this socket and not on any TCP port. This option is mutually exclusive with `listen_on` and `port`. +| `max_database_pool_connections` | PostgreSQL: 50
MySql: 75
SQLite: 16
MSSQL: 100 | How many simultaneous database connections to open at most | +| `database_connection_idle_timeout_seconds` | SQLite: None
All other: 30 minutes | Automatically close database connections after this period of inactivity | +| `database_connection_max_lifetime_seconds` | SQLite: None
All other: 60 minutes | Always close database connections after this amount of time | +| `database_connection_retries` | 6 | Database connection attempts before giving up. Retries will happen every 5 seconds. | +| `database_connection_acquire_timeout_seconds` | 10 | How long to wait when acquiring a database connection from the pool before giving up and returning an error. | +| `sqlite_extensions` | | An array of SQLite extensions to load, such as `mod_spatialite` | +| `web_root` | `.` | The root directory of the web server, where the `index.sql` file is located. | +| `site_prefix` | `/` | Base path of the site. If you want to host SQLPage at `https://example.com/sqlpage/`, set this to `/sqlpage/`. When using a reverse proxy, this allows hosting SQLPage together with other applications on the same subdomain. | | `configuration_directory` | `./sqlpage/` | The directory where the `sqlpage.json` file is located. This is used to find the path to [`templates/`](https://sql-page.com/custom_components.sql), [`migrations/`](https://sql-page.com/your-first-sql-website/migrations.sql), and `on_connect.sql`. Obviously, this configuration parameter can be set only through environment variables, not through the `sqlpage.json` file itself in order to find the `sqlpage.json` file. Be careful not to use a path that is accessible from the public WEB_ROOT | -| `allow_exec` | false | Allow usage of the `sqlpage.exec` function. Do this only if all users with write access to sqlpage query files and to the optional `sqlpage_files` table on the database are trusted. | -| `max_uploaded_file_size` | 5242880 | Maximum size of forms and uploaded files in bytes. Defaults to 5 MiB. | -| `max_pending_rows` | 256 | Maximum number of rendered rows that can be queued up in memory when a client is slow to receive them. | -| `compress_responses` | true | When the client supports it, compress the http response body. This can save bandwidth and speed up page loading on slow connections, but can also increase CPU usage and cause rendering delays on pages that take time to render (because streaming responses are buffered for longer than necessary). | -| `https_domain` | | Domain name to request a certificate for. Setting this parameter will automatically make SQLPage listen on port 443 and request an SSL certificate. The server will take a little bit longer to start the first time it has to request a certificate. | -| `https_certificate_email` | contact@ | The email address to use when requesting a certificate. | -| `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. | -| `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). | -| `markdown_allow_dangerous_protocol` | false | Whether to allow dangerous protocols (like javascript:) in markdown links. Only enable this if the markdown content is fully trusted (not user generated). | +| `allow_exec` | false | Allow usage of the `sqlpage.exec` function. Do this only if all users with write access to sqlpage query files and to the optional `sqlpage_files` table on the database are trusted. | +| `max_uploaded_file_size` | 5242880 | Maximum size of forms and uploaded files in bytes. Defaults to 5 MiB. | +| `max_pending_rows` | 256 | Maximum number of rendered rows that can be queued up in memory when a client is slow to receive them. | +| `compress_responses` | true | When the client supports it, compress the http response body. This can save bandwidth and speed up page loading on slow connections, but can also increase CPU usage and cause rendering delays on pages that take time to render (because streaming responses are buffered for longer than necessary). | +| `https_domain` | | Domain name to request a certificate for. Setting this parameter will automatically make SQLPage listen on port 443 and request an SSL certificate. The server will take a little bit longer to start the first time it has to request a certificate. | +| `https_certificate_email` | contact@ | The email address to use when requesting a certificate. | +| `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-{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). | +| `markdown_allow_dangerous_protocol` | false | Whether to allow dangerous protocols (like javascript:) in markdown links. Only enable this if the markdown content is fully trusted (not user generated). | Multiple configuration file formats are supported: you can use a [`.json5`](https://json5.org/) file, a [`.toml`](https://toml.io/) file, or a [`.yaml`](https://en.wikipedia.org/wiki/YAML#Syntax) file. From 28907f66b868b9a3a508dfa009faf296fadcce32 Mon Sep 17 00:00:00 2001 From: Gus Power Date: Mon, 5 May 2025 13:03:48 +0100 Subject: [PATCH 04/13] generate nonce per request; config is a string (again); added playwright test to verify subsequent requests return a different nonce. --- src/app_config.rs | 8 +-- src/render.rs | 3 + src/webserver/content_security_policy.rs | 70 +++++++++++------------- src/webserver/http.rs | 15 ++--- tests/end-to-end/official-site.spec.ts | 7 +++ 5 files changed, 52 insertions(+), 51 deletions(-) diff --git a/src/app_config.rs b/src/app_config.rs index 6424a926..c898705d 100644 --- a/src/app_config.rs +++ b/src/app_config.rs @@ -1,4 +1,4 @@ -use crate::webserver::content_security_policy::ContentSecurityPolicy; +use crate::webserver::content_security_policy::DEFAULT_CONTENT_SECURITY_POLICY; use crate::webserver::routing::RoutingConfig; use anyhow::Context; use clap::Parser; @@ -247,7 +247,7 @@ pub struct AppConfig { /// 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 #[serde(default = "default_content_security_policy")] - pub content_security_policy: ContentSecurityPolicy, + pub content_security_policy: String, /// 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 @@ -513,8 +513,8 @@ fn default_compress_responses() -> bool { true } -fn default_content_security_policy() -> ContentSecurityPolicy { - ContentSecurityPolicy::default() +fn default_content_security_policy() -> String { + String::from(DEFAULT_CONTENT_SECURITY_POLICY) } fn default_system_root_ca_certificates() -> bool { diff --git a/src/render.rs b/src/render.rs index a24b157f..02995ad1 100644 --- a/src/render.rs +++ b/src/render.rs @@ -92,6 +92,9 @@ impl HeaderContext { ) -> Self { let mut response = HttpResponseBuilder::new(StatusCode::OK); response.content_type("text/html; charset=utf-8"); + 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 2db2e3b2..bfb283e4 100644 --- a/src/webserver/content_security_policy.rs +++ b/src/webserver/content_security_policy.rs @@ -1,59 +1,51 @@ 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}; -#[derive(Debug, Deserialize, Clone, PartialEq)] -#[serde(from = "String")] +pub const DEFAULT_CONTENT_SECURITY_POLICY: &str = "script-src 'self' 'nonce-{NONCE}'"; + +#[derive(Debug, Clone)] pub struct ContentSecurityPolicy { pub nonce: u64, - value: String, + policy: String, } impl ContentSecurityPolicy { - #[must_use] - pub fn is_enabled(&self) -> bool { - !self.value.is_empty() - } - - fn new>(value: S) -> Self { + pub fn new>(policy: S) -> Self { Self { nonce: random(), - value: value.into(), + policy: policy.into(), } } + pub fn apply_to_response(&self, response: &mut HttpResponseBuilder) { + if self.is_enabled() { + response.insert_header(self); + } + } + + fn is_enabled(&self) -> bool { + !self.policy.is_empty() + } + #[allow(dead_code)] fn set_nonce(&mut self, nonce: u64) { self.nonce = nonce; } } -impl Default for ContentSecurityPolicy { - fn default() -> Self { - Self::new("script-src 'self' 'nonce-{NONCE}'") - } -} - impl Display for ContentSecurityPolicy { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - let value = self - .value - .replace("{NONCE}", self.nonce.to_string().as_str()); + let value = self.policy.replace("{NONCE}", &self.nonce.to_string()); write!(f, "{value}") } } -impl From for ContentSecurityPolicy { - fn from(input: String) -> Self { - ContentSecurityPolicy::new(input) - } -} - impl TryIntoHeaderPair for &ContentSecurityPolicy { type Error = InvalidHeaderValue; @@ -70,38 +62,40 @@ mod tests { use super::*; #[test] - fn default_csp_contains_random_nonce() { - let mut csp = ContentSecurityPolicy::default(); + fn default_csp_response_contains_random_nonce() { + let mut csp = ContentSecurityPolicy::new(DEFAULT_CONTENT_SECURITY_POLICY); csp.set_nonce(0); - assert_eq!(csp.to_string().as_str(), "script-src 'self' 'nonce-0'"); assert!(csp.is_enabled()); + assert_eq!(&csp.to_string(), "script-src 'self' 'nonce-0'"); } #[test] - fn custom_csp_without_nonce() { - let csp: ContentSecurityPolicy = String::from("object-src 'none';").into(); - assert_eq!("object-src 'none';", csp.to_string().as_str()); + fn custom_csp_response_without_nonce() { + let csp = ContentSecurityPolicy::new("object-src 'none';"); + assert!(csp.is_enabled()); + assert_eq!("object-src 'none';", &csp.to_string()); } #[test] - fn blank_csp() { - let csp: ContentSecurityPolicy = String::from("").into(); - assert_eq!("", csp.to_string().as_str()); + fn blank_csp_response() { + let csp = ContentSecurityPolicy::new(""); + assert!(!csp.is_enabled()); + assert_eq!("", &csp.to_string()); } #[test] fn custom_csp_with_nonce() { - let mut csp: ContentSecurityPolicy = - String::from("script-src 'self' 'nonce-{NONCE}'; object-src 'none';").into(); + let mut csp = + ContentSecurityPolicy::new("script-src 'self' 'nonce-{NONCE}'; object-src 'none';"); csp.set_nonce(0); + assert!(csp.is_enabled()); assert_eq!( "script-src 'self' 'nonce-0'; object-src 'none';", csp.to_string().as_str() ); - assert!(csp.is_enabled()); } } diff --git a/src/webserver/http.rs b/src/webserver/http.rs index b0d7215a..357545b5 100644 --- a/src/webserver/http.rs +++ b/src/webserver/http.rs @@ -175,7 +175,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: app_state.config.content_security_policy.clone(), + content_security_policy: ContentSecurityPolicy::new( + &app_state.config.content_security_policy, + ), }; let mut conn = None; let database_entries_stream = @@ -467,7 +469,7 @@ pub fn create_app( // when receiving a request outside of the prefix, redirect to the prefix .default_service(fn_service(default_prefix_redirect)) .wrap(Logger::default()) - .wrap(default_headers(&app_state)) + .wrap(default_headers()) .wrap(middleware::Condition::new( app_state.config.compress_responses, middleware::Compress::default(), @@ -504,14 +506,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)); - let csp = &app_state.config.content_security_policy; - if csp.is_enabled() { - headers = headers.add(csp); - } - headers + middleware::DefaultHeaders::new().add(("Server", server_header)) } pub async fn run_server(config: &AppConfig, state: AppState) -> anyhow::Result<()> { diff --git a/tests/end-to-end/official-site.spec.ts b/tests/end-to-end/official-site.spec.ts index 27a75844..f94d2b59 100644 --- a/tests/end-to-end/official-site.spec.ts +++ b/tests/end-to-end/official-site.spec.ts @@ -158,6 +158,13 @@ test("no console errors on card page", async ({ page }) => { await checkNoConsoleErrors(page, "card"); }); +test("CSP issues unique nonces per request", async ({page}) => { + let csp1 = await (await page.goto(BASE)).headerValue("content-security-policy"); + let 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`); From 36d10e1660ec8503133e40983ab958cec5f6e464 Mon Sep 17 00:00:00 2001 From: Gus Power Date: Mon, 5 May 2025 13:06:53 +0100 Subject: [PATCH 05/13] fix js lint `let` -> `const` --- tests/end-to-end/official-site.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/end-to-end/official-site.spec.ts b/tests/end-to-end/official-site.spec.ts index f94d2b59..37991c54 100644 --- a/tests/end-to-end/official-site.spec.ts +++ b/tests/end-to-end/official-site.spec.ts @@ -159,8 +159,8 @@ test("no console errors on card page", async ({ page }) => { }); test("CSP issues unique nonces per request", async ({page}) => { - let csp1 = await (await page.goto(BASE)).headerValue("content-security-policy"); - let csp2 = await (await page.reload()).headerValue("content-security-policy"); + 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); }); From 93aa1b754005133002d386e6e4e5a770cb34da59 Mon Sep 17 00:00:00 2001 From: lovasoa Date: Mon, 5 May 2025 23:26:07 +0200 Subject: [PATCH 06/13] format --- tests/end-to-end/official-site.spec.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/end-to-end/official-site.spec.ts b/tests/end-to-end/official-site.spec.ts index 37991c54..f7999523 100644 --- a/tests/end-to-end/official-site.spec.ts +++ b/tests/end-to-end/official-site.spec.ts @@ -158,9 +158,13 @@ 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"); +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); }); From aadce60f994c49ba5b69329b8addcff76705b6e6 Mon Sep 17 00:00:00 2001 From: lovasoa Date: Mon, 5 May 2025 23:41:59 +0200 Subject: [PATCH 07/13] remove some useless string copies we are still re-parsing the csp template on every request --- src/webserver/content_security_policy.rs | 72 ++++++------------------ src/webserver/http.rs | 4 +- 2 files changed, 18 insertions(+), 58 deletions(-) diff --git a/src/webserver/content_security_policy.rs b/src/webserver/content_security_policy.rs index bfb283e4..f3c60b76 100644 --- a/src/webserver/content_security_policy.rs +++ b/src/webserver/content_security_policy.rs @@ -5,20 +5,23 @@ use actix_web::HttpResponseBuilder; use awc::http::header::InvalidHeaderValue; use rand::random; use std::fmt::{Display, Formatter}; +use std::sync::Arc; + +use crate::AppState; pub const DEFAULT_CONTENT_SECURITY_POLICY: &str = "script-src 'self' 'nonce-{NONCE}'"; #[derive(Debug, Clone)] pub struct ContentSecurityPolicy { pub nonce: u64, - policy: String, + app_state: Arc, } impl ContentSecurityPolicy { - pub fn new>(policy: S) -> Self { + pub fn new(app_state: Arc) -> Self { Self { nonce: random(), - policy: policy.into(), + app_state, } } @@ -28,21 +31,23 @@ impl ContentSecurityPolicy { } } - fn is_enabled(&self) -> bool { - !self.policy.is_empty() + fn template_string(&self) -> &str { + &self.app_state.config.content_security_policy } - #[allow(dead_code)] - fn set_nonce(&mut self, nonce: u64) { - self.nonce = nonce; + fn is_enabled(&self) -> bool { + !self.app_state.config.content_security_policy.is_empty() } } impl Display for ContentSecurityPolicy { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - let value = self.policy.replace("{NONCE}", &self.nonce.to_string()); - - write!(f, "{value}") + let template = self.template_string(); + if let Some((before, after)) = template.split_once("{NONCE}") { + write!(f, "{before}{nonce}{after}", nonce = self.nonce) + } else { + write!(f, "{}", template) + } } } @@ -52,50 +57,7 @@ impl TryIntoHeaderPair for &ContentSecurityPolicy { fn try_into_pair(self) -> Result<(HeaderName, HeaderValue), Self::Error> { Ok(( CONTENT_SECURITY_POLICY, - HeaderValue::from_str(&self.to_string())?, + HeaderValue::from_maybe_shared(self.to_string())?, )) } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn default_csp_response_contains_random_nonce() { - let mut csp = ContentSecurityPolicy::new(DEFAULT_CONTENT_SECURITY_POLICY); - csp.set_nonce(0); - - assert!(csp.is_enabled()); - assert_eq!(&csp.to_string(), "script-src 'self' 'nonce-0'"); - } - - #[test] - fn custom_csp_response_without_nonce() { - let csp = ContentSecurityPolicy::new("object-src 'none';"); - - assert!(csp.is_enabled()); - assert_eq!("object-src 'none';", &csp.to_string()); - } - - #[test] - fn blank_csp_response() { - let csp = ContentSecurityPolicy::new(""); - - assert!(!csp.is_enabled()); - assert_eq!("", &csp.to_string()); - } - - #[test] - fn custom_csp_with_nonce() { - let mut csp = - ContentSecurityPolicy::new("script-src 'self' 'nonce-{NONCE}'; object-src 'none';"); - csp.set_nonce(0); - - assert!(csp.is_enabled()); - assert_eq!( - "script-src 'self' 'nonce-0'; object-src 'none';", - csp.to_string().as_str() - ); - } -} diff --git a/src/webserver/http.rs b/src/webserver/http.rs index 102168ac..09997ac5 100644 --- a/src/webserver/http.rs +++ b/src/webserver/http.rs @@ -177,9 +177,7 @@ 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::new( - &app_state.config.content_security_policy, - ), + content_security_policy: ContentSecurityPolicy::new(Arc::clone(&app_state)), }; let mut conn = None; let database_entries_stream = From f08fa577883cc9b45d0f800938708be825fb4acc Mon Sep 17 00:00:00 2001 From: lovasoa Date: Mon, 5 May 2025 23:55:42 +0200 Subject: [PATCH 08/13] implement a proper csp template struct --- src/webserver/content_security_policy.rs | 72 +++++++++++++++++++----- src/webserver/http.rs | 6 +- 2 files changed, 62 insertions(+), 16 deletions(-) diff --git a/src/webserver/content_security_policy.rs b/src/webserver/content_security_policy.rs index f3c60b76..0d932a5c 100644 --- a/src/webserver/content_security_policy.rs +++ b/src/webserver/content_security_policy.rs @@ -7,21 +7,45 @@ use rand::random; use std::fmt::{Display, Formatter}; use std::sync::Arc; -use crate::AppState; - pub const DEFAULT_CONTENT_SECURITY_POLICY: &str = "script-src 'self' 'nonce-{NONCE}'"; #[derive(Debug, Clone)] pub struct ContentSecurityPolicy { pub nonce: u64, - app_state: Arc, + 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)] +pub struct ContentSecurityPolicyTemplate { + pub before_nonce: Arc, + pub after_nonce: Option>, +} + +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 ContentSecurityPolicy { - pub fn new(app_state: Arc) -> Self { + pub fn new(template: ContentSecurityPolicyTemplate) -> Self { Self { nonce: random(), - app_state, + template, } } @@ -31,26 +55,22 @@ impl ContentSecurityPolicy { } } - fn template_string(&self) -> &str { - &self.app_state.config.content_security_policy - } - fn is_enabled(&self) -> bool { - !self.app_state.config.content_security_policy.is_empty() + !self.template.before_nonce.is_empty() } } impl Display for ContentSecurityPolicy { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - let template = self.template_string(); - if let Some((before, after)) = template.split_once("{NONCE}") { - write!(f, "{before}{nonce}{after}", nonce = self.nonce) + 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, "{}", template) + write!(f, "{before}") } } } - impl TryIntoHeaderPair for &ContentSecurityPolicy { type Error = InvalidHeaderValue; @@ -61,3 +81,25 @@ impl TryIntoHeaderPair for &ContentSecurityPolicy { )) } } + +#[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 09997ac5..18fe190a 100644 --- a/src/webserver/http.rs +++ b/src/webserver/http.rs @@ -177,7 +177,11 @@ 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::new(Arc::clone(&app_state)), + content_security_policy: ContentSecurityPolicy::new( + crate::webserver::content_security_policy::ContentSecurityPolicyTemplate::from( + app_state.config.content_security_policy.as_str(), + ), + ), }; let mut conn = None; let database_entries_stream = From 8e913b8a66f4d07fb4a0af3813707ec9d63c9332 Mon Sep 17 00:00:00 2001 From: lovasoa Date: Tue, 6 May 2025 00:05:03 +0200 Subject: [PATCH 09/13] parse the content-security-policy just once --- src/app_config.rs | 10 +++------- src/webserver/content_security_policy.rs | 21 +++++++++++++++++++-- src/webserver/http.rs | 4 +--- 3 files changed, 23 insertions(+), 12 deletions(-) diff --git a/src/app_config.rs b/src/app_config.rs index 3dea8d94..aec2585a 100644 --- a/src/app_config.rs +++ b/src/app_config.rs @@ -1,4 +1,4 @@ -use crate::webserver::content_security_policy::DEFAULT_CONTENT_SECURITY_POLICY; +use crate::webserver::content_security_policy::ContentSecurityPolicyTemplate; use crate::webserver::routing::RoutingConfig; use anyhow::Context; use clap::Parser; @@ -266,8 +266,8 @@ pub struct AppConfig { /// 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 - #[serde(default = "default_content_security_policy")] - pub content_security_policy: String, + #[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 @@ -533,10 +533,6 @@ fn default_compress_responses() -> bool { true } -fn default_content_security_policy() -> String { - String::from(DEFAULT_CONTENT_SECURITY_POLICY) -} - fn default_system_root_ca_certificates() -> bool { std::env::var("SSL_CERT_FILE").is_ok_and(|x| !x.is_empty()) || std::env::var("SSL_CERT_DIR").is_ok_and(|x| !x.is_empty()) diff --git a/src/webserver/content_security_policy.rs b/src/webserver/content_security_policy.rs index 0d932a5c..3eb9b422 100644 --- a/src/webserver/content_security_policy.rs +++ b/src/webserver/content_security_policy.rs @@ -4,6 +4,7 @@ use actix_web::http::header::{ use actix_web::HttpResponseBuilder; use awc::http::header::InvalidHeaderValue; use rand::random; +use serde::Deserialize; use std::fmt::{Display, Formatter}; use std::sync::Arc; @@ -19,12 +20,18 @@ pub struct ContentSecurityPolicy { /// 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)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct ContentSecurityPolicyTemplate { pub before_nonce: Arc, pub after_nonce: Option>, } +impl Default for ContentSecurityPolicyTemplate { + fn default() -> Self { + Self::from(DEFAULT_CONTENT_SECURITY_POLICY) + } +} + impl From<&str> for ContentSecurityPolicyTemplate { fn from(s: &str) -> Self { if let Some((before, after)) = s.split_once("{NONCE}") { @@ -41,6 +48,16 @@ impl From<&str> for ContentSecurityPolicyTemplate { } } +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 { pub fn new(template: ContentSecurityPolicyTemplate) -> Self { Self { @@ -56,7 +73,7 @@ impl ContentSecurityPolicy { } fn is_enabled(&self) -> bool { - !self.template.before_nonce.is_empty() + !self.template.before_nonce.is_empty() || self.template.after_nonce.is_some() } } diff --git a/src/webserver/http.rs b/src/webserver/http.rs index 18fe190a..30dadb12 100644 --- a/src/webserver/http.rs +++ b/src/webserver/http.rs @@ -178,9 +178,7 @@ async fn render_sql( let request_context = RequestContext { is_embedded: req_param.get_variables.contains_key("_sqlpage_embed"), content_security_policy: ContentSecurityPolicy::new( - crate::webserver::content_security_policy::ContentSecurityPolicyTemplate::from( - app_state.config.content_security_policy.as_str(), - ), + app_state.config.content_security_policy.clone(), ), }; let mut conn = None; From a88f3507250e4dd41f53cd32db8625d791377702 Mon Sep 17 00:00:00 2001 From: lovasoa Date: Tue, 6 May 2025 00:07:23 +0200 Subject: [PATCH 10/13] fix merge issue. --- configuration.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/configuration.md b/configuration.md index 30b2eaee..0772214e 100644 --- a/configuration.md +++ b/configuration.md @@ -25,6 +25,10 @@ Here are the available configuration options and their default values: | `configuration_directory` | `./sqlpage/` | The directory where the `sqlpage.json` file is located. This is used to find the path to [`templates/`](https://sql-page.com/custom_components.sql), [`migrations/`](https://sql-page.com/your-first-sql-website/migrations.sql), and `on_connect.sql`. Obviously, this configuration parameter can be set only through environment variables, not through the `sqlpage.json` file itself in order to find the `sqlpage.json` file. Be careful not to use a path that is accessible from the public WEB_ROOT | | `allow_exec` | false | Allow usage of the `sqlpage.exec` function. Do this only if all users with write access to sqlpage query files and to the optional `sqlpage_files` table on the database are trusted. | | `max_uploaded_file_size` | 5242880 | Maximum size of forms and uploaded files in bytes. Defaults to 5 MiB. | +| `oidc_issuer_url` | | The base URL of the [OpenID Connect provider](#openid-connect-oidc-authentication). Required for enabling Single Sign-On. | +| `oidc_client_id` | sqlpage | The ID that identifies your SQLPage application to the OIDC provider. You get this when registering your app with the provider. | +| `oidc_client_secret` | | The secret key for your SQLPage application. Keep this confidential as it allows your app to authenticate with the OIDC provider. | +| `oidc_scopes` | openid email profile | Space-separated list of [scopes](https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims) your app requests from the OIDC provider. | | `max_pending_rows` | 256 | Maximum number of rendered rows that can be queued up in memory when a client is slow to receive them. | | `compress_responses` | true | When the client supports it, compress the http response body. This can save bandwidth and speed up page loading on slow connections, but can also increase CPU usage and cause rendering delays on pages that take time to render (because streaming responses are buffered for longer than necessary). | | `https_domain` | | Domain name to request a certificate for. Setting this parameter will automatically make SQLPage listen on port 443 and request an SSL certificate. The server will take a little bit longer to start the first time it has to request a certificate. | From d7ecf8f0e4615e89dab6f0cb378a711f26edeaf2 Mon Sep 17 00:00:00 2001 From: lovasoa Date: Tue, 6 May 2025 00:09:31 +0200 Subject: [PATCH 11/13] fix docs --- configuration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/configuration.md b/configuration.md index 0772214e..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-{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. | +| `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). | From 29d7c991089e9f2cc7a84111ca0d496834137cb7 Mon Sep 17 00:00:00 2001 From: lovasoa Date: Tue, 6 May 2025 00:15:16 +0200 Subject: [PATCH 12/13] fix docs --- src/app_config.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/app_config.rs b/src/app_config.rs index aec2585a..584d32a2 100644 --- a/src/app_config.rs +++ b/src/app_config.rs @@ -265,7 +265,9 @@ 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 + /// 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, From 4877a25eb962279f7fed5bf845a79557ba10c49c Mon Sep 17 00:00:00 2001 From: lovasoa Date: Tue, 6 May 2025 00:16:52 +0200 Subject: [PATCH 13/13] clippy --- src/webserver/content_security_policy.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/webserver/content_security_policy.rs b/src/webserver/content_security_policy.rs index 3eb9b422..af9623d7 100644 --- a/src/webserver/content_security_policy.rs +++ b/src/webserver/content_security_policy.rs @@ -59,6 +59,7 @@ impl<'de> Deserialize<'de> for ContentSecurityPolicyTemplate { } impl ContentSecurityPolicy { + #[must_use] pub fn new(template: ContentSecurityPolicyTemplate) -> Self { Self { nonce: random(),