feat: support CSP nonce on injected style elements#9655
feat: support CSP nonce on injected style elements#9655costajohnt wants to merge 8 commits intoadobe:mainfrom
Conversation
fece367 to
72626e4
Compare
reidbarber
left a comment
There was a problem hiding this comment.
Thanks for the PR!
- I think we can add
globalThis: "readonly"to thelanguageOptions.globalsineslint.config.mjs(at line 460) to fix the lint step failing. - You also need to sign the CLA, then close and re-open the PR and the build should pass.
snowystinger
left a comment
There was a problem hiding this comment.
Thanks for the PR, one small comment for typescript
|
|
||
| /** | ||
| * Returns the CSP nonce, if configured via a `<meta property="csp-nonce">` tag or `__webpack_nonce__`. | ||
| * This allows dynamically injected `<style>` elements to work with Content Security Policy. | ||
| */ | ||
| export function getNonce(doc?: Document): string | undefined { | ||
| let d = doc ?? (typeof document !== 'undefined' ? document : undefined); | ||
| let meta = d | ||
| ? d.querySelector('meta[property="csp-nonce"]') as HTMLMetaElement | null | ||
| : null; | ||
| return meta?.nonce || meta?.content || (globalThis as Record<string, any>)['__webpack_nonce__'] || undefined; | ||
| } |
There was a problem hiding this comment.
No need to cast things, we can use type guards
| /** | |
| * Returns the CSP nonce, if configured via a `<meta property="csp-nonce">` tag or `__webpack_nonce__`. | |
| * This allows dynamically injected `<style>` elements to work with Content Security Policy. | |
| */ | |
| export function getNonce(doc?: Document): string | undefined { | |
| let d = doc ?? (typeof document !== 'undefined' ? document : undefined); | |
| let meta = d | |
| ? d.querySelector('meta[property="csp-nonce"]') as HTMLMetaElement | null | |
| : null; | |
| return meta?.nonce || meta?.content || (globalThis as Record<string, any>)['__webpack_nonce__'] || undefined; | |
| } | |
| import {getOwnerWindow} from './domHelpers'; | |
| /** | |
| * Returns the CSP nonce, if configured via a `<meta property="csp-nonce">` tag or `__webpack_nonce__`. | |
| * This allows dynamically injected `<style>` elements to work with Content Security Policy. | |
| */ | |
| export function getNonce(doc?: Document): string | undefined { | |
| let d = doc ?? (typeof document !== 'undefined' ? document : undefined); | |
| let meta = d | |
| ? d.querySelector('meta[property="csp-nonce"]') | |
| : null; | |
| return (meta && meta instanceof getOwnerWindow(meta).HTMLMetaElement && (meta?.nonce || meta?.content)) || globalThis['__webpack_nonce__'] || undefined; | |
| } |
There was a problem hiding this comment.
Might also be worth caching this return value?
|
@snowystinger This is still marked as changes requested but I believe I've addressed all concerns with recent commits. Can you take another look when you get a changes please? 🙇 |
| /** Reset the cached nonce value. Exported for testing only. */ | ||
| export function resetNonceCache(): void { | ||
| cachedNonce = undefined; | ||
| cachePopulated = false; | ||
| } | ||
|
|
There was a problem hiding this comment.
I think the cache should be a WeakMap on the Document. Also we can just skip cache entirely for test environments rather than exporting a reset helper.
There was a problem hiding this comment.
we can still do the reset and it's good to go through the same things the browser would, but we shouldn't need to export it from the index file. I've added comments to show how to do that.
It'd be fine to have it in a WeakMap, but what were you thinking for the key?
There was a problem hiding this comment.
I was thinking Document for the key, nonce as the value. Right now the caching would break with multiple documents.
There was a problem hiding this comment.
ah, yeah, that'd be a good idea, thanks for bringing that up
| export {isCtrlKeyPressed, willOpenKeyboard} from './keyboard'; | ||
| export {useEnterAnimation, useExitAnimation} from './animation'; | ||
| export {isFocusable, isTabbable} from './isFocusable'; | ||
| export {getNonce, resetNonceCache} from './getNonce'; |
There was a problem hiding this comment.
| export {getNonce, resetNonceCache} from './getNonce'; | |
| export {getNonce} from './getNonce'; |
| * governing permissions and limitations under the License. | ||
| */ | ||
|
|
||
| import {getNonce, resetNonceCache} from '../'; |
There was a problem hiding this comment.
| import {getNonce, resetNonceCache} from '../'; | |
| import {getNonce} from '../'; | |
| import {resetNonceCache} from '../src/getNonce'; |
| /** Reset the cached nonce value. Exported for testing only. */ | ||
| export function resetNonceCache(): void { | ||
| cachedNonce = undefined; | ||
| cachePopulated = false; | ||
| } | ||
|
|
There was a problem hiding this comment.
we can still do the reset and it's good to go through the same things the browser would, but we shouldn't need to export it from the index file. I've added comments to show how to do that.
It'd be fine to have it in a WeakMap, but what were you thinking for the key?
snowystinger
left a comment
There was a problem hiding this comment.
Thanks for the quick fixes!
reidbarber
left a comment
There was a problem hiding this comment.
Nice! Just two small requests
| } | ||
|
|
||
| let meta = d.querySelector('meta[property="csp-nonce"]'); | ||
| let nonce = (meta && meta instanceof getOwnerWindow(meta).HTMLMetaElement && (meta.nonce || meta.content)) || globalThis['__webpack_nonce__'] || undefined; |
There was a problem hiding this comment.
The webpack fallback uses globalThis, but in an iframe it might have it's own __webpack_nonce__. Maybe we want to have something like:
type NonceWindow = Window & typeof globalThis & {
__webpack_nonce__?: string
};
// ...
function getWebpackNonce(doc?: Document): string | undefined {
let ownerWindow = doc?.defaultView as NonceWindow | null | undefined;
return ownerWindow?.__webpack_nonce__ || globalThis['__webpack_nonce__'] || undefined;
}and we can call that on line 29 and 37.
| let meta = d.querySelector('meta[property="csp-nonce"]'); | ||
| let nonce = (meta && meta instanceof getOwnerWindow(meta).HTMLMetaElement && (meta.nonce || meta.content)) || globalThis['__webpack_nonce__'] || undefined; | ||
|
|
||
| nonceCache.set(d, nonce); |
There was a problem hiding this comment.
This caches misses, so we probably want:
| nonceCache.set(d, nonce); | |
| if (nonce !== undefined) { | |
| nonceCache.set(d, nonce); | |
| } |
then we could remove undefined from the type above:
let nonceCache = new WeakMap<Document, string>();| document.head.appendChild(meta); | ||
|
|
||
| expect(getNonce()).toBe('content-fallback'); | ||
| }); |
There was a problem hiding this comment.
Here are some tests for the requested changes:
it('does not cache a missing nonce', () => {
expect(getNonce()).toBeUndefined();
globalThis['__webpack_nonce__'] = 'late-webpack-nonce';
expect(getNonce()).toBe('late-webpack-nonce');
});
it('detects a meta nonce added after an initial miss', () => {
expect(getNonce()).toBeUndefined();
let meta = document.createElement('meta');
meta.setAttribute('property', 'csp-nonce');
meta.nonce = 'late-meta-nonce';
document.head.appendChild(meta);
expect(getNonce()).toBe('late-meta-nonce');
});
it('reads __webpack_nonce__ from the provided document window', () => {
let iframe = document.createElement('iframe');
document.body.appendChild(iframe);
globalThis['__webpack_nonce__'] = 'parent-webpack-nonce';
iframe.contentWindow['__webpack_nonce__'] = 'iframe-webpack-nonce';
expect(getNonce(iframe.contentWindow.document)).toBe('iframe-webpack-nonce');
});with cleanup of the iframe in afterEach:
document.querySelectorAll('iframe').forEach(el => el.remove());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
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.
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
Add resetNonceCache() export and call it in afterEach to prevent cached values from leaking across test runs.
globalThis is already declared in eslint.config.mjs globals, so the /* global globalThis */ comment triggers no-redeclare.
- 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
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.
c4bde89 to
7956387
Compare
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.
Summary
Fixes #8273
Adds CSP nonce support to dynamically injected
<style>elements inusePressandusePreventScroll. Without a nonce, these inline styles are blocked by strict Content Security Policy headers.Approach
Following the approach suggested by @devongovett, this PR adds a
getNonce()utility that reads the nonce from existing pseudo-standards:This automatically works with:
html.cspNonceconfig (injects<meta property="csp-nonce">)__webpack_nonce__global<meta property="csp-nonce" content="...">to the document headChanges
@react-aria/utils: AddedgetNonce()utility + export@react-aria/interactions: Applied nonce to the<style>element inusePress@react-aria/overlays: Applied nonce to the<style>element inusePreventScrollTest plan
getNonce()covering: no config, meta nonce attribute, meta content attribute, webpack global, precedenceusePresstests pass (90/90)