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
5 changes: 5 additions & 0 deletions .changeset/yellow-lamps-act.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': minor
---

feat: `hydratable`'s injected script now works with CSP
76 changes: 54 additions & 22 deletions packages/kit/src/runtime/server/page/csp.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,21 +53,30 @@ class BaseProvider {
/** @type {import('types').CspDirectives} */
#directives;

/** @type {import('types').Csp.Source[]} */
/** @type {Set<import('types').Csp.Source>} */
#script_src;

/** @type {import('types').Csp.Source[]} */
/** @type {Set<import('types').Csp.Source>} */
#script_src_elem;

/** @type {import('types').Csp.Source[]} */
/** @type {Set<import('types').Csp.Source>} */
#style_src;

/** @type {import('types').Csp.Source[]} */
/** @type {Set<import('types').Csp.Source>} */
#style_src_attr;

/** @type {import('types').Csp.Source[]} */
/** @type {Set<import('types').Csp.Source>} */
#style_src_elem;

/** @type {boolean} */
script_needs_nonce;

/** @type {boolean} */
style_needs_nonce;

/** @type {boolean} */
script_needs_hash;

/** @type {string} */
#nonce;

Expand All @@ -82,11 +91,11 @@ class BaseProvider {

const d = this.#directives;

this.#script_src = [];
this.#script_src_elem = [];
this.#style_src = [];
this.#style_src_attr = [];
this.#style_src_elem = [];
this.#script_src = new Set();
this.#script_src_elem = new Set();
this.#style_src = new Set();
this.#style_src_attr = new Set();
this.#style_src_elem = new Set();

const effective_script_src = d['script-src'] || d['default-src'];
const script_src_elem = d['script-src-elem'];
Expand Down Expand Up @@ -162,6 +171,7 @@ class BaseProvider {

this.script_needs_nonce = this.#script_needs_csp && !this.#use_hashes;
this.style_needs_nonce = this.#style_needs_csp && !this.#use_hashes;
this.script_needs_hash = this.#script_needs_csp && this.#use_hashes;

this.#nonce = nonce;
}
Expand All @@ -174,11 +184,23 @@ class BaseProvider {
const source = this.#use_hashes ? `sha256-${sha256(content)}` : `nonce-${this.#nonce}`;

if (this.#script_src_needs_csp) {
this.#script_src.push(source);
this.#script_src.add(source);
}

if (this.#script_src_elem_needs_csp) {
this.#script_src_elem.push(source);
this.#script_src_elem.add(source);
}
}

/** @param {`sha256-${string}`[]} hashes */
add_script_hashes(hashes) {
for (const hash of hashes) {
if (this.#script_src_needs_csp) {
this.#script_src.add(hash);
}
if (this.#script_src_elem_needs_csp) {
this.#script_src_elem.add(hash);
}
}
}

Expand All @@ -190,11 +212,11 @@ class BaseProvider {
const source = this.#use_hashes ? `sha256-${sha256(content)}` : `nonce-${this.#nonce}`;

if (this.#style_src_needs_csp) {
this.#style_src.push(source);
this.#style_src.add(source);
}

if (this.#style_src_attr_needs_csp) {
this.#style_src_attr.push(source);
this.#style_src_attr.add(source);
}

if (this.#style_src_elem_needs_csp) {
Expand All @@ -207,13 +229,13 @@ class BaseProvider {
if (
d['style-src-elem'] &&
!d['style-src-elem'].includes(sha256_empty_comment_hash) &&
!this.#style_src_elem.includes(sha256_empty_comment_hash)
!this.#style_src_elem.has(sha256_empty_comment_hash)
) {
this.#style_src_elem.push(sha256_empty_comment_hash);
this.#style_src_elem.add(sha256_empty_comment_hash);
}

if (source !== sha256_empty_comment_hash) {
this.#style_src_elem.push(source);
this.#style_src_elem.add(source);
}
}
}
Expand All @@ -230,35 +252,35 @@ class BaseProvider {

const directives = { ...this.#directives };

if (this.#style_src.length > 0) {
if (this.#style_src.size > 0) {
directives['style-src'] = [
...(directives['style-src'] || directives['default-src'] || []),
...this.#style_src
];
}

if (this.#style_src_attr.length > 0) {
if (this.#style_src_attr.size > 0) {
directives['style-src-attr'] = [
...(directives['style-src-attr'] || []),
...this.#style_src_attr
];
}

if (this.#style_src_elem.length > 0) {
if (this.#style_src_elem.size > 0) {
directives['style-src-elem'] = [
...(directives['style-src-elem'] || []),
...this.#style_src_elem
];
}

if (this.#script_src.length > 0) {
if (this.#script_src.size > 0) {
directives['script-src'] = [
...(directives['script-src'] || directives['default-src'] || []),
...this.#script_src
];
}

if (this.#script_src_elem.length > 0) {
if (this.#script_src_elem.size > 0) {
directives['script-src-elem'] = [
...(directives['script-src-elem'] || []),
...this.#script_src_elem
Expand Down Expand Up @@ -351,6 +373,10 @@ export class Csp {
this.report_only_provider = new CspReportOnlyProvider(use_hashes, reportOnly, this.nonce);
}

get script_needs_hash() {
return this.csp_provider.script_needs_hash || this.report_only_provider.script_needs_hash;
}

get script_needs_nonce() {
return this.csp_provider.script_needs_nonce || this.report_only_provider.script_needs_nonce;
}
Expand All @@ -365,6 +391,12 @@ export class Csp {
this.report_only_provider.add_script(content);
}

/** @param {`sha256-${string}`[]} hashes */
add_script_hashes(hashes) {
this.csp_provider.add_script_hashes(hashes);
this.report_only_provider.add_script_hashes(hashes);
}

/** @param {string} content */
add_style(content) {
this.csp_provider.add_style(content);
Expand Down
106 changes: 106 additions & 0 deletions packages/kit/src/runtime/server/page/csp.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,112 @@ describe.skipIf(process.env.NODE_ENV !== 'production')('CSPs in prod', () => {
}, '`content-security-policy-report-only` must be specified with either the `report-to` or `report-uri` directives, or both');
});

test('add_script_hashes adds hashes to script-src', () => {
const csp = new Csp(
{
mode: 'hash',
directives: {
'script-src': ['self']
},
reportOnly: {
'script-src': ['self'],
'report-uri': ['/']
}
},
{
prerender: true
}
);

csp.add_script_hashes(['sha256-abc123', 'sha256-def456']);

const csp_header = csp.csp_provider.get_header();
assert.ok(csp_header.includes("'sha256-abc123'"));
assert.ok(csp_header.includes("'sha256-def456'"));

const report_only_header = csp.report_only_provider.get_header();
assert.ok(report_only_header.includes("'sha256-abc123'"));
assert.ok(report_only_header.includes("'sha256-def456'"));
});

test('add_script_hashes adds to script-src-elem when configured', () => {
const csp = new Csp(
{
mode: 'hash',
directives: {
'script-src-elem': ['self']
},
reportOnly: {}
},
{
prerender: true
}
);

csp.add_script_hashes(['sha256-test123']);

const csp_header = csp.csp_provider.get_header();
assert.ok(csp_header.includes("script-src-elem 'self' 'sha256-test123'"));
});

test('add_script_hashes deduplicates hashes', () => {
const csp = new Csp(
{
mode: 'hash',
directives: {
'script-src': ['self']
},
reportOnly: {}
},
{
prerender: true
}
);

csp.add_script_hashes(['sha256-abc123']);
csp.add_script_hashes(['sha256-abc123']);

const csp_header = csp.csp_provider.get_header();
const matches = csp_header.match(/'sha256-abc123'/g);
assert.equal(matches?.length, 1);
});

test('script_needs_hash returns true when using hashes', () => {
const csp = new Csp(
{
mode: 'hash',
directives: {
'script-src': ['self']
},
reportOnly: {}
},
{
prerender: true
}
);

assert.ok(csp.script_needs_hash);
assert.ok(!csp.script_needs_nonce);
});

test('script_needs_hash returns false when using nonces', () => {
const csp = new Csp(
{
mode: 'nonce',
directives: {
'script-src': ['self']
},
reportOnly: {}
},
{
prerender: false
}
);

assert.ok(!csp.script_needs_hash);
assert.ok(csp.script_needs_nonce);
});

test('adds nonce when both unsafe-inline and strict-dynamic are present', () => {
const csp = new Csp(
{
Expand Down
24 changes: 16 additions & 8 deletions packages/kit/src/runtime/server/page/render.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export async function render_response({
// TODO if we add a client entry point one day, we will need to include inline_styles with the entry, otherwise stylesheets will be linked even if they are below inlineStyleThreshold
const inline_styles = new Map();

/** @type {ReturnType<typeof options.root.render>} */
let rendered;

const form_value =
Expand All @@ -103,6 +104,10 @@ export async function render_response({
*/
let base_expression = s(paths.base);

const csp = new Csp(options.csp, {
prerender: !!state.prerendering
});

// if appropriate, use relative paths for greater portability
if (paths.relative) {
if (!state.prerendering?.fallback) {
Expand Down Expand Up @@ -170,7 +175,8 @@ export async function render_response({
page: props.page
}
]
])
]),
csp: csp.script_needs_nonce ? { nonce: csp.nonce } : { hash: csp.script_needs_hash }
};

const fetch = globalThis.fetch;
Expand Down Expand Up @@ -220,9 +226,15 @@ export async function render_response({
paths.reset();
}

const { head, html, css } = options.async ? await rendered : rendered;
const { head, html, css, hashes } = /** @type {ReturnType<typeof options.root.render>} */ (
options.async ? await rendered : rendered
);

return { head, html, css };
if (hashes) {
csp.add_script_hashes(hashes.script);
}

return { head, html, css, hashes };
});
} finally {
if (DEV) {
Expand All @@ -249,16 +261,12 @@ export async function render_response({
}
}
} else {
rendered = { head: '', html: '', css: { code: '', map: null } };
rendered = { head: '', html: '', css: { code: '', map: null }, hashes: { script: [] } };
}

const head = new Head(rendered.head, !!state.prerendering);
let body = rendered.html;

const csp = new Csp(options.csp, {
prerender: !!state.prerendering
});

/** @param {string} path */
const prefixed = (path) => {
if (path.startsWith('/')) {
Expand Down
6 changes: 5 additions & 1 deletion packages/kit/src/types/internal.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -381,14 +381,18 @@ export interface SSRComponent {
default: {
render(
props: Record<string, any>,
opts: { context: Map<any, any> }
opts: { context: Map<any, any>; csp?: { nonce?: string; hash?: boolean } }
): {
html: string;
head: string;
css: {
code: string;
map: any; // TODO
};
/** Until we require all Svelte versions that support hashes, this might not be defined */
hashes?: {
script: Array<`sha256-${string}`>;
};
};
};
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<script>
import { hydratable } from 'svelte';

const value = await hydratable('test-key', () => Promise.resolve('hydrated-value'));
</script>

<h1 id="hydratable-result">{value}</h1>
Loading
Loading