Skip to content
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

feat(runtime): support for CSP nonces #3823

Merged
merged 16 commits into from
Jan 10, 2023
Merged
Show file tree
Hide file tree
Changes from 10 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
7 changes: 7 additions & 0 deletions src/client/client-patch-browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,13 @@ const patchDynamicImport = (base: string, orgScriptElm: HTMLScriptElement) => {
type: 'application/javascript',
})
);

// Apply CSP nonce to the script tag if it exists
const nonce = plt.$nonce$ ?? (window as any).nonce;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if grabbing a field called nonce on window is the right thing to do... 🤔

On one hand, the chances of it getting overwritten by other scripts is probably higher with a name of just 'nonce'. On the other, it would probably seem weird to see a server configure a stencil_nonce field.

I suppose this is probably fine. Leaving it here in case anyone else wants to comment on

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Passing thought - instead of pulling this value off window, could we instead pull it as an attribute off the html root element?

To me, it seems the usage pattern here is currently:

  • <script nonce="value"> - for a single script
  • window.nonce - for everything in the DOM

Whereas it could be:

  • <html nonce="value"> - for everything in the DOM
  • <script nonce="value"> - for a single script

Consistent DX with less brittleness tied to the window object.

Do we have access to the root element in the execution context here?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The way that Rails handles this is by adding a meta tag in the header which subsequent scripts can read from before injecting their content:

<meta name="csp-nonce" content="nonce_inserted_here">

I think this is preferable to setting on the main html tag (though I agree the DX is better to do that than window).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sean-perkins @cscorley Thanks for the feedback and suggestions! I like the idea of using the meta tag. I'll check it out and see the feasibility to replace the window approach!

if (nonce != null) {
script.setAttribute('nonce', nonce);
}

