Skip to content

Commit c91ed48

Browse files
authored
feat(runtime): support for CSP nonces (#3823)
* 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
1 parent b4b5b22 commit c91ed48

22 files changed

+189
-8
lines changed

src/client/client-patch-browser.ts

+8-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { BUILD, NAMESPACE } from '@app-data';
22
import { consoleDevInfo, doc, H, plt, promiseResolve, win } from '@platform';
3-
import { getDynamicImportFunction } from '@utils';
3+
import { getDynamicImportFunction, queryNonceMetaTagContent } from '@utils';
44

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

@@ -104,6 +104,13 @@ const patchDynamicImport = (base: string, orgScriptElm: HTMLScriptElement) => {
104104
type: 'application/javascript',
105105
})
106106
);
107+
108+
// Apply CSP nonce to the script tag if it exists
109+
const nonce = plt.$nonce$ ?? queryNonceMetaTagContent(doc);
110+
if (nonce != null) {
111+
script.setAttribute('nonce', nonce);
112+
}
113+
107114
mod = new Promise((resolve) => {
108115
script.onload = () => {
109116
resolve((win as any)[importFunctionName].m);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { addGlobalLink } from '../load-link-styles';
2+
3+
describe('loadLinkStyles', () => {
4+
describe('addGlobalLink', () => {
5+
global.fetch = jest.fn().mockResolvedValue({ text: () => '--color: var(--app-color);' });
6+
7+
afterEach(() => {
8+
jest.clearAllMocks();
9+
});
10+
11+
it('should create a style tag within the link element parent node', async () => {
12+
const linkElm = document.createElement('link');
13+
linkElm.setAttribute('rel', 'stylesheet');
14+
linkElm.setAttribute('href', '');
15+
16+
const parentElm = document.createElement('head');
17+
parentElm.appendChild(linkElm);
18+
19+
await addGlobalLink(document, [], linkElm);
20+
21+
expect(parentElm.innerHTML).toEqual('<style data-styles>--color: var(--app-color);</style>');
22+
});
23+
});
24+
});

src/compiler/output-targets/dist-custom-elements/custom-elements-types.ts

+9
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,15 @@ const generateCustomElementsTypesOutput = async (
8181
` */`,
8282
`export declare const setAssetPath: (path: string) => void;`,
8383
``,
84+
`/**`,
85+
` * Used to specify a nonce value that corresponds with an application's CSP.`,
86+
` * When set, the nonce will be added to all dynamically created script and style tags at runtime.`,
87+
` * Alternatively, the nonce value can be set on a meta tag in the DOM head`,
88+
` * (<meta name="csp-nonce" content="{ nonce value here }" />) which`,
89+
` * will result in the same behavior.`,
90+
` */`,
91+
`export declare const setNonce: (nonce: string) => void`,
92+
``,
8493
`export interface SetPlatformOptions {`,
8594
` raf?: (c: FrameRequestCallback) => number;`,
8695
` ael?: (el: EventTarget, eventName: string, listener: EventListenerOrEventListenerObject, options: boolean | AddEventListenerOptions) => void;`,

src/compiler/output-targets/dist-custom-elements/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ export const generateEntryPoint = (outputTarget: d.OutputTargetDistCustomElement
232232
const imp: string[] = [];
233233

234234
imp.push(
235-
`export { setAssetPath, setPlatformOptions } from '${STENCIL_INTERNAL_CLIENT_ID}';`,
235+
`export { setAssetPath, setNonce, setPlatformOptions } from '${STENCIL_INTERNAL_CLIENT_ID}';`,
236236
`export * from '${USER_INDEX_ENTRY_ID}';`
237237
);
238238

src/compiler/output-targets/dist-lazy/lazy-output.ts

+1
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ function createEntryModule(cmps: d.ComponentCompilerMeta[]): d.EntryModule {
150150

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

155156
if (isBrowser) {

src/compiler/output-targets/output-lazy-loader.ts

+9
Original file line numberDiff line numberDiff line change
@@ -89,5 +89,14 @@ export interface CustomElementsDefineOptions {
8989
}
9090
export declare function defineCustomElements(win?: Window, opts?: CustomElementsDefineOptions): Promise<void>;
9191
export declare function applyPolyfills(): Promise<void>;
92+
93+
/**
94+
* Used to specify a nonce value that corresponds with an application's CSP.
95+
* When set, the nonce will be added to all dynamically created script and style tags at runtime.
96+
* Alternatively, the nonce value can be set on a meta tag in the DOM head
97+
* (<meta name="csp-nonce" content="{ nonce value here }" />) which
98+
* will result in the same behavior.
99+
*/
100+
export declare function setNonce(nonce: string): void;
92101
`;
93102
};

src/compiler/output-targets/test/custom-elements-types.spec.ts

+9
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,15 @@ describe('Custom Elements Typedef generation', () => {
8484
' */',
8585
'export declare const setAssetPath: (path: string) => void;',
8686
'',
87+
'/**',
88+
` * Used to specify a nonce value that corresponds with an application's CSP.`,
89+
' * When set, the nonce will be added to all dynamically created script and style tags at runtime.',
90+
' * Alternatively, the nonce value can be set on a meta tag in the DOM head',
91+
' * (<meta name="csp-nonce" content="{ nonce value here }" />) which',
92+
' * will result in the same behavior.',
93+
' */',
94+
'export declare const setNonce: (nonce: string) => void',
95+
'',
8796
'export interface SetPlatformOptions {',
8897
' raf?: (c: FrameRequestCallback) => number;',
8998
' ael?: (el: EventTarget, eventName: string, listener: EventListenerOrEventListenerObject, options: boolean | AddEventListenerOptions) => void;',

src/compiler/output-targets/test/output-targets-dist-custom-elements.spec.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ describe('Custom Elements output target', () => {
148148
);
149149
addCustomElementInputs(buildCtx, bundleOptions);
150150
expect(bundleOptions.loader['\0core']).toEqual(
151-
`export { setAssetPath, setPlatformOptions } from '${STENCIL_INTERNAL_CLIENT_ID}';
151+
`export { setAssetPath, setNonce, setPlatformOptions } from '${STENCIL_INTERNAL_CLIENT_ID}';
152152
export * from '${USER_INDEX_ENTRY_ID}';
153153
import { globalScripts } from '${STENCIL_APP_GLOBALS_ID}';
154154
globalScripts();
@@ -174,7 +174,7 @@ export { MyBestComponent, defineCustomElement as defineCustomElementMyBestCompon
174174
);
175175
addCustomElementInputs(buildCtx, bundleOptions);
176176
expect(bundleOptions.loader['\0core']).toEqual(
177-
`export { setAssetPath, setPlatformOptions } from '${STENCIL_INTERNAL_CLIENT_ID}';
177+
`export { setAssetPath, setNonce, setPlatformOptions } from '${STENCIL_INTERNAL_CLIENT_ID}';
178178
export * from '${USER_INDEX_ENTRY_ID}';
179179
import { globalScripts } from '${STENCIL_APP_GLOBALS_ID}';
180180
globalScripts();

src/declarations/stencil-private.ts

+10
Original file line numberDiff line numberDiff line change
@@ -1697,6 +1697,11 @@ export interface PlatformRuntime {
16971697
$flags$: number;
16981698
$orgLocNodes$?: Map<string, RenderNode>;
16991699
$resourcesUrl$: string;
1700+
/**
1701+
* The nonce value to be applied to all script/style tags at runtime.
1702+
* If `null`, the nonce attribute will not be applied.
1703+
*/
1704+
$nonce$?: string | null;
17001705
jmp: (c: Function) => any;
17011706
raf: (c: FrameRequestCallback) => number;
17021707
ael: (
@@ -2399,6 +2404,11 @@ export interface NewSpecPageOptions {
23992404
attachStyles?: boolean;
24002405

24012406
strictBuild?: boolean;
2407+
/**
2408+
* Default values to be set on the platform runtime object (@see PlatformRuntime) when creating
2409+
* the spec page.
2410+
*/
2411+
platform?: Partial<PlatformRuntime>;
24022412
}
24032413

24042414
/**

src/declarations/stencil-public-runtime.ts

+10
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,16 @@ export declare function getAssetPath(path: string): string;
298298
*/
299299
export declare function setAssetPath(path: string): string;
300300

301+
/**
302+
* Used to specify a nonce value that corresponds with an application's
303+
* [Content Security Policy (CSP)](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP).
304+
* When set, the nonce will be added to all dynamically created script and style tags at runtime.
305+
* Alternatively, the nonce value can be set on a `meta` tag in the DOM head
306+
* (<meta name="csp-nonce" content="{ nonce value here }" />) and will result in the same behavior.
307+
* @param nonce The value to be used for the nonce attribute.
308+
*/
309+
export declare function setNonce(nonce: string): void;
310+
301311
/**
302312
* Retrieve a Stencil element for a given reference
303313
* @param ref the ref to get the Stencil element for

src/hydrate/platform/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -186,5 +186,6 @@ export {
186186
renderVdom,
187187
setAssetPath,
188188
setMode,
189+
setNonce,
189190
setValue,
190191
} from '@runtime';

src/internal/stencil-core/index.d.ts

+1
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export {
4343
setAssetPath,
4444
setErrorHandler,
4545
setMode,
46+
setNonce,
4647
setPlatformHelpers,
4748
State,
4849
Watch,

src/runtime/bootstrap-lazy.ts

+8-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { BUILD } from '@app-data';
22
import { doc, getHostRef, plt, registerHost, supportsShadow, win } from '@platform';
3-
import { CMP_FLAGS } from '@utils';
3+
import { CMP_FLAGS, queryNonceMetaTagContent } from '@utils';
44

55
import type * as d from '../declarations';
66
import { connectedCallback } from './connected-callback';
@@ -12,6 +12,7 @@ import { proxyComponent } from './proxy-component';
1212
import { HYDRATED_CSS, HYDRATED_STYLE_ID, PLATFORM_FLAGS, PROXY_FLAGS } from './runtime-constants';
1313
import { convertScopedToShadow, registerStyle } from './styles';
1414
import { appDidLoad } from './update-component';
15+
export { setNonce } from '@platform';
1516

1617
export const bootstrapLazy = (lazyBundles: d.LazyBundlesRuntimeData, options: d.CustomElementsDefineOptions = {}) => {
1718
if (BUILD.profile && performance.mark) {
@@ -166,6 +167,12 @@ export const bootstrapLazy = (lazyBundles: d.LazyBundlesRuntimeData, options: d.
166167
if (BUILD.invisiblePrehydration && (BUILD.hydratedClass || BUILD.hydratedAttribute)) {
167168
visibilityStyle.innerHTML = cmpTags + HYDRATED_CSS;
168169
visibilityStyle.setAttribute('data-styles', '');
170+
171+
// Apply CSP nonce to the style tag if it exists
172+
const nonce = plt.$nonce$ ?? queryNonceMetaTagContent(doc);
173+
if (nonce != null) {
174+
visibilityStyle.setAttribute('nonce', nonce);
175+
}
169176
head.insertBefore(visibilityStyle, metaCharset ? metaCharset.nextSibling : head.firstChild);
170177
}
171178

src/runtime/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export { createEvent } from './event-emitter';
1010
export { Fragment } from './fragment';
1111
export { addHostEventListeners } from './host-listener';
1212
export { getMode, setMode } from './mode';
13+
export { setNonce } from './nonce';
1314
export { parsePropertyValue } from './parse-property-value';
1415
export { setPlatformOptions } from './platform-options';
1516
export { proxyComponent } from './proxy-component';

src/runtime/nonce.ts

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { plt } from '@platform';
2+
3+
/**
4+
* Assigns the given value to the nonce property on the runtime platform object.
5+
* During runtime, this value is used to set the nonce attribute on all dynamically created script and style tags.
6+
* @param nonce The value to be assigned to the platform nonce property.
7+
* @returns void
8+
*/
9+
export const setNonce = (nonce: string) => (plt.$nonce$ = nonce);

src/runtime/styles.ts

+7-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { BUILD } from '@app-data';
22
import { doc, plt, styles, supportsConstructableStylesheets, supportsShadow } from '@platform';
3-
import { CMP_FLAGS } from '@utils';
3+
import { CMP_FLAGS, queryNonceMetaTagContent } from '@utils';
44

55
import type * as d from '../declarations';
66
import { createTime } from './profile';
@@ -77,6 +77,12 @@ export const addStyle = (
7777
styleElm.innerHTML = style;
7878
}
7979

80+
// Apply CSP nonce to the style tag if it exists
81+
const nonce = plt.$nonce$ ?? queryNonceMetaTagContent(doc);
82+
if (nonce != null) {
83+
styleElm.setAttribute('nonce', nonce);
84+
}
85+
8086
if (BUILD.hydrateServerSide || BUILD.hotModuleReplacement) {
8187
styleElm.setAttribute(HYDRATED_STYLE_ID, scopeId);
8288
}

src/runtime/test/style.spec.tsx

+25
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,31 @@ describe('style', () => {
2323
expect(styles.get('sc-cmp-a')).toBe(`div { color: red; }`);
2424
});
2525

26+
it('applies the nonce value to the head style tags', async () => {
27+
@Component({
28+
tag: 'cmp-a',
29+
styles: `div { color: red; }`,
30+
})
31+
class CmpA {
32+
render() {
33+
return `innertext`;
34+
}
35+
}
36+
37+
const { doc } = await newSpecPage({
38+
components: [CmpA],
39+
includeAnnotations: true,
40+
html: `<cmp-a></cmp-a>`,
41+
platform: {
42+
$nonce$: '1234',
43+
},
44+
});
45+
46+
expect(doc.head.innerHTML).toEqual(
47+
'<style data-styles nonce="1234">cmp-a{visibility:hidden}.hydrated{visibility:inherit}</style>'
48+
);
49+
});
50+
2651
describe('mode', () => {
2752
it('md mode', async () => {
2853
setMode(() => 'md');

src/testing/platform/testing-platform.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export const setSupportsShadowDom = (supports: boolean) => {
3535
supportsShadow = supports;
3636
};
3737

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

4849
if (plt.$orgLocNodes$ != null) {
4950
plt.$orgLocNodes$.clear();

src/testing/spec-page.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export async function newSpecPage(opts: NewSpecPageOptions): Promise<SpecPage> {
3636
}
3737

3838
// reset the platform for this new test
39-
resetPlatform();
39+
resetPlatform(opts.platform ?? {});
4040
resetBuildConditionals(BUILD);
4141

4242
if (Array.isArray(opts.components)) {

src/utils/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export * from './logger/logger-typescript';
99
export * from './logger/logger-utils';
1010
export * from './message-utils';
1111
export * from './normalize-path';
12+
export * from './query-nonce-meta-tag-content';
1213
export * from './sourcemaps';
1314
export * from './url-paths';
1415
export * from './util';
+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/**
2+
* Helper method for querying a `meta` tag that contains a nonce value
3+
* out of a DOM's head.
4+
*
5+
* @param doc The DOM containing the `head` to query against
6+
* @returns The content of the meta tag representing the nonce value, or `undefined` if no tag
7+
* exists or the tag has no content.
8+
*/
9+
export function queryNonceMetaTagContent(doc: Document): string | undefined {
10+
return doc.head?.querySelector('meta[name="csp-nonce"]')?.getAttribute('content') ?? undefined;
11+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { queryNonceMetaTagContent } from '../query-nonce-meta-tag-content';
2+
3+
describe('queryNonceMetaTagContent', () => {
4+
it('should return the nonce value if the tag exists', () => {
5+
const meta = document.createElement('meta');
6+
meta.setAttribute('name', 'csp-nonce');
7+
meta.setAttribute('content', '1234');
8+
document.head.appendChild(meta);
9+
10+
const nonce = queryNonceMetaTagContent(document);
11+
12+
expect(nonce).toEqual('1234');
13+
});
14+
15+
it('should return `undefined` if the tag does not exist', () => {
16+
const nonce = queryNonceMetaTagContent(document);
17+
18+
expect(nonce).toEqual(undefined);
19+
});
20+
21+
it('should return `undefined` if the document does not have a head element', () => {
22+
const head = document.querySelector('head');
23+
head.remove();
24+
25+
const nonce = queryNonceMetaTagContent(document);
26+
27+
expect(nonce).toEqual(undefined);
28+
});
29+
30+
it('should return `undefined` if the tag has no content', () => {
31+
const meta = document.createElement('meta');
32+
meta.setAttribute('name', 'csp-nonce');
33+
document.head.appendChild(meta);
34+
35+
const nonce = queryNonceMetaTagContent(document);
36+
37+
expect(nonce).toEqual(undefined);
38+
});
39+
});

0 commit comments

Comments
 (0)