Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). |
Expand Down
8 changes: 6 additions & 2 deletions src/app_config.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use crate::webserver::content_security_policy::ContentSecurityPolicyTemplate;
use crate::webserver::routing::RoutingConfig;
use anyhow::Context;
use clap::Parser;
Expand Down Expand Up @@ -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<String>,
/// 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
Expand Down
6 changes: 3 additions & 3 deletions src/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
123 changes: 103 additions & 20 deletions src/webserver/content_security_policy.rs
Original file line number Diff line number Diff line change
@@ -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<str>,
pub after_nonce: Option<Arc<str>>,
}

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<D>(deserializer: D) -> Result<Self, D::Error>
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"
);
}
}
14 changes: 6 additions & 8 deletions src/webserver/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -508,13 +510,9 @@ pub fn payload_config(app_state: &web::Data<AppState>) -> PayloadConfig {
PayloadConfig::default().limit(app_state.config.max_uploaded_file_size * 2)
}

fn default_headers(app_state: &web::Data<AppState>) -> 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<()> {
Expand Down
2 changes: 1 addition & 1 deletion src/webserver/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
11 changes: 11 additions & 0 deletions tests/end-to-end/official-site.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);

Expand Down
Loading