mod = new Promise((resolve) => {
script.onload = () => {
resolve((win as any)[importFunctionName].m);
Expand Down
8 changes: 8 additions & 0 deletions src/client/polyfills/css-shim/custom-style.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,14 @@ export class CustomStyle implements CssVarShim {
const styleEl = this.doc.createElement('style');
styleEl.setAttribute('data-no-shim', '');

// Apply CSP nonce to the style tag if it exists
tanner-reits marked this conversation as resolved.
Show resolved Hide resolved
// NOTE: we cannot use the "platform" object here because these files
// cannot resolve a reference to `@app-data` when running our e2e tests
const nonce = (window as any).nonce;
if (nonce != null) {
styleEl.setAttribute('nonce', nonce);
}

if (!baseScope.usesCssVars) {
// This component does not use (read) css variables
styleEl.textContent = cssText;
Expand Down
8 changes: 8 additions & 0 deletions src/client/polyfills/css-shim/load-link-styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,14 @@ export function addGlobalLink(doc: Document, globalScopes: CSSScope[], linkElm:
styleEl.setAttribute('data-styles', '');
styleEl.textContent = text;

// Apply CSP nonce to the style tag if it exists
// NOTE: we cannot use the "platform" object here because these files
// cannot resolve a reference to `@app-data` when running our e2e tests
const nonce = (window as any).nonce;
if (nonce != null) {
styleEl.setAttribute('nonce', nonce);
}

addGlobalStyle(globalScopes, styleEl);
linkElm.parentNode.insertBefore(styleEl, linkElm);
linkElm.remove();
Expand Down
20 changes: 20 additions & 0 deletions src/client/polyfills/css-shim/test/css-shim.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { mockWindow } from '@stencil/core/testing';

import { win } from '../../../client-window';
import { CustomStyle } from '../custom-style';

describe('css-shim', () => {
Expand Down Expand Up @@ -372,6 +373,25 @@ describe('css-shim', () => {
);
});

it('should create a style element without a nonce attribute', () => {
const customStyle = new CustomStyle(window, document);
const hostEl = document.createElement('div');

const styleEl = customStyle.createHostStyle(hostEl, 'sc-div', 'color: red;', false);

expect(styleEl.getAttribute('nonce')).toBe(null);
});

it('should create a style element with a nonce attribute', () => {
const customStyle = new CustomStyle(window, document);
const hostEl = document.createElement('div');
(win as any).nonce = 'abc123';

const styleEl = customStyle.createHostStyle(hostEl, 'sc-div', 'color: red;', false);

expect(styleEl.getAttribute('nonce')).toBe('abc123');
});

var window: Window; // eslint-disable-line no-var -- shims will continue to use var while we support older browsers
var document: Document; // eslint-disable-line no-var -- shims will continue to use var while we support older browsers

Expand Down
39 changes: 39 additions & 0 deletions src/client/polyfills/css-shim/test/load-link-styles.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { win } from '../../../client-window';
import { addGlobalLink } from '../load-link-styles';

describe('loadLinkStyles', () => {
describe('addGlobalLink', () => {
global.fetch = jest.fn().mockResolvedValue({ text: () => '--color: var(--app-color);' });

afterEach(() => {
jest.clearAllMocks();
});

it('should create a style tag within the link element parent node', async () => {
const linkElm = document.createElement('link');
linkElm.setAttribute('rel', 'stylesheet');
linkElm.setAttribute('href', '');

const parentElm = document.createElement('head');
parentElm.appendChild(linkElm);

await addGlobalLink(document, [], linkElm);

expect(parentElm.innerHTML).toEqual('<style data-styles>--color: var(--app-color);</style>');
});

it('should create a style tag with a nonce attribute within the link element parent node', async () => {
(win as any).nonce = 'abc123';
const linkElm = document.createElement('link');
linkElm.setAttribute('rel', 'stylesheet');
linkElm.setAttribute('href', '');

const parentElm = document.createElement('head');
parentElm.appendChild(linkElm);

await addGlobalLink(document, [], linkElm);

expect(parentElm.innerHTML).toEqual('<style data-styles nonce="abc123">--color: var(--app-color);</style>');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,14 @@ const generateCustomElementsTypesOutput = async (
` */`,
`export declare const setAssetPath: (path: string) => void;`,
``,
`/**`,
` * Used to specify a nonce value that corresponds with an application's CSP.`,
` * When set, the nonce will be added to all dynamically created script and style tags at runtime.`,
` * Alternatively, the nonce value can be set on the window object (window.nonce) and`,
` * will result in the same behavior.`,
tanner-reits marked this conversation as resolved.
Show resolved Hide resolved
` */`,
`export declare const setNonce: (nonce: string) => void`,
``,
`export interface SetPlatformOptions {`,
` raf?: (c: FrameRequestCallback) => number;`,
` ael?: (el: EventTarget, eventName: string, listener: EventListenerOrEventListenerObject, options: boolean | AddEventListenerOptions) => void;`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ export const generateEntryPoint = (outputTarget: d.OutputTargetDistCustomElement
const imp: string[] = [];

imp.push(
`export { setAssetPath, setPlatformOptions } from '${STENCIL_INTERNAL_CLIENT_ID}';`,
`export { setAssetPath, setNonce, setPlatformOptions } from '${STENCIL_INTERNAL_CLIENT_ID}';`,
`export * from '${USER_INDEX_ENTRY_ID}';`
);

Expand Down
1 change: 1 addition & 0 deletions src/compiler/output-targets/dist-lazy/lazy-output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ function createEntryModule(cmps: d.ComponentCompilerMeta[]): d.EntryModule {

const getLazyEntry = (isBrowser: boolean): string => {
const s = new MagicString(``);
s.append(`export { setNonce } from '${STENCIL_CORE_ID}';\n`);
s.append(`import { bootstrapLazy } from '${STENCIL_CORE_ID}';\n`);

if (isBrowser) {
Expand Down
8 changes: 8 additions & 0 deletions src/compiler/output-targets/output-lazy-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,5 +89,13 @@ export interface CustomElementsDefineOptions {
}
export declare function defineCustomElements(win?: Window, opts?: CustomElementsDefineOptions): Promise<void>;
export declare function applyPolyfills(): Promise<void>;

/**
* Used to specify a nonce value that corresponds with an application's CSP.
* When set, the nonce will be added to all dynamically created script and style tags at runtime.
* Alternatively, the nonce value can be set on the window object (window.nonce) and
* will result in the same behavior.
tanner-reits marked this conversation as resolved.
Show resolved Hide resolved
*/
export declare function setNonce(nonce: string): void;
`;
};
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,14 @@ describe('Custom Elements Typedef generation', () => {
' */',
'export declare const setAssetPath: (path: string) => void;',
'',
'/**',
` * Used to specify a nonce value that corresponds with an application's CSP.`,
' * When set, the nonce will be added to all dynamically created script and style tags at runtime.',
' * Alternatively, the nonce value can be set on the window object (window.nonce) and',
' * will result in the same behavior.',
tanner-reits marked this conversation as resolved.
Show resolved Hide resolved
' */',
'export declare const setNonce: (nonce: string) => void',
'',
'export interface SetPlatformOptions {',
' raf?: (c: FrameRequestCallback) => number;',
' ael?: (el: EventTarget, eventName: string, listener: EventListenerOrEventListenerObject, options: boolean | AddEventListenerOptions) => void;',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ describe('Custom Elements output target', () => {
);
addCustomElementInputs(buildCtx, bundleOptions);
expect(bundleOptions.loader['\0core']).toEqual(
`export { setAssetPath, setPlatformOptions } from '${STENCIL_INTERNAL_CLIENT_ID}';
`export { setAssetPath, setNonce, setPlatformOptions } from '${STENCIL_INTERNAL_CLIENT_ID}';
export * from '${USER_INDEX_ENTRY_ID}';
import { globalScripts } from '${STENCIL_APP_GLOBALS_ID}';
globalScripts();
Expand All @@ -174,7 +174,7 @@ export { MyBestComponent, defineCustomElement as defineCustomElementMyBestCompon
);
addCustomElementInputs(buildCtx, bundleOptions);
expect(bundleOptions.loader['\0core']).toEqual(
`export { setAssetPath, setPlatformOptions } from '${STENCIL_INTERNAL_CLIENT_ID}';
`export { setAssetPath, setNonce, setPlatformOptions } from '${STENCIL_INTERNAL_CLIENT_ID}';
export * from '${USER_INDEX_ENTRY_ID}';
import { globalScripts } from '${STENCIL_APP_GLOBALS_ID}';
globalScripts();
Expand Down
7 changes: 7 additions & 0 deletions src/declarations/stencil-private.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1697,6 +1697,11 @@ export interface PlatformRuntime {
$flags$: number;
$orgLocNodes$?: Map<string, RenderNode>;
$resourcesUrl$: string;
/**
* The nonce value to be applied to all script/style tags at runtime.
* If `null`, the nonce attribute will not be applied.
*/
$nonce$?: string | null;
jmp: (c: Function) => any;
raf: (c: FrameRequestCallback) => number;
ael: (
Expand Down Expand Up @@ -2399,6 +2404,8 @@ export interface NewSpecPageOptions {
attachStyles?: boolean;

strictBuild?: boolean;

platform?: Partial<PlatformRuntime>;
tanner-reits marked this conversation as resolved.
Show resolved Hide resolved
}

/**
Expand Down
9 changes: 9 additions & 0 deletions src/declarations/stencil-public-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,15 @@ export declare function getAssetPath(path: string): string;
*/
export declare function setAssetPath(path: string): string;

/**
* Used to specify a nonce value that corresponds with an application's CSP.
tanner-reits marked this conversation as resolved.
Show resolved Hide resolved
* When set, the nonce will be added to all dynamically created script and style tags at runtime.
* Alternatively, the nonce value can be set on the window object (window.nonce) and
* will result in the same behavior.
* @param nonce The value to be used for the nonce attribute.
*/
export declare function setNonce(nonce: string): void;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a format that a nonce should be in? I don't see any guidance in the JSDoc as to what I should pass in if I'm a user and looking at the documentation provided by my IDE 🤔

I wonder if there's anything we should do from a type perspective to enforce a certain format 🤔

Copy link
Member Author

@tanner-reits tanner-reits Dec 6, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can definitely link out to guidelines around nonce generation, but it didn't seem like Stencil's responsibility to enforce a "correct" nonce. I found these guidelines on one of the pages I was using for reference during development/testing:

  • The nonce should be generated using a cryptographically secure random generator
  • The nonce should have sufficient length, aim for at least 128 bits of entropy (32 hex characters, or about 24 base64 characters).
  • The characters that can be used in the nonce string are limited to the characters found in base64 encoding.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, perhaps this lives in the documentation to point to best practices around using a nonce

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have a guide drafted up that will go on our docs. I'll add a section for this topic and link out to that resource I was using. I'll get a PR up for that tomorrow.


/**
* Retrieve a Stencil element for a given reference
* @param ref the ref to get the Stencil element for
Expand Down
1 change: 1 addition & 0 deletions src/hydrate/platform/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,5 +186,6 @@ export {
renderVdom,
setAssetPath,
setMode,
setNonce,
setValue,
} from '@runtime';
1 change: 1 addition & 0 deletions src/internal/stencil-core/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export {
setAssetPath,
setErrorHandler,
setMode,
setNonce,
setPlatformHelpers,
State,
Watch,
Expand Down
7 changes: 7 additions & 0 deletions src/runtime/bootstrap-lazy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { proxyComponent } from './proxy-component';
import { HYDRATED_CSS, HYDRATED_STYLE_ID, PLATFORM_FLAGS, PROXY_FLAGS } from './runtime-constants';
import { convertScopedToShadow, registerStyle } from './styles';
import { appDidLoad } from './update-component';
export { setNonce } from '@platform';

export const bootstrapLazy = (lazyBundles: d.LazyBundlesRuntimeData, options: d.CustomElementsDefineOptions = {}) => {
if (BUILD.profile && performance.mark) {
Expand Down Expand Up @@ -166,6 +167,12 @@ export const bootstrapLazy = (lazyBundles: d.LazyBundlesRuntimeData, options: d.
if (BUILD.invisiblePrehydration && (BUILD.hydratedClass || BUILD.hydratedAttribute)) {
visibilityStyle.innerHTML = cmpTags + HYDRATED_CSS;
visibilityStyle.setAttribute('data-styles', '');

// Apply CSP nonce to the style tag if it exists
const nonce = plt.$nonce$ ?? (window as any).nonce;
if (nonce != null) {
visibilityStyle.setAttribute('nonce', nonce);
}
head.insertBefore(visibilityStyle, metaCharset ? metaCharset.nextSibling : head.firstChild);
}

Expand Down
1 change: 1 addition & 0 deletions src/runtime/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export { createEvent } from './event-emitter';
export { Fragment } from './fragment';
export { addHostEventListeners } from './host-listener';
export { getMode, setMode } from './mode';
export { setNonce } from './nonce';
export { parsePropertyValue } from './parse-property-value';
export { setPlatformOptions } from './platform-options';
export { proxyComponent } from './proxy-component';
Expand Down
9 changes: 9 additions & 0 deletions src/runtime/nonce.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { plt } from '@platform';

/**
* Assigns the given value to the nonce property on the runtime platform object.
* During runtime, this value is used to set the nonce attribute on all dynamically created script and style tags.
* @param nonce The value to be assigned to the platform nonce property.
* @returns void
*/
export const setNonce = (nonce: string) => (plt.$nonce$ = nonce);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is probably going to be covered by the docs, but is the assumption that users will have to change the nonce server-side for every request that's served?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorta. I'm not sure the best way to phrase it, but the idea is that a nonce should be unique "per page view". So, for SPA like an Angular app, you can just generate a nonce at initial bootstrap and use that for the page's lifetime

6 changes: 6 additions & 0 deletions src/runtime/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,12 @@ export const addStyle = (
styleElm.innerHTML = style;
}

// Apply CSP nonce to the style tag if it exists
const nonce = plt.$nonce$ ?? (window as any).nonce;
if (nonce != null) {
styleElm.setAttribute('nonce', nonce);
}

if (BUILD.hydrateServerSide || BUILD.hotModuleReplacement) {
styleElm.setAttribute(HYDRATED_STYLE_ID, scopeId);
}
Expand Down
25 changes: 25 additions & 0 deletions src/runtime/test/style.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,31 @@ describe('style', () => {
expect(styles.get('sc-cmp-a')).toBe(`div { color: red; }`);
});

it('applies the nonce value to the head style tags', async () => {
@Component({
tag: 'cmp-a',
styles: `div { color: red; }`,
})
class CmpA {
render() {
return `innertext`;
}
}

const { doc } = await newSpecPage({
components: [CmpA],
includeAnnotations: true,
html: `<cmp-a></cmp-a>`,
platform: {
$nonce$: '1234',
},
});

expect(doc.head.innerHTML).toEqual(
'<style data-styles nonce="1234">cmp-a{visibility:hidden}.hydrated{visibility:inherit}</style>'
);
});

describe('mode', () => {
it('md mode', async () => {
setMode(() => 'md');
Expand Down
3 changes: 2 additions & 1 deletion src/testing/platform/testing-platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export const setSupportsShadowDom = (supports: boolean) => {
supportsShadow = supports;
};

export function resetPlatform() {
export function resetPlatform(defaults: Partial<d.PlatformRuntime> = {}) {
if (win && typeof win.close === 'function') {
win.close();
}
Expand All @@ -44,6 +44,7 @@ export function resetPlatform() {
styles.clear();
plt.$flags$ = 0;
Object.keys(Context).forEach((key) => delete Context[key]);
Object.assign(plt, defaults);

if (plt.$orgLocNodes$ != null) {
plt.$orgLocNodes$.clear();
Expand Down
2 changes: 1 addition & 1 deletion src/testing/spec-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export async function newSpecPage(opts: NewSpecPageOptions): Promise<SpecPage> {
}

// reset the platform for this new test
resetPlatform();
resetPlatform(opts.platform ?? {});
resetBuildConditionals(BUILD);

if (Array.isArray(opts.components)) {
Expand Down