Skip to content

Commit 28907f6

Browse files
committed
generate nonce per request; config is a string (again); added playwright test to verify subsequent requests return a different nonce.
1 parent e2d44d7 commit 28907f6

File tree

5 files changed

+52
-51
lines changed

5 files changed

+52
-51
lines changed

src/app_config.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use crate::webserver::content_security_policy::ContentSecurityPolicy;
1+
use crate::webserver::content_security_policy::DEFAULT_CONTENT_SECURITY_POLICY;
22
use crate::webserver::routing::RoutingConfig;
33
use anyhow::Context;
44
use clap::Parser;
@@ -247,7 +247,7 @@ pub struct AppConfig {
247247
/// Content-Security-Policy header to send to the client.
248248
/// If not set, a default policy allowing scripts from the same origin is used and from jsdelivr.net
249249
#[serde(default = "default_content_security_policy")]
250-
pub content_security_policy: ContentSecurityPolicy,
250+
pub content_security_policy: String,
251251

252252
/// Whether `sqlpage.fetch` should load trusted certificates from the operating system's certificate store
253253
/// 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 {
513513
true
514514
}
515515

516-
fn default_content_security_policy() -> ContentSecurityPolicy {
517-
ContentSecurityPolicy::default()
516+
fn default_content_security_policy() -> String {
517+
String::from(DEFAULT_CONTENT_SECURITY_POLICY)
518518
}
519519

520520
fn default_system_root_ca_certificates() -> bool {

src/render.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,9 @@ impl HeaderContext {
9292
) -> Self {
9393
let mut response = HttpResponseBuilder::new(StatusCode::OK);
9494
response.content_type("text/html; charset=utf-8");
95+
request_context
96+
.content_security_policy
97+
.apply_to_response(&mut response);
9598
Self {
9699
app_state,
97100
request_context,
Lines changed: 32 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,59 +1,51 @@
11
use actix_web::http::header::{
22
HeaderName, HeaderValue, TryIntoHeaderPair, CONTENT_SECURITY_POLICY,
33
};
4+
use actix_web::HttpResponseBuilder;
45
use awc::http::header::InvalidHeaderValue;
56
use rand::random;
6-
use serde::Deserialize;
77
use std::fmt::{Display, Formatter};
88

9-
#[derive(Debug, Deserialize, Clone, PartialEq)]
10-
#[serde(from = "String")]
9+
pub const DEFAULT_CONTENT_SECURITY_POLICY: &str = "script-src 'self' 'nonce-{NONCE}'";
10+
11+
#[derive(Debug, Clone)]
1112
pub struct ContentSecurityPolicy {
1213
pub nonce: u64,
13-
value: String,
14+
policy: String,
1415
}
1516

1617
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 {
18+
pub fn new<S: Into<String>>(policy: S) -> Self {
2319
Self {
2420
nonce: random(),
25-
value: value.into(),
21+
policy: policy.into(),
2622
}
2723
}
2824

25+
pub fn apply_to_response(&self, response: &mut HttpResponseBuilder) {
26+
if self.is_enabled() {
27+
response.insert_header(self);
28+
}
29+
}
30+
31+
fn is_enabled(&self) -> bool {
32+
!self.policy.is_empty()
33+
}
34+
2935
#[allow(dead_code)]
3036
fn set_nonce(&mut self, nonce: u64) {
3137
self.nonce = nonce;
3238
}
3339
}
3440

35-
impl Default for ContentSecurityPolicy {
36-
fn default() -> Self {
37-
Self::new("script-src 'self' 'nonce-{NONCE}'")
38-
}
39-
}
40-
4141
impl Display for ContentSecurityPolicy {
4242
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
43-
let value = self
44-
.value
45-
.replace("{NONCE}", self.nonce.to_string().as_str());
43+
let value = self.policy.replace("{NONCE}", &self.nonce.to_string());
4644

4745
write!(f, "{value}")
4846
}
4947
}
5048

51-
impl From<String> for ContentSecurityPolicy {
52-
fn from(input: String) -> Self {
53-
ContentSecurityPolicy::new(input)
54-
}
55-
}
56-
5749
impl TryIntoHeaderPair for &ContentSecurityPolicy {
5850
type Error = InvalidHeaderValue;
5951

@@ -70,38 +62,40 @@ mod tests {
7062
use super::*;
7163

7264
#[test]
73-
fn default_csp_contains_random_nonce() {
74-
let mut csp = ContentSecurityPolicy::default();
65+
fn default_csp_response_contains_random_nonce() {
66+
let mut csp = ContentSecurityPolicy::new(DEFAULT_CONTENT_SECURITY_POLICY);
7567
csp.set_nonce(0);
7668

77-
assert_eq!(csp.to_string().as_str(), "script-src 'self' 'nonce-0'");
7869
assert!(csp.is_enabled());
70+
assert_eq!(&csp.to_string(), "script-src 'self' 'nonce-0'");
7971
}
8072

8173
#[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());
74+
fn custom_csp_response_without_nonce() {
75+
let csp = ContentSecurityPolicy::new("object-src 'none';");
76+
8577
assert!(csp.is_enabled());
78+
assert_eq!("object-src 'none';", &csp.to_string());
8679
}
8780

8881
#[test]
89-
fn blank_csp() {
90-
let csp: ContentSecurityPolicy = String::from("").into();
91-
assert_eq!("", csp.to_string().as_str());
82+
fn blank_csp_response() {
83+
let csp = ContentSecurityPolicy::new("");
84+
9285
assert!(!csp.is_enabled());
86+
assert_eq!("", &csp.to_string());
9387
}
9488

9589
#[test]
9690
fn custom_csp_with_nonce() {
97-
let mut csp: ContentSecurityPolicy =
98-
String::from("script-src 'self' 'nonce-{NONCE}'; object-src 'none';").into();
91+
let mut csp =
92+
ContentSecurityPolicy::new("script-src 'self' 'nonce-{NONCE}'; object-src 'none';");
9993
csp.set_nonce(0);
10094

95+
assert!(csp.is_enabled());
10196
assert_eq!(
10297
"script-src 'self' 'nonce-0'; object-src 'none';",
10398
csp.to_string().as_str()
10499
);
105-
assert!(csp.is_enabled());
106100
}
107101
}

src/webserver/http.rs

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,9 @@ async fn render_sql(
175175
actix_web::rt::spawn(async move {
176176
let request_context = RequestContext {
177177
is_embedded: req_param.get_variables.contains_key("_sqlpage_embed"),
178-
content_security_policy: app_state.config.content_security_policy.clone(),
178+
content_security_policy: ContentSecurityPolicy::new(
179+
&app_state.config.content_security_policy,
180+
),
179181
};
180182
let mut conn = None;
181183
let database_entries_stream =
@@ -467,7 +469,7 @@ pub fn create_app(
467469
// when receiving a request outside of the prefix, redirect to the prefix
468470
.default_service(fn_service(default_prefix_redirect))
469471
.wrap(Logger::default())
470-
.wrap(default_headers(&app_state))
472+
.wrap(default_headers())
471473
.wrap(middleware::Condition::new(
472474
app_state.config.compress_responses,
473475
middleware::Compress::default(),
@@ -504,14 +506,9 @@ pub fn payload_config(app_state: &web::Data<AppState>) -> PayloadConfig {
504506
PayloadConfig::default().limit(app_state.config.max_uploaded_file_size * 2)
505507
}
506508

507-
fn default_headers(app_state: &web::Data<AppState>) -> middleware::DefaultHeaders {
509+
fn default_headers() -> middleware::DefaultHeaders {
508510
let server_header = format!("{} v{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"));
509-
let mut headers = middleware::DefaultHeaders::new().add(("Server", server_header));
510-
let csp = &app_state.config.content_security_policy;
511-
if csp.is_enabled() {
512-
headers = headers.add(csp);
513-
}
514-
headers
511+
middleware::DefaultHeaders::new().add(("Server", server_header))
515512
}
516513

517514
pub async fn run_server(config: &AppConfig, state: AppState) -> anyhow::Result<()> {

tests/end-to-end/official-site.spec.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,13 @@ test("no console errors on card page", async ({ page }) => {
158158
await checkNoConsoleErrors(page, "card");
159159
});
160160

161+
test("CSP issues unique nonces per request", async ({page}) => {
162+
let csp1 = await (await page.goto(BASE)).headerValue("content-security-policy");
163+
let csp2 = await (await page.reload()).headerValue("content-security-policy");
164+
165+
expect(csp1, `check if ${csp1} != ${csp2}`).not.toEqual(csp2);
166+
});
167+
161168
test("form component documentation", async ({ page }) => {
162169
await page.goto(`${BASE}/component.sql?component=form`);
163170

0 commit comments

Comments
 (0)