Skip to content

Commit c52b2ad

Browse files
committed
improve security and usability of custom js
The new default Content-Security-Policy contains a nonce that is made available to components during rendering. This allows including inline scripts as well as scripts from any source easily by specifying a nonce, and forbids arbitrary js from jsdelivr in case of xss
1 parent 6d9eb72 commit c52b2ad

File tree

6 files changed

+101
-30
lines changed

6 files changed

+101
-30
lines changed

examples/official-site/custom_components.sql

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,19 @@ SQLPage adds the following attributes to the context of your components:
144144
145145
- `@component_index` : the index of the current component in the page. Useful to generate unique ids or classes.
146146
- `@row_index` : the index of the current row in the current component. Useful to implement special behavior on the first row, for instance.
147+
- `@csp_nonce` : a random nonce that you must use as the `nonce` attribute of your `<script>` tags if you include external scripts.
148+
149+
## External javascript
150+
151+
For security, by default SQLPage ships with a [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) that prevents the execution of inline javascript
152+
and the loading of external scripts. However, you can include external scripts in your page by adding them to the `javascript` parameter of the default [`shell`](./documentation.sql?component=shell#component) component,
153+
or inside your own custom components using
154+
155+
```handlebars
156+
<script nonce="{{@csp_nonce}}">
157+
// your javascript code here
158+
</script>
159+
```
147160
148161
## Overwriting the default components
149162

sqlpage/templates/shell.handlebars

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,12 @@
3838
<script src="{{static_path 'sqlpage.js'}}" defer></script>
3939
{{#each (to_array javascript)}}
4040
{{#if this}}
41-
<script src="{{this}}" defer></script>
41+
<script src="{{this}}" defer nonce="{{@../csp_nonce}}"></script>
4242
{{/if}}
4343
{{/each}}
4444
{{#each (to_array javascript_module)}}
4545
{{#if this}}
46-
<script src="{{this}}" type="module" defer></script>
46+
<script src="{{this}}" type="module" defer nonce="{{@../csp_nonce}}"></script>
4747
{{/if}}
4848
{{/each}}
4949

src/render.rs

Lines changed: 37 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use crate::templates::SplitTemplate;
2-
use crate::webserver::http::LayoutContext;
2+
use crate::webserver::http::RequestContext;
33
use crate::webserver::ErrorWithStatus;
44
use crate::AppState;
55
use actix_web::cookie::time::format_description::well_known::Rfc3339;
@@ -15,9 +15,9 @@ use serde_json::{json, Value};
1515
use std::borrow::Cow;
1616
use std::sync::Arc;
1717

18-
pub enum PageContext<'a, W: std::io::Write> {
18+
pub enum PageContext<W: std::io::Write> {
1919
/// Indicates that we should stay in the header context
20-
Header(HeaderContext<'a, W>),
20+
Header(HeaderContext<W>),
2121

2222
/// Indicates that we should start rendering the body
2323
Body {
@@ -30,27 +30,28 @@ pub enum PageContext<'a, W: std::io::Write> {
3030
}
3131

3232
/// Handles the first SQL statements, before the headers have been sent to
33-
pub struct HeaderContext<'a, W: std::io::Write> {
33+
pub struct HeaderContext<W: std::io::Write> {
3434
app_state: Arc<AppState>,
35-
layout_context: &'a LayoutContext,
35+
request_context: RequestContext,
3636
pub writer: W,
3737
response: HttpResponseBuilder,
3838
has_status: bool,
3939
}
4040

41-
impl<'a, W: std::io::Write> HeaderContext<'a, W> {
42-
pub fn new(app_state: Arc<AppState>, layout_context: &'a LayoutContext, writer: W) -> Self {
41+
impl<'a, W: std::io::Write> HeaderContext<W> {
42+
pub fn new(app_state: Arc<AppState>, request_context: RequestContext, writer: W) -> Self {
4343
let mut response = HttpResponseBuilder::new(StatusCode::OK);
4444
response.content_type("text/html; charset=utf-8");
45+
response.insert_header(&request_context.content_security_policy);
4546
Self {
4647
app_state,
47-
layout_context,
48+
request_context,
4849
writer,
4950
response,
5051
has_status: false,
5152
}
5253
}
53-
pub async fn handle_row(self, data: JsonValue) -> anyhow::Result<PageContext<'a, W>> {
54+
pub async fn handle_row(self, data: JsonValue) -> anyhow::Result<PageContext<W>> {
5455
log::debug!("Handling header row: {data}");
5556
match get_object_str(&data, "component") {
5657
Some("status_code") => self.status_code(&data).map(PageContext::Header),
@@ -63,7 +64,7 @@ impl<'a, W: std::io::Write> HeaderContext<'a, W> {
6364
}
6465
}
6566

66-
pub async fn handle_error(self, err: anyhow::Error) -> anyhow::Result<PageContext<'a, W>> {
67+
pub async fn handle_error(self, err: anyhow::Error) -> anyhow::Result<PageContext<W>> {
6768
if self.app_state.config.environment.is_prod() {
6869
return Err(err);
6970
}
@@ -198,7 +199,7 @@ impl<'a, W: std::io::Write> HeaderContext<'a, W> {
198199
Ok(self.response.body(json_response))
199200
}
200201

201-
async fn authentication(mut self, mut data: JsonValue) -> anyhow::Result<PageContext<'a, W>> {
202+
async fn authentication(mut self, mut data: JsonValue) -> anyhow::Result<PageContext<W>> {
202203
let password_hash = take_object_str(&mut data, "password_hash");
203204
let password = take_object_str(&mut data, "password");
204205
if let (Some(password), Some(password_hash)) = (password, password_hash) {
@@ -228,8 +229,8 @@ impl<'a, W: std::io::Write> HeaderContext<'a, W> {
228229
Ok(PageContext::Close(http_response))
229230
}
230231

231-
async fn start_body(self, data: JsonValue) -> anyhow::Result<PageContext<'a, W>> {
232-
let renderer = RenderContext::new(self.app_state, self.layout_context, self.writer, data)
232+
async fn start_body(self, data: JsonValue) -> anyhow::Result<PageContext<W>> {
233+
let renderer = RenderContext::new(self.app_state, self.request_context, self.writer, data)
233234
.await
234235
.with_context(|| "Failed to create a render context from the header context.")?;
235236
let http_response = self.response;
@@ -287,6 +288,7 @@ pub struct RenderContext<W: std::io::Write> {
287288
current_component: Option<SplitTemplateRenderer>,
288289
shell_renderer: SplitTemplateRenderer,
289290
current_statement: usize,
291+
request_context: RequestContext,
290292
}
291293

292294
const DEFAULT_COMPONENT: &str = "table";
@@ -296,7 +298,7 @@ const FRAGMENT_SHELL_COMPONENT: &str = "shell-empty";
296298
impl<W: std::io::Write> RenderContext<W> {
297299
pub async fn new(
298300
app_state: Arc<AppState>,
299-
layout_context: &LayoutContext,
301+
request_context: RequestContext,
300302
mut writer: W,
301303
initial_row: JsonValue,
302304
) -> anyhow::Result<RenderContext<W>> {
@@ -309,7 +311,7 @@ impl<W: std::io::Write> RenderContext<W> {
309311
.and_then(|c| get_object_str(c, "component"))
310312
.is_some_and(Self::is_shell_component)
311313
{
312-
let default_shell = if layout_context.is_embedded {
314+
let default_shell = if request_context.is_embedded {
313315
FRAGMENT_SHELL_COMPONENT
314316
} else {
315317
PAGE_SHELL_COMPONENT
@@ -329,6 +331,7 @@ impl<W: std::io::Write> RenderContext<W> {
329331
get_object_str(&shell_row, "component").expect("shell should exist"),
330332
Arc::clone(&app_state),
331333
0,
334+
request_context.content_security_policy.nonce,
332335
)
333336
.await
334337
.with_context(|| "The shell component should always exist")?;
@@ -341,6 +344,7 @@ impl<W: std::io::Write> RenderContext<W> {
341344
current_component: None,
342345
shell_renderer,
343346
current_statement: 1,
347+
request_context,
344348
};
345349

346350
for row in rows_iter {
@@ -461,6 +465,7 @@ impl<W: std::io::Write> RenderContext<W> {
461465
component: &str,
462466
app_state: Arc<AppState>,
463467
component_index: usize,
468+
nonce: u64,
464469
) -> anyhow::Result<SplitTemplateRenderer> {
465470
let split_template = app_state
466471
.all_templates
@@ -470,6 +475,7 @@ impl<W: std::io::Write> RenderContext<W> {
470475
split_template,
471476
app_state,
472477
component_index,
478+
nonce,
473479
))
474480
}
475481

@@ -486,6 +492,7 @@ impl<W: std::io::Write> RenderContext<W> {
486492
component,
487493
Arc::clone(&self.app_state),
488494
current_component_index + 1,
495+
self.request_context.content_security_policy.nonce,
489496
)
490497
.await?;
491498
Ok(self.current_component.replace(new_component))
@@ -543,13 +550,15 @@ pub struct SplitTemplateRenderer {
543550
app_state: Arc<AppState>,
544551
row_index: usize,
545552
component_index: usize,
553+
nonce: JsonValue,
546554
}
547555

548556
impl SplitTemplateRenderer {
549557
fn new(
550558
split_template: Arc<SplitTemplate>,
551559
app_state: Arc<AppState>,
552560
component_index: usize,
561+
nonce: u64,
553562
) -> Self {
554563
Self {
555564
split_template,
@@ -558,6 +567,7 @@ impl SplitTemplateRenderer {
558567
row_index: 0,
559568
ctx: Context::null(),
560569
component_index,
570+
nonce: nonce.into(),
561571
}
562572
}
563573
fn name(&self) -> &str {
@@ -581,13 +591,15 @@ impl SplitTemplateRenderer {
581591
.unwrap_or_default(),
582592
);
583593
let mut render_context = handlebars::RenderContext::new(None);
584-
render_context
594+
let blk = render_context
585595
.block_mut()
586-
.expect("context created without block")
587-
.set_local_var(
588-
"component_index",
589-
JsonValue::Number(self.component_index.into()),
590-
);
596+
.expect("context created without block");
597+
blk.set_local_var(
598+
"component_index",
599+
JsonValue::Number(self.component_index.into()),
600+
);
601+
blk.set_local_var("csp_nonce", self.nonce.clone());
602+
591603
*self.ctx.data_mut() = data;
592604
let mut output = HandlebarWriterOutput(writer);
593605
self.split_template.before_list.render(
@@ -618,6 +630,7 @@ impl SplitTemplateRenderer {
618630
let mut blk = BlockContext::new();
619631
blk.set_base_value(data);
620632
blk.set_local_var("row_index", JsonValue::Number(self.row_index.into()));
633+
blk.set_local_var("csp_nonce", self.nonce.clone());
621634
render_context.push_block(blk);
622635
let mut output = HandlebarWriterOutput(writer);
623636
self.split_template.list_content.render(
@@ -646,6 +659,7 @@ impl SplitTemplateRenderer {
646659
if let Some(mut local_vars) = self.local_vars.take() {
647660
let mut render_context = handlebars::RenderContext::new(None);
648661
local_vars.put("row_index", self.row_index.into());
662+
local_vars.put("csp_nonce", self.nonce.clone());
649663
log::trace!("Rendering the after_list template with the following local variables: {local_vars:?}");
650664
*render_context
651665
.block_mut()
@@ -681,7 +695,7 @@ mod tests {
681695
let mut output = Vec::new();
682696
let config = app_config::tests::test_config();
683697
let app_state = Arc::new(AppState::init(&config).await.unwrap());
684-
let mut rdr = SplitTemplateRenderer::new(Arc::new(split), app_state, 0);
698+
let mut rdr = SplitTemplateRenderer::new(Arc::new(split), app_state, 0, 0);
685699
rdr.render_start(&mut output, json!({"name": "SQL"}))?;
686700
rdr.render_item(&mut output, json!({"x": 1}))?;
687701
rdr.render_item(&mut output, json!({"x": 2}))?;
@@ -702,7 +716,7 @@ mod tests {
702716
let mut output = Vec::new();
703717
let config = app_config::tests::test_config();
704718
let app_state = Arc::new(AppState::init(&config).await.unwrap());
705-
let mut rdr = SplitTemplateRenderer::new(Arc::new(split), app_state, 0);
719+
let mut rdr = SplitTemplateRenderer::new(Arc::new(split), app_state, 0, 0);
706720
rdr.render_start(&mut output, json!(null))?;
707721
rdr.render_item(&mut output, json!({"x": 1}))?;
708722
rdr.render_item(&mut output, json!({"x": 2}))?;
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
use std::fmt::Display;
2+
3+
use awc::http::header::InvalidHeaderValue;
4+
use rand::random;
5+
6+
#[derive(Debug, Clone, Copy)]
7+
pub struct ContentSecurityPolicy {
8+
pub nonce: u64,
9+
}
10+
11+
impl ContentSecurityPolicy {
12+
pub fn new() -> Self {
13+
Self { nonce: random() }
14+
}
15+
}
16+
17+
impl Display for ContentSecurityPolicy {
18+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
19+
write!(f, "script-src 'self' 'nonce-{}'", self.nonce)
20+
}
21+
}
22+
23+
impl actix_web::http::header::TryIntoHeaderPair for &ContentSecurityPolicy {
24+
type Error = InvalidHeaderValue;
25+
26+
fn try_into_pair(
27+
self,
28+
) -> Result<
29+
(
30+
actix_web::http::header::HeaderName,
31+
actix_web::http::header::HeaderValue,
32+
),
33+
Self::Error,
34+
> {
35+
Ok((
36+
actix_web::http::header::CONTENT_SECURITY_POLICY,
37+
actix_web::http::header::HeaderValue::from_str(&self.to_string())?,
38+
))
39+
}
40+
}

src/webserver/http.rs

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use crate::render::{HeaderContext, PageContext, RenderContext};
2+
use crate::webserver::content_security_policy::ContentSecurityPolicy;
23
use crate::webserver::database::{execute_queries::stream_query_results_with_conn, DbItem};
34
use crate::webserver::http_request_info::extract_request_info;
45
use crate::webserver::ErrorWithStatus;
@@ -40,8 +41,9 @@ pub struct ResponseWriter {
4041
}
4142

4243
#[derive(Clone)]
43-
pub struct LayoutContext {
44+
pub struct RequestContext {
4445
pub is_embedded: bool,
46+
pub content_security_policy: ContentSecurityPolicy,
4547
}
4648

4749
impl ResponseWriter {
@@ -172,12 +174,12 @@ async fn stream_response(
172174
async fn build_response_header_and_stream<S: Stream<Item = DbItem>>(
173175
app_state: Arc<AppState>,
174176
database_entries: S,
175-
layout_context: &LayoutContext,
177+
request_context: RequestContext,
176178
) -> anyhow::Result<ResponseWithWriter<S>> {
177179
let chan_size = app_state.config.max_pending_rows;
178180
let (sender, receiver) = mpsc::channel(chan_size);
179181
let writer = ResponseWriter::new(sender);
180-
let mut head_context = HeaderContext::new(app_state, layout_context, writer);
182+
let mut head_context = HeaderContext::new(app_state, request_context, writer);
181183
let mut stream = Box::pin(database_entries);
182184
while let Some(item) = stream.next().await {
183185
let page_context = match item {
@@ -250,16 +252,17 @@ async fn render_sql(
250252

251253
let (resp_send, resp_recv) = tokio::sync::oneshot::channel::<HttpResponse>();
252254
actix_web::rt::spawn(async move {
253-
let layout_context = &LayoutContext {
255+
let request_context = RequestContext {
254256
is_embedded: req_param.get_variables.contains_key("_sqlpage_embed"),
257+
content_security_policy: ContentSecurityPolicy::new(),
255258
};
256259
let mut conn = None;
257260
let database_entries_stream =
258261
stream_query_results_with_conn(&sql_file, &mut req_param, &mut conn);
259262
let response_with_writer = build_response_header_and_stream(
260263
Arc::clone(&app_state),
261264
database_entries_stream,
262-
layout_context,
265+
request_context,
263266
)
264267
.await;
265268
match response_with_writer {

src/webserver/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
mod content_security_policy;
12
pub mod database;
23
pub mod error_with_status;
34
pub mod http;

0 commit comments

Comments
 (0)