Skip to content

Commit 6669b9a

Browse files
tests
1 parent 46603d9 commit 6669b9a

File tree

20 files changed

+212
-33
lines changed

20 files changed

+212
-33
lines changed

documentation/docs/98-reference/.generated/server-warnings.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
<!-- This file is generated by scripts/process-messages/index.js. Do not edit! -->
22

3+
### invalid_csp
4+
5+
```
6+
`csp.nonce` was set while `csp.hash` was `true`. These options cannot be used simultaneously.
7+
`nonce` will be used.
8+
```
9+
310
### unresolved_hydratable
411

512
```

packages/svelte/messages/server-warnings/warnings.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
## invalid_csp
2+
3+
> `csp.nonce` was set while `csp.hash` was `true`. These options cannot be used simultaneously.
4+
> `nonce` will be used.
5+
16
## unresolved_hydratable
27

38
> A `hydratable` value with key `%key%` was created, but at least part of it was not used during the render.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
let text_encoder;
2+
3+
/** @param {string} data */
4+
export async function sha256(data) {
5+
text_encoder ??= new TextEncoder();
6+
const hash_buffer = await crypto.subtle.digest('SHA-256', text_encoder.encode(data));
7+
return Buffer.from(hash_buffer).toString('base64');
8+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { assert, test } from 'vitest';
2+
import { sha256 } from './crypto.js';
3+
4+
const inputs = [
5+
['hello world', 'uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek='],
6+
['', '47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU='],
7+
['abcd', 'iNQmb9TmM40TuEX88olXnSCciXgjuSF9o+Fhk28DFYk='],
8+
['the quick brown fox jumps over the lazy dog', 'Bcbgjx2f2voDFH/Lj4LxJMdtL3Dj2Ynciq2159dFC+w='],
9+
['工欲善其事,必先利其器', 'oPOthkQ1c5BbPpvrr5WlUBJPyD5e6JeVdWcqBs9zvjA=']
10+
];
11+
12+
test.each(inputs)('sha256("%s")', async (input, expected) => {
13+
const actual = await sha256(input);
14+
assert.equal(actual, expected);
15+
});

packages/svelte/src/internal/server/index.js

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
/** @import { ComponentType, SvelteComponent, Component } from 'svelte' */
2-
/** @import { RenderOutput } from '#server' */
2+
/** @import { Csp, RenderOutput } from '#server' */
33
/** @import { Store } from '#shared' */
4-
/** @import { AccumulatedContent } from './renderer.js' */
54
export { FILENAME, HMR } from '../../constants.js';
65
import { attr, clsx, to_class, to_style } from '../shared/attributes.js';
76
import { is_promise, noop } from '../shared/utils.js';
@@ -18,6 +17,7 @@ import { EMPTY_COMMENT, BLOCK_CLOSE, BLOCK_OPEN, BLOCK_OPEN_ELSE } from './hydra
1817
import { validate_store } from '../shared/validate.js';
1918
import { is_boolean_attribute, is_raw_text_element, is_void } from '../../utils.js';
2019
import { Renderer } from './renderer.js';
20+
import * as w from './warnings.js';
2121

2222
// https://html.spec.whatwg.org/multipage/syntax.html#attributes-2
2323
// https://infra.spec.whatwg.org/#noncharacter
@@ -56,11 +56,25 @@ export function element(renderer, tag, attributes_fn = noop, children_fn = noop)
5656
* Takes a component and returns an object with `body` and `head` properties on it, which you can use to populate the HTML when server-rendering your app.
5757
* @template {Record<string, any>} Props
5858
* @param {Component<Props> | ComponentType<SvelteComponent<Props>>} component
59-
* @param {{ props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any>; idPrefix?: string }} [options]
59+
* @param {{ props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any>; idPrefix?: string; csp?: Csp }} [options]
6060
* @returns {RenderOutput}
6161
*/
6262
export function render(component, options = {}) {
63-
return Renderer.render(/** @type {Component<Props>} */ (component), options);
63+
let csp;
64+
if (options.csp) {
65+
csp =
66+
'nonce' in options.csp
67+
? { nonce: options.csp.nonce, hash: false }
68+
: { hash: options.csp.hash, nonce: undefined };
69+
70+
if (csp.hash && csp.nonce) {
71+
w.invalid_csp();
72+
}
73+
}
74+
return Renderer.render(/** @type {Component<Props>} */ (component), {
75+
...options,
76+
csp
77+
});
6478
}
6579

6680
/**

packages/svelte/src/internal/server/renderer.js

Lines changed: 48 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/** @import { Component } from 'svelte' */
2-
/** @import { HydratableContext, RenderOutput, SSRContext, SyncRenderOutput } from './types.js' */
2+
/** @import { CspInternal, HydratableContext, RenderOutput, SSRContext, SyncRenderOutput } from './types.js' */
33
/** @import { MaybePromise } from '#shared' */
44
import { async_mode_flag } from '../flags/index.js';
55
import { abort } from './abort-signal.js';
@@ -9,7 +9,7 @@ import * as w from './warnings.js';
99
import { BLOCK_CLOSE, BLOCK_OPEN } from './hydration.js';
1010
import { attributes } from './index.js';
1111
import { get_render_context, with_render_context, init_render_context } from './render-context.js';
12-
import { DEV } from 'esm-env';
12+
import { sha256 } from './crypto.js';
1313

1414
/** @typedef {'head' | 'body'} RendererType */
1515
/** @typedef {{ [key in RendererType]: string }} AccumulatedContent */
@@ -376,7 +376,7 @@ export class Renderer {
376376
* Takes a component and returns an object with `body` and `head` properties on it, which you can use to populate the HTML when server-rendering your app.
377377
* @template {Record<string, any>} Props
378378
* @param {Component<Props>} component
379-
* @param {{ props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any>; idPrefix?: string }} [options]
379+
* @param {{ props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any>; idPrefix?: string; csp?: CspInternal }} [options]
380380
* @returns {RenderOutput}
381381
*/
382382
static render(component, options = {}) {
@@ -404,6 +404,11 @@ export class Renderer {
404404
return (sync ??= Renderer.#render(component, options)).body;
405405
}
406406
},
407+
hashes: {
408+
value: {
409+
script: ''
410+
}
411+
},
407412
then: {
408413
value:
409414
/**
@@ -420,7 +425,8 @@ export class Renderer {
420425
const user_result = onfulfilled({
421426
head: result.head,
422427
body: result.body,
423-
html: result.body
428+
html: result.body,
429+
hashes: { script: '' }
424430
});
425431
return Promise.resolve(user_result);
426432
}
@@ -514,8 +520,8 @@ export class Renderer {
514520
*
515521
* @template {Record<string, any>} Props
516522
* @param {Component<Props>} component
517-
* @param {{ props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any>; idPrefix?: string }} options
518-
* @returns {Promise<AccumulatedContent>}
523+
* @param {{ props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any>; idPrefix?: string; csp?: CspInternal }} options
524+
* @returns {Promise<AccumulatedContent & { hashes: { script: string } }>}
519525
*/
520526
static async #render_async(component, options) {
521527
const previous_context = ssr_context;
@@ -585,19 +591,19 @@ export class Renderer {
585591
await comparison;
586592
}
587593

588-
return await Renderer.#hydratable_block(ctx);
594+
return await this.#hydratable_block(ctx);
589595
}
590596

591597
/**
592598
* @template {Record<string, any>} Props
593599
* @param {'sync' | 'async'} mode
594600
* @param {import('svelte').Component<Props>} component
595-
* @param {{ props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any>; idPrefix?: string }} options
601+
* @param {{ props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any>; idPrefix?: string; csp?: CspInternal }} options
596602
* @returns {Renderer}
597603
*/
598604
static #open_render(mode, component, options) {
599605
const renderer = new Renderer(
600-
new SSRState(mode, options.idPrefix ? options.idPrefix + '-' : '')
606+
new SSRState(mode, options.idPrefix ? options.idPrefix + '-' : '', options.csp)
601607
);
602608

603609
renderer.push(BLOCK_OPEN);
@@ -623,6 +629,7 @@ export class Renderer {
623629
/**
624630
* @param {AccumulatedContent} content
625631
* @param {Renderer} renderer
632+
* @returns {AccumulatedContent & { hashes: { script: string } }}
626633
*/
627634
static #close_render(content, renderer) {
628635
for (const cleanup of renderer.#collect_on_destroy()) {
@@ -638,14 +645,17 @@ export class Renderer {
638645

639646
return {
640647
head,
641-
body
648+
body,
649+
hashes: {
650+
script: renderer.global.csp.script_hashes.map((hash) => `'${hash}'`).join(' ')
651+
}
642652
};
643653
}
644654

645655
/**
646656
* @param {HydratableContext} ctx
647657
*/
648-
static async #hydratable_block(ctx) {
658+
async #hydratable_block(ctx) {
649659
if (ctx.lookup.size === 0) {
650660
return null;
651661
}
@@ -665,27 +675,37 @@ export class Renderer {
665675
let prelude = `const h = (window.__svelte ??= {}).h ??= new Map();`;
666676

667677
if (has_promises) {
668-
prelude = `const r = (v) => Promise.resolve(v);
669-
${prelude}`;
678+
prelude = `const r = (v) => Promise.resolve(v);\n\t${prelude}`;
670679
}
671680

672-
// TODO csp -- have discussed but not implemented
673-
return `
674-
<script>
675-
{
676-
${prelude}
681+
const body = `
682+
{
683+
${prelude}
677684
678-
for (const [k, v] of [
679-
${entries.join(',\n\t\t\t\t\t')}
680-
]) {
681-
h.set(k, v);
682-
}
683-
}
684-
</script>`;
685+
for (const [k, v] of [
686+
${entries.join(',\n')}
687+
]) {
688+
h.set(k, v);
689+
}
690+
}
691+
`;
692+
693+
let csp_attr = '';
694+
if (this.global.csp.nonce) {
695+
csp_attr = ` nonce="${this.global.csp.nonce}"`;
696+
} else if (this.global.csp.hash) {
697+
const hash = await sha256(body);
698+
this.global.csp.script_hashes.push(hash);
699+
}
700+
701+
return `<script${csp_attr}>${body}</script>`;
685702
}
686703
}
687704

688705
export class SSRState {
706+
/** @readonly @type {CspInternal & { script_hashes: string[] }} */
707+
csp;
708+
689709
/** @readonly @type {'sync' | 'async'} */
690710
mode;
691711

@@ -701,9 +721,11 @@ export class SSRState {
701721
/**
702722
* @param {'sync' | 'async'} mode
703723
* @param {string} [id_prefix]
724+
* @param {CspInternal} [csp]
704725
*/
705-
constructor(mode, id_prefix = '') {
726+
constructor(mode, id_prefix = '', csp = { hash: false }) {
706727
this.mode = mode;
728+
this.csp = { ...csp, script_hashes: [] };
707729

708730
let uid = 1;
709731
this.uid = () => `${id_prefix}s${uid++}`;

packages/svelte/src/internal/server/types.d.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ export interface SSRContext {
1515
element?: Element;
1616
}
1717

18+
export type Csp = { nonce: string } | { hash: true };
19+
20+
export type CspInternal = { nonce?: string; hash: boolean };
21+
1822
export interface HydratableLookupEntry {
1923
value: unknown;
2024
serialized: string;
@@ -40,6 +44,9 @@ export interface SyncRenderOutput {
4044
html: string;
4145
/** HTML that goes somewhere into the `<body>` */
4246
body: string;
47+
hashes: {
48+
script: string;
49+
};
4350
}
4451

4552
export type RenderOutput = SyncRenderOutput & PromiseLike<SyncRenderOutput>;

packages/svelte/src/internal/server/warnings.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,23 @@ import { DEV } from 'esm-env';
55
var bold = 'font-weight: bold';
66
var normal = 'font-weight: normal';
77

8+
/**
9+
* `csp.nonce` was set while `csp.hash` was `true`. These options cannot be used simultaneously.
10+
* `nonce` will be used.
11+
*/
12+
export function invalid_csp() {
13+
if (DEV) {
14+
console.warn(
15+
`%c[svelte] invalid_csp\n%c\`csp.nonce\` was set while \`csp.hash\` was \`true\`. These options cannot be used simultaneously.
16+
\`nonce\` will be used.\nhttps://svelte.dev/e/invalid_csp`,
17+
bold,
18+
normal
19+
);
20+
} else {
21+
console.warn(`https://svelte.dev/e/invalid_csp`);
22+
}
23+
}
24+
825
/**
926
* A `hydratable` value with key `%key%` was created, but at least part of it was not used during the render.
1027
*

packages/svelte/src/server/index.d.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { RenderOutput } from '#server';
1+
import type { Csp, RenderOutput } from '#server';
22
import type { ComponentProps, Component, SvelteComponent, ComponentType } from 'svelte';
33

44
/**
@@ -16,6 +16,7 @@ export function render<
1616
props?: Omit<Props, '$$slots' | '$$events'>;
1717
context?: Map<any, any>;
1818
idPrefix?: string;
19+
csp?: Csp;
1920
}
2021
]
2122
: [
@@ -24,6 +25,7 @@ export function render<
2425
props: Omit<Props, '$$slots' | '$$events'>;
2526
context?: Map<any, any>;
2627
idPrefix?: string;
28+
csp?: Csp;
2729
}
2830
]
2931
): RenderOutput;
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { test } from '../../test';
2+
3+
export default test({
4+
mode: ['async'],
5+
csp: { hash: true },
6+
script_hashes: "'TbJbBVc8UZ9yf9YRlCZCPBDitel+IaSg0BXUsnAx0cA='"
7+
});

0 commit comments

Comments
 (0)