Skip to content

Commit

Permalink
feat(runtime): support for CSP nonces (#3823)
Browse files Browse the repository at this point in the history
* wip(compiler/runtime): ability to set nonce on runtime platform

This commit adds the ability to set a `nonce` value on the runtime platform object. This also introduces the consumption of this value and setting of the `nonce` attribute for generated `style` tags in `dist-custom-elements`.

* wip(compiler/runtime): setNonce behavior for dist target

This commit adds the `setNonce` definition to the generated output for the `dist` output target. This also sets the `nonce` attribute on the style tag responsible for invisible pre-hydration styles.

* feat(runtime): extra check for nonce on window

* chore(): nonce function/declaration code comments

This commit adds some comments to the internal and generated helper function code for apply nonce attributes.

* test(): test various nonce application functions

* chore(): run prettier

* fix(): update unit tests

* fix(tests): remove platform reference in CSS shim polyfill

* fix(tests): update CSS shim unit tests

* chore(): remove CSP from IE polyfills

CSP nonces aren't supported by IE so there is no reason to add support in the polyfills

* chore(): add JSdoc comments

* chore(): update `setNonce` JSdoc

* feat(runtime): updates nonce fallback to use meta tag instead of window (#3955)

* feat(runtime): updates nonce fallback to use meta tag instead of window

This commit updates our CSP nonce support logic to allow implementers to leverage a meta tag in the DOM head for setting nonce values during the Stencil runtime rather than pulling the value off of the global window object

* fix(): PR feedback

* feat(utils): update return type fallback to `undefined` only
  • Loading branch information
tanner-reits committed Jan 10, 2023
1 parent b4b5b22 commit c91ed48
Show file tree
Hide file tree
Showing 22 changed files with 189 additions and 8 deletions.
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
2 changes: 1 addition & 1 deletion src/compiler/output-targets/dist-custom-elements/index.ts
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.
*/
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.',
' */',
'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>;
}

/**
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;

/**
* 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);
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);
});
});

0 comments on commit c91ed48

Please sign in to comment.