Skip to content

Commit dc3cbd5

Browse files
authored
feat: support CSP nonce on injected style elements (adobe#9655)
* feat: support CSP nonce on dynamically injected style elements Add getNonce() utility that reads CSP nonce from <meta property="csp-nonce"> or __webpack_nonce__, and apply it to <style> elements injected by usePress and usePreventScroll. This fixes CSP violations when a strict style-src directive is in effect. Fixes adobe#8273 * fix: add globalThis ESLint global to getNonce test file The .js test file was missing globalThis in ESLint globals since the TypeScript config block (which declares it) only applies to .ts/.tsx files. This resolves the no-undef lint failure. * fix: use type guard, cache nonce, and add globalThis ESLint global Address review feedback: - Replace type cast with instanceof type guard via getOwnerWindow - Cache nonce result for repeated calls without explicit doc arg - Add globalThis to ESLint globals to fix lint failure * fix: reset nonce cache between tests Add resetNonceCache() export and call it in afterEach to prevent cached values from leaking across test runs. * fix: remove redundant globalThis inline global directive globalThis is already declared in eslint.config.mjs globals, so the /* global globalThis */ comment triggers no-redeclare. * fix: use WeakMap for nonce caching to support multiple documents - Replace module-level cache with WeakMap<Document, string | undefined> so each document gets its own cached nonce (supports iframes) - Remove resetNonceCache from public API (only needed for tests) - Import resetNonceCache directly from source in test file * fix: remove redundant optional chaining and add comprehensive tests Remove unnecessary ?. operators after null guard in getNonce, and add tests for nonce/content priority, caching behavior, cache reset, empty string handling, and content fallback. * fix: check document window for __webpack_nonce__ and don't cache misses Extract getWebpackNonce() helper that checks the document's owning window before falling back to globalThis, supporting iframe scenarios. Only cache defined nonce values so late-arriving nonces are detected. Add tests for no-cache-on-miss, late meta detection, and per-window webpack nonce resolution.
1 parent 1f54814 commit dc3cbd5

File tree

6 files changed

+217
-1
lines changed

6 files changed

+217
-1
lines changed

eslint.config.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,7 @@ export default [{
457457
FileSystemDirectoryEntry: "readonly",
458458
FileSystemEntry: "readonly",
459459
IS_REACT_ACT_ENVIRONMENT: "readonly",
460+
globalThis: "readonly",
460461
},
461462

462463
parser: tseslint.parser,

packages/@react-aria/interactions/src/usePress.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
chain,
2020
focusWithoutScrolling,
2121
getEventTarget,
22+
getNonce,
2223
getOwnerDocument,
2324
getOwnerWindow,
2425
isMac,
@@ -887,6 +888,10 @@ export function usePress(props: PressHookProps): PressResult {
887888

888889
const style = ownerDocument.createElement('style');
889890
style.id = STYLE_ID;
891+
let nonce = getNonce(ownerDocument);
892+
if (nonce) {
893+
style.nonce = nonce;
894+
}
890895
// touchAction: 'manipulation' is supposed to be equivalent, but in
891896
// Safari it causes onPointerCancel not to fire on scroll.
892897
// https://bugs.webkit.org/show_bug.cgi?id=240917

packages/@react-aria/overlays/src/usePreventScroll.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
import {chain, getActiveElement, getEventTarget, getScrollParent, isIOS, isScrollable, useLayoutEffect, willOpenKeyboard} from '@react-aria/utils';
13+
import {chain, getActiveElement, getEventTarget, getNonce, getScrollParent, isIOS, isScrollable, useLayoutEffect, willOpenKeyboard} from '@react-aria/utils';
1414

1515
interface PreventScrollOptions {
1616
/** Whether the scroll lock is disabled. */
@@ -134,6 +134,10 @@ function preventScrollMobileSafari() {
134134
// the window instead.
135135
// This must be applied before the touchstart event as of iOS 26, so inject it as a <style> element.
136136
let style = document.createElement('style');
137+
let nonce = getNonce();
138+
if (nonce) {
139+
style.nonce = nonce;
140+
}
137141
style.textContent = `
138142
@layer {
139143
* {
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/*
2+
* Copyright 2026 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
import {getOwnerWindow} from './domHelpers';
14+
15+
type NonceWindow = Window & typeof globalThis & {
16+
__webpack_nonce__?: string
17+
};
18+
19+
function getWebpackNonce(doc?: Document): string | undefined {
20+
let ownerWindow = doc?.defaultView as NonceWindow | null | undefined;
21+
return ownerWindow?.__webpack_nonce__ || globalThis['__webpack_nonce__'] || undefined;
22+
}
23+
24+
let nonceCache = new WeakMap<Document, string>();
25+
26+
/** Reset the cached nonce value. Exported for testing only. */
27+
export function resetNonceCache(): void {
28+
nonceCache = new WeakMap();
29+
}
30+
31+
/**
32+
* Returns the CSP nonce, if configured via a `<meta property="csp-nonce">` tag or `__webpack_nonce__`.
33+
* This allows dynamically injected `<style>` elements to work with Content Security Policy.
34+
*/
35+
export function getNonce(doc?: Document): string | undefined {
36+
let d = doc ?? (typeof document !== 'undefined' ? document : undefined);
37+
if (!d) {
38+
return getWebpackNonce(d);
39+
}
40+
41+
if (nonceCache.has(d)) {
42+
return nonceCache.get(d);
43+
}
44+
45+
let meta = d.querySelector('meta[property="csp-nonce"]');
46+
let nonce = (meta && meta instanceof getOwnerWindow(meta).HTMLMetaElement && (meta.nonce || meta.content)) || getWebpackNonce(d) || undefined;
47+
48+
if (nonce !== undefined) {
49+
nonceCache.set(d, nonce);
50+
}
51+
return nonce;
52+
}

packages/@react-aria/utils/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,5 +51,6 @@ export {CLEAR_FOCUS_EVENT, FOCUS_EVENT} from './constants';
5151
export {isCtrlKeyPressed, willOpenKeyboard} from './keyboard';
5252
export {useEnterAnimation, useExitAnimation} from './animation';
5353
export {isFocusable, isTabbable} from './isFocusable';
54+
export {getNonce} from './getNonce';
5455

5556
export type {LoadMoreSentinelProps} from './useLoadMoreSentinel';
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
/*
2+
* Copyright 2026 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
import {getNonce} from '../';
14+
import {resetNonceCache} from '../src/getNonce';
15+
16+
describe('getNonce', () => {
17+
afterEach(() => {
18+
document.querySelectorAll('meta[property="csp-nonce"]').forEach(el => el.remove());
19+
document.querySelectorAll('iframe').forEach(el => el.remove());
20+
delete globalThis['__webpack_nonce__'];
21+
resetNonceCache();
22+
});
23+
24+
it('returns undefined when no nonce is configured', () => {
25+
expect(getNonce()).toBeUndefined();
26+
});
27+
28+
it('reads nonce from meta tag nonce attribute', () => {
29+
let meta = document.createElement('meta');
30+
meta.setAttribute('property', 'csp-nonce');
31+
meta.nonce = 'test-nonce-123';
32+
document.head.appendChild(meta);
33+
34+
expect(getNonce()).toBe('test-nonce-123');
35+
});
36+
37+
it('reads nonce from meta tag content attribute', () => {
38+
let meta = document.createElement('meta');
39+
meta.setAttribute('property', 'csp-nonce');
40+
meta.setAttribute('content', 'content-nonce-456');
41+
document.head.appendChild(meta);
42+
43+
expect(getNonce()).toBe('content-nonce-456');
44+
});
45+
46+
it('reads nonce from __webpack_nonce__ global', () => {
47+
globalThis['__webpack_nonce__'] = 'webpack-nonce-789';
48+
49+
expect(getNonce()).toBe('webpack-nonce-789');
50+
});
51+
52+
it('prefers meta tag nonce over __webpack_nonce__', () => {
53+
let meta = document.createElement('meta');
54+
meta.setAttribute('property', 'csp-nonce');
55+
meta.nonce = 'meta-nonce';
56+
document.head.appendChild(meta);
57+
globalThis['__webpack_nonce__'] = 'webpack-nonce';
58+
59+
expect(getNonce()).toBe('meta-nonce');
60+
});
61+
62+
it('prefers nonce attribute over content attribute on the same meta tag', () => {
63+
let meta = document.createElement('meta');
64+
meta.setAttribute('property', 'csp-nonce');
65+
meta.nonce = 'nonce-attr';
66+
meta.setAttribute('content', 'content-attr');
67+
document.head.appendChild(meta);
68+
69+
expect(getNonce()).toBe('nonce-attr');
70+
});
71+
72+
it('caches the nonce per document', () => {
73+
let meta = document.createElement('meta');
74+
meta.setAttribute('property', 'csp-nonce');
75+
meta.nonce = 'cached-nonce';
76+
document.head.appendChild(meta);
77+
78+
expect(getNonce()).toBe('cached-nonce');
79+
80+
// Remove the meta tag — cached value should still be returned
81+
meta.remove();
82+
expect(getNonce()).toBe('cached-nonce');
83+
});
84+
85+
it('resetNonceCache clears the cached value', () => {
86+
let meta = document.createElement('meta');
87+
meta.setAttribute('property', 'csp-nonce');
88+
meta.nonce = 'first-nonce';
89+
document.head.appendChild(meta);
90+
91+
expect(getNonce()).toBe('first-nonce');
92+
93+
// Change the nonce and clear the cache
94+
meta.nonce = 'second-nonce';
95+
resetNonceCache();
96+
97+
expect(getNonce()).toBe('second-nonce');
98+
});
99+
100+
it('treats empty string nonce as no nonce', () => {
101+
let meta = document.createElement('meta');
102+
meta.setAttribute('property', 'csp-nonce');
103+
meta.nonce = '';
104+
meta.setAttribute('content', '');
105+
document.head.appendChild(meta);
106+
107+
expect(getNonce()).toBeUndefined();
108+
});
109+
110+
it('falls back to content when nonce attribute is empty', () => {
111+
let meta = document.createElement('meta');
112+
meta.setAttribute('property', 'csp-nonce');
113+
meta.nonce = '';
114+
meta.setAttribute('content', 'content-fallback');
115+
document.head.appendChild(meta);
116+
117+
expect(getNonce()).toBe('content-fallback');
118+
});
119+
120+
it('does not cache a missing nonce', () => {
121+
// First call: no nonce configured — should return undefined
122+
expect(getNonce()).toBeUndefined();
123+
124+
// Now set a nonce — it should be picked up because undefined wasn't cached
125+
globalThis['__webpack_nonce__'] = 'late-nonce';
126+
expect(getNonce()).toBe('late-nonce');
127+
});
128+
129+
it('detects a meta nonce added after an initial miss', () => {
130+
// First call: no meta tag — should return undefined
131+
expect(getNonce()).toBeUndefined();
132+
133+
// Add a meta tag after the initial miss
134+
let meta = document.createElement('meta');
135+
meta.setAttribute('property', 'csp-nonce');
136+
meta.nonce = 'late-meta-nonce';
137+
document.head.appendChild(meta);
138+
139+
expect(getNonce()).toBe('late-meta-nonce');
140+
});
141+
142+
it('reads __webpack_nonce__ from the provided document window', () => {
143+
let iframe = document.createElement('iframe');
144+
document.body.appendChild(iframe);
145+
146+
// Set different nonces on parent and iframe windows
147+
globalThis['__webpack_nonce__'] = 'parent-nonce';
148+
iframe.contentWindow['__webpack_nonce__'] = 'iframe-nonce';
149+
150+
// When given the iframe's document, should prefer the iframe's nonce
151+
expect(getNonce(iframe.contentDocument)).toBe('iframe-nonce');
152+
});
153+
});

0 commit comments

Comments
 (0)