Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
6 changes: 1 addition & 5 deletions configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,18 +25,14 @@ 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. |
| `https_certificate_email` | contact@<https_domain> | 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. |
| `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: 7 additions & 1 deletion src/app_config.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use crate::webserver::content_security_policy::DEFAULT_CONTENT_SECURITY_POLICY;
use crate::webserver::routing::RoutingConfig;
use anyhow::Context;
use clap::Parser;
Expand Down Expand Up @@ -265,7 +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
pub content_security_policy: Option<String>,
#[serde(default = "default_content_security_policy")]
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
Expand Down Expand Up @@ -531,6 +533,10 @@ 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())
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
101 changes: 81 additions & 20 deletions src/webserver/content_security_policy.rs
Original file line number Diff line number Diff line change
@@ -1,40 +1,101 @@
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 std::fmt::{Display, Formatter};

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,
policy: String,
}

impl Default for ContentSecurityPolicy {
fn default() -> Self {
Self { nonce: random() }
impl ContentSecurityPolicy {
pub fn new<S: Into<String>>(policy: S) -> Self {
Self {
nonce: random(),
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 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.policy.replace("{NONCE}", &self.nonce.to_string());

write!(f, "{value}")
}
}

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_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()
);
}
}
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,
),
};
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