-
-
Notifications
You must be signed in to change notification settings - Fork 4.7k
feat: Add CSP support for hydratable
#17338
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
6669b9a
66f3255
2be2594
59301a3
3e04518
342ec99
7a5886d
479deb6
691cd47
25b9b93
4736669
f614efe
25e4050
6460284
ae7bbe0
87aebaf
2f394b8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| import { BROWSER } from 'esm-env'; | ||
|
|
||
| let text_encoder; | ||
| // TODO - remove this and use global `crypto` when we drop Node 18 | ||
| let crypto; | ||
|
|
||
| /** @param {string} data */ | ||
| export async function sha256(data) { | ||
| text_encoder ??= new TextEncoder(); | ||
| // @ts-ignore | ||
| crypto ??= globalThis.crypto?.subtle?.digest | ||
| ? globalThis.crypto | ||
| : // @ts-ignore - we don't install node types in the prod build | ||
| (await import('node:crypto')).webcrypto; | ||
| const hash_buffer = await crypto.subtle.digest('SHA-256', text_encoder.encode(data)); | ||
| // @ts-ignore - we don't install node types in the prod build | ||
| return base64_encode(hash_buffer); | ||
| } | ||
|
|
||
| /** | ||
| * @param {Uint8Array} bytes | ||
| * @returns {string} | ||
| */ | ||
| export function base64_encode(bytes) { | ||
| // Using `Buffer` is faster than iterating | ||
| if (!BROWSER && globalThis.Buffer) { | ||
| return globalThis.Buffer.from(bytes).toString('base64'); | ||
| } | ||
|
|
||
| let binary = ''; | ||
|
|
||
| for (let i = 0; i < bytes.length; i++) { | ||
| binary += String.fromCharCode(bytes[i]); | ||
| } | ||
|
|
||
| return btoa(binary); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| import { assert, test } from 'vitest'; | ||
| import { sha256 } from './crypto.js'; | ||
|
|
||
| const inputs = [ | ||
| ['hello world', 'uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek='], | ||
| ['', '47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU='], | ||
| ['abcd', 'iNQmb9TmM40TuEX88olXnSCciXgjuSF9o+Fhk28DFYk='], | ||
| ['the quick brown fox jumps over the lazy dog', 'Bcbgjx2f2voDFH/Lj4LxJMdtL3Dj2Ynciq2159dFC+w='], | ||
| ['工欲善其事,必先利其器', 'oPOthkQ1c5BbPpvrr5WlUBJPyD5e6JeVdWcqBs9zvjA='] | ||
| ]; | ||
|
|
||
| test.each(inputs)('sha256("%s")', async (input, expected) => { | ||
| const actual = await sha256(input); | ||
| assert.equal(actual, expected); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,7 +1,6 @@ | ||
| /** @import { ComponentType, SvelteComponent, Component } from 'svelte' */ | ||
| /** @import { RenderOutput } from '#server' */ | ||
| /** @import { Csp, RenderOutput } from '#server' */ | ||
| /** @import { Store } from '#shared' */ | ||
| /** @import { AccumulatedContent } from './renderer.js' */ | ||
| export { FILENAME, HMR } from '../../constants.js'; | ||
| import { attr, clsx, to_class, to_style } from '../shared/attributes.js'; | ||
| import { is_promise, noop } from '../shared/utils.js'; | ||
|
|
@@ -18,6 +17,7 @@ import { EMPTY_COMMENT, BLOCK_CLOSE, BLOCK_OPEN, BLOCK_OPEN_ELSE } from './hydra | |
| import { validate_store } from '../shared/validate.js'; | ||
| import { is_boolean_attribute, is_raw_text_element, is_void } from '../../utils.js'; | ||
| import { Renderer } from './renderer.js'; | ||
| import * as w from './warnings.js'; | ||
|
|
||
| // https://html.spec.whatwg.org/multipage/syntax.html#attributes-2 | ||
| // https://infra.spec.whatwg.org/#noncharacter | ||
|
|
@@ -56,11 +56,26 @@ export function element(renderer, tag, attributes_fn = noop, children_fn = noop) | |
| * 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. | ||
| * @template {Record<string, any>} Props | ||
| * @param {Component<Props> | ComponentType<SvelteComponent<Props>>} component | ||
| * @param {{ props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any>; idPrefix?: string }} [options] | ||
| * @param {{ props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any>; idPrefix?: string; csp?: Csp }} [options] | ||
| * @returns {RenderOutput} | ||
| */ | ||
| export function render(component, options = {}) { | ||
| return Renderer.render(/** @type {Component<Props>} */ (component), options); | ||
| let csp; | ||
| if (options.csp) { | ||
| csp = | ||
| 'nonce' in options.csp | ||
| ? { nonce: options.csp.nonce, hash: false } | ||
| : { hash: options.csp.hash, nonce: undefined }; | ||
|
Comment on lines
+64
to
+68
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. can't we just use
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not as currently typed -- the input value is a union. Maybe this is undesirable and we should just use an object with optional keys and JSDoc? |
||
|
|
||
| // @ts-expect-error | ||
| if (options.csp.hash && options.csp.nonce) { | ||
| w.invalid_csp(); | ||
| } | ||
| } | ||
| return Renderer.render(/** @type {Component<Props>} */ (component), { | ||
| ...options, | ||
| csp | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,5 @@ | ||
| /** @import { Component } from 'svelte' */ | ||
| /** @import { HydratableContext, RenderOutput, SSRContext, SyncRenderOutput } from './types.js' */ | ||
| /** @import { CspInternal, HydratableContext, RenderOutput, SSRContext, SyncRenderOutput, Sha256Source } from './types.js' */ | ||
| /** @import { MaybePromise } from '#shared' */ | ||
| import { async_mode_flag } from '../flags/index.js'; | ||
| import { abort } from './abort-signal.js'; | ||
|
|
@@ -9,7 +9,7 @@ import * as w from './warnings.js'; | |
| import { BLOCK_CLOSE, BLOCK_OPEN } from './hydration.js'; | ||
| import { attributes } from './index.js'; | ||
| import { get_render_context, with_render_context, init_render_context } from './render-context.js'; | ||
| import { DEV } from 'esm-env'; | ||
| import { sha256 } from './crypto.js'; | ||
|
|
||
| /** @typedef {'head' | 'body'} RendererType */ | ||
| /** @typedef {{ [key in RendererType]: string }} AccumulatedContent */ | ||
|
|
@@ -376,13 +376,13 @@ export class Renderer { | |
| * 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. | ||
| * @template {Record<string, any>} Props | ||
| * @param {Component<Props>} component | ||
| * @param {{ props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any>; idPrefix?: string }} [options] | ||
| * @param {{ props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any>; idPrefix?: string; csp?: CspInternal }} [options] | ||
| * @returns {RenderOutput} | ||
| */ | ||
| static render(component, options = {}) { | ||
| /** @type {AccumulatedContent | undefined} */ | ||
| let sync; | ||
| /** @type {Promise<AccumulatedContent> | undefined} */ | ||
| /** @type {Promise<AccumulatedContent & { hashes: { script: Sha256Source[] } }> | undefined} */ | ||
| let async; | ||
|
|
||
| const result = /** @type {RenderOutput} */ ({}); | ||
|
|
@@ -404,6 +404,11 @@ export class Renderer { | |
| return (sync ??= Renderer.#render(component, options)).body; | ||
| } | ||
| }, | ||
| hashes: { | ||
| value: { | ||
| script: '' | ||
| } | ||
| }, | ||
| then: { | ||
| value: | ||
| /** | ||
|
|
@@ -420,7 +425,8 @@ export class Renderer { | |
| const user_result = onfulfilled({ | ||
| head: result.head, | ||
| body: result.body, | ||
| html: result.body | ||
| html: result.body, | ||
| hashes: { script: [] } | ||
| }); | ||
| return Promise.resolve(user_result); | ||
| } | ||
|
|
@@ -514,8 +520,8 @@ export class Renderer { | |
| * | ||
| * @template {Record<string, any>} Props | ||
| * @param {Component<Props>} component | ||
| * @param {{ props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any>; idPrefix?: string }} options | ||
| * @returns {Promise<AccumulatedContent>} | ||
| * @param {{ props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any>; idPrefix?: string; csp?: CspInternal }} options | ||
| * @returns {Promise<AccumulatedContent & { hashes: { script: Sha256Source[] } }>} | ||
| */ | ||
| static async #render_async(component, options) { | ||
| const previous_context = ssr_context; | ||
|
|
@@ -585,19 +591,19 @@ export class Renderer { | |
| await comparison; | ||
| } | ||
|
|
||
| return await Renderer.#hydratable_block(ctx); | ||
| return await this.#hydratable_block(ctx); | ||
| } | ||
|
|
||
| /** | ||
| * @template {Record<string, any>} Props | ||
| * @param {'sync' | 'async'} mode | ||
| * @param {import('svelte').Component<Props>} component | ||
| * @param {{ props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any>; idPrefix?: string }} options | ||
| * @param {{ props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any>; idPrefix?: string; csp?: CspInternal }} options | ||
| * @returns {Renderer} | ||
| */ | ||
| static #open_render(mode, component, options) { | ||
| const renderer = new Renderer( | ||
| new SSRState(mode, options.idPrefix ? options.idPrefix + '-' : '') | ||
| new SSRState(mode, options.idPrefix ? options.idPrefix + '-' : '', options.csp) | ||
| ); | ||
|
|
||
| renderer.push(BLOCK_OPEN); | ||
|
|
@@ -623,6 +629,7 @@ export class Renderer { | |
| /** | ||
| * @param {AccumulatedContent} content | ||
| * @param {Renderer} renderer | ||
| * @returns {AccumulatedContent & { hashes: { script: Sha256Source[] } }} | ||
| */ | ||
| static #close_render(content, renderer) { | ||
| for (const cleanup of renderer.#collect_on_destroy()) { | ||
|
|
@@ -638,14 +645,17 @@ export class Renderer { | |
|
|
||
| return { | ||
| head, | ||
| body | ||
| body, | ||
| hashes: { | ||
| script: renderer.global.csp.script_hashes | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. as of now, this will only ever have one hash in it -- however, with streaming, it would be more... Thinking ahead, with streaming, this should probably error if you try to access it prior to fully-reading |
||
| } | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * @param {HydratableContext} ctx | ||
| */ | ||
| static async #hydratable_block(ctx) { | ||
| async #hydratable_block(ctx) { | ||
| if (ctx.lookup.size === 0) { | ||
| return null; | ||
| } | ||
|
|
@@ -665,27 +675,40 @@ export class Renderer { | |
| let prelude = `const h = (window.__svelte ??= {}).h ??= new Map();`; | ||
|
|
||
| if (has_promises) { | ||
| prelude = `const r = (v) => Promise.resolve(v); | ||
| ${prelude}`; | ||
| prelude = `const r = (v) => Promise.resolve(v);\n\t${prelude}`; | ||
| } | ||
|
|
||
| // TODO csp -- have discussed but not implemented | ||
| return ` | ||
| <script> | ||
| { | ||
| ${prelude} | ||
| const body = ` | ||
| { | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I dedented and changed the formatting of this stuff because it was confusing in the browser... I could be convinced to sacrifice browser readability for code readability but I don't think the readability here is that bad, and it also technically reduces the byte count
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do you think we should look into some sort of minification in production?
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah, it's about whether you're viewing source or inspecting
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Even when viewing source that seems wrong -- we don't send formatted HTML so it's like... indenting the tags but not the content? Weird |
||
| ${prelude} | ||
|
|
||
| for (const [k, v] of [ | ||
| ${entries.join(',\n\t\t\t\t\t')} | ||
| ]) { | ||
| h.set(k, v); | ||
| } | ||
| } | ||
| </script>`; | ||
| for (const [k, v] of [ | ||
| ${entries.join(',\n')} | ||
Ocean-OS marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| ]) { | ||
| h.set(k, v); | ||
| } | ||
| } | ||
| `; | ||
|
|
||
| let csp_attr = ''; | ||
| if (this.global.csp.nonce) { | ||
| csp_attr = ` nonce="${this.global.csp.nonce}"`; | ||
| } else if (this.global.csp.hash) { | ||
| // note to future selves: this doesn't need to be optimized with a Map<body, hash> | ||
| // because the it's impossible for identical data to occur multiple times in a single render | ||
| // (this would require the same hydratable key:value pair to be serialized multiple times) | ||
| const hash = await sha256(body); | ||
| this.global.csp.script_hashes.push(`sha256-${hash}`); | ||
| } | ||
|
|
||
| return `<script${csp_attr}>${body}</script>`; | ||
| } | ||
| } | ||
|
|
||
| export class SSRState { | ||
| /** @readonly @type {CspInternal & { script_hashes: Sha256Source[] }} */ | ||
| csp; | ||
|
|
||
| /** @readonly @type {'sync' | 'async'} */ | ||
| mode; | ||
|
|
||
|
|
@@ -700,10 +723,12 @@ export class SSRState { | |
|
|
||
| /** | ||
| * @param {'sync' | 'async'} mode | ||
| * @param {string} [id_prefix] | ||
| * @param {string} id_prefix | ||
| * @param {CspInternal} csp | ||
| */ | ||
| constructor(mode, id_prefix = '') { | ||
| constructor(mode, id_prefix = '', csp = { hash: false }) { | ||
| this.mode = mode; | ||
| this.csp = { ...csp, script_hashes: [] }; | ||
|
|
||
| let uid = 1; | ||
| this.uid = () => `${id_prefix}s${uid++}`; | ||
|
|
||



Uh oh!
There was an error while loading. Please reload this page.