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 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
9 changes: 8 additions & 1 deletion src/client/client-patch-browser.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { BUILD, NAMESPACE } from '@app-data';
import { consoleDevInfo, doc, H, plt, promiseResolve, win } from '@platform';
import { getDynamicImportFunction } from '@utils';
import { getDynamicImportFunction, queryNonceMetaTagContent } from '@utils';

import type * as d from '../declarations';

Expand Down 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$ ?? queryNonceMetaTagContent(doc);
if (nonce != null) {
script.setAttribute('nonce', nonce);
}

mod = new Promise((resolve) => {
script.onload = () => {
resolve((win as any)[importFunctionName].m);
Expand Down
24 changes: 24 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,24 @@
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>');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,15 @@ 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 a meta tag in the DOM head`,
` * (<meta name="csp-nonce" content="{ nonce value here }" />) which`,
` * will result in the same behavior.`,
` */`,
`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
9 changes: 9 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,14 @@ 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 a meta tag in the DOM head
* (<meta name="csp-nonce" content="{ nonce value here }" />) which
* 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,15 @@ 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 a meta tag in the DOM head',
' * (<meta name="csp-nonce" content="{ nonce value here }" />) which',
' * 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
10 changes: 10 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,11 @@ export interface NewSpecPageOptions {
attachStyles?: boolean;

strictBuild?: boolean;
/**
* Default values to be set on the platform runtime object (@see PlatformRuntime) when creating
* the spec page.
*/
platform?: Partial<PlatformRuntime>;
tanner-reits marked this conversation as resolved.
Show resolved Hide resolved
}

/**
Expand Down
10 changes: 10 additions & 0 deletions src/declarations/stencil-public-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,16 @@ 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
* [Content Security Policy (CSP)](https://developer.mozilla.org/en-US/docs/Web/HTTP/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 a `meta` tag in the DOM head
* (<meta name="csp-nonce" content="{ nonce value here }" />) 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
9 changes: 8 additions & 1 deletion src/runtime/bootstrap-lazy.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { BUILD } from '@app-data';
import { doc, getHostRef, plt, registerHost, supportsShadow, win } from '@platform';
import { CMP_FLAGS } from '@utils';
import { CMP_FLAGS, queryNonceMetaTagContent } from '@utils';

import type * as d from '../declarations';
import { connectedCallback } from './connected-callback';
Expand All @@ -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$ ?? queryNonceMetaTagContent(doc);
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

8 changes: 7 additions & 1 deletion src/runtime/styles.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { BUILD } from '@app-data';
import { doc, plt, styles, supportsConstructableStylesheets, supportsShadow } from '@platform';
import { CMP_FLAGS } from '@utils';
import { CMP_FLAGS, queryNonceMetaTagContent } from '@utils';

import type * as d from '../declarations';
import { createTime } from './profile';
Expand Down 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$ ?? queryNonceMetaTagContent(doc);
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
1 change: 1 addition & 0 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export * from './logger/logger-typescript';
export * from './logger/logger-utils';
export * from './message-utils';
export * from './normalize-path';
export * from './query-nonce-meta-tag-content';
export * from './sourcemaps';
export * from './url-paths';
export * from './util';
Expand Down
11 changes: 11 additions & 0 deletions src/utils/query-nonce-meta-tag-content.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* Helper method for querying a `meta` tag that contains a nonce value
* out of a DOM's head.
*
* @param doc The DOM containing the `head` to query against
* @returns The content of the meta tag representing the nonce value, or `undefined` if no tag
* exists or the tag has no content.
*/
export function queryNonceMetaTagContent(doc: Document): string | undefined {
return doc.head?.querySelector('meta[name="csp-nonce"]')?.getAttribute('content') ?? undefined;
}
39 changes: 39 additions & 0 deletions src/utils/test/query-nonce-meta-tag-content.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { queryNonceMetaTagContent } from '../query-nonce-meta-tag-content';

describe('queryNonceMetaTagContent', () => {
it('should return the nonce value if the tag exists', () => {
const meta = document.createElement('meta');
meta.setAttribute('name', 'csp-nonce');
meta.setAttribute('content', '1234');
document.head.appendChild(meta);

const nonce = queryNonceMetaTagContent(document);

expect(nonce).toEqual('1234');
});

it('should return `undefined` if the tag does not exist', () => {
const nonce = queryNonceMetaTagContent(document);

expect(nonce).toEqual(undefined);
});

it('should return `undefined` if the document does not have a head element', () => {
const head = document.querySelector('head');
head.remove();

const nonce = queryNonceMetaTagContent(document);

expect(nonce).toEqual(undefined);
});

it('should return `undefined` if the tag has no content', () => {
const meta = document.createElement('meta');
meta.setAttribute('name', 'csp-nonce');
document.head.appendChild(meta);

const nonce = queryNonceMetaTagContent(document);

expect(nonce).toEqual(undefined);
});
});