Skip to content

Commit 6df33d3

Browse files
nikita-jpgNikita Zaletin
andauthored
fix(mdx): discover CSP nonce from the document (#241)
* fix(mdx): discover CSP nonce from the document * fix(mdx): skip empty document nonces --------- Co-authored-by: Nikita Zaletin <zaletinni@users.noreply.github.com>
1 parent e83803c commit 6df33d3

6 files changed

Lines changed: 129 additions & 20 deletions

File tree

src/components/atoms/MarkdownRenderer/MarkdownRenderer.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export interface MarkdownRendererMdxOptions {
2828
components: MDXComponents;
2929
/** Optional list of tag names to limit which components are processed as MDX. */
3030
tagNames?: string[];
31-
/** CSP nonce applied to scripts that execute compiled MDX artifacts. */
31+
/** CSP nonce applied to compiled MDX scripts. Defaults to a nonce found in the document. */
3232
nonce?: string;
3333
}
3434

src/components/atoms/MarkdownRenderer/MdxPortals.tsx

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import type {MDXComponents, MDXModule, MDXProps} from 'mdx/types';
77
import * as runtime from 'react/jsx-runtime';
88

99
import {MdxDataContext} from './MdxContext';
10-
import {asyncExecuteCode} from './asyncExecuteCode';
10+
import {asyncExecuteCode, resolveCspNonce} from './asyncExecuteCode';
1111

1212
interface MdxLoaderState extends IdMdxComponentLoader {
1313
idMdx?: Record<string, string>;
@@ -16,10 +16,11 @@ interface MdxLoaderState extends IdMdxComponentLoader {
1616

1717
function useMdxComponentLoader(mdxArtifacts: MdxArtifacts | undefined, nonce?: string) {
1818
const idMdx = mdxArtifacts?.idMdx;
19+
const resolvedNonce = resolveCspNonce(nonce);
1920
const [state, setState] = useState<MdxLoaderState>({isSuccess: false});
2021

2122
useEffect(() => {
22-
if (!nonce) {
23+
if (!resolvedNonce) {
2324
return () => undefined;
2425
}
2526

@@ -32,7 +33,7 @@ function useMdxComponentLoader(mdxArtifacts: MdxArtifacts | undefined, nonce?: s
3233
for (const [artifactId, code] of Object.entries(idMdx ?? {})) {
3334
const fn = await asyncExecuteCode<(jsxRuntime: typeof runtime) => MDXModule>(
3435
code,
35-
nonce,
36+
resolvedNonce,
3637
);
3738

3839
if (!isActive) {
@@ -43,25 +44,25 @@ function useMdxComponentLoader(mdxArtifacts: MdxArtifacts | undefined, nonce?: s
4344
}
4445

4546
if (isActive) {
46-
setState({idMdx, nonce, data, isSuccess: true});
47+
setState({idMdx, nonce: resolvedNonce, data, isSuccess: true});
4748
}
4849
} catch {
4950
if (isActive) {
50-
setState({idMdx, nonce, isSuccess: false});
51+
setState({idMdx, nonce: resolvedNonce, isSuccess: false});
5152
}
5253
}
5354
})();
5455

5556
return () => {
5657
isActive = false;
5758
};
58-
}, [idMdx, nonce]);
59+
}, [idMdx, resolvedNonce]);
5960

60-
if (!nonce) {
61+
if (!resolvedNonce) {
6162
return undefined;
6263
}
6364

64-
if (state.idMdx !== idMdx || state.nonce !== nonce) {
65+
if (state.idMdx !== idMdx || state.nonce !== resolvedNonce) {
6566
return {isSuccess: false};
6667
}
6768

@@ -77,7 +78,7 @@ export interface MdxPortalsProps {
7778
components?: MDXComponents;
7879
/** Artifacts collected by the MDX transform, required to hydrate the components. */
7980
mdxArtifacts?: MdxArtifacts;
80-
/** CSP nonce applied to scripts that execute compiled MDX artifacts. */
81+
/** CSP nonce applied to compiled MDX scripts. Defaults to a nonce found in the document. */
8182
nonce?: string;
8283
/** Arbitrary value exposed to the MDX components through {@link MdxDataContext}. */
8384
mdxContext?: Record<string, unknown>;

src/components/atoms/MarkdownRenderer/README.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ This block is rendered by a **React component**.
5252
components: {Callout},
5353
// Optional: only treat these tags as MDX components
5454
tagNames: ['Callout'],
55-
// Required when the service enforces a nonce-based script-src CSP
55+
// Optional: pass the nonce explicitly instead of discovering it from the page
5656
nonce: cspNonce,
5757
}}
5858
/>;
@@ -63,10 +63,11 @@ This block is rendered by a **React component**.
6363
> blocks and its artifacts must be collected from the whole document.
6464
6565
Compiled MDX artifacts are executed through an inline `<script>` instead of `eval` or
66-
`new Function`. Services with a nonce-based `script-src` Content Security Policy must pass the
67-
nonce through `mdxOptions.nonce`; the renderer applies it to every temporary MDX script.
68-
When `nonce` is omitted, the renderer preserves the extension's legacy `new Function` execution
69-
path for backward compatibility.
66+
`new Function`. Services with a nonce-based `script-src` Content Security Policy can pass the
67+
nonce through `mdxOptions.nonce`; the renderer applies it to every temporary MDX script. When the
68+
option is omitted, the renderer reuses the first non-empty nonce from a
69+
`<script nonce="...">` in the document. If no nonce is available, the renderer preserves the
70+
extension's legacy `new Function` execution path for backward compatibility.
7071

7172
### Per-message context (`mdxContext` + `useMdxContext`)
7273

@@ -128,7 +129,7 @@ instead of a static value. It is called with the concrete message and its return
128129
| ------------ | --------------- | -------- | ------- | ----------------------------------------------------------------------------------------------------------------- |
129130
| `components` | `MDXComponents` | Yes | - | Map of tag name → React component used to render embedded MDX/JSX in the content (e.g. `{Callout, StatusBadge}`). |
130131
| `tagNames` | `string[]` | - | - | Restricts which tags are processed as MDX components. When omitted, the extension's default detection is used. |
131-
| `nonce` | `string` | - | - | CSP nonce applied to temporary inline scripts. When omitted, MDX uses the legacy `new Function` execution path. |
132+
| `nonce` | `string` | - | - | CSP nonce applied to temporary inline scripts. When omitted, it is discovered from an existing script. |
132133

133134
## Styling
134135

src/components/atoms/MarkdownRenderer/__tests__/MdxPortals.unit.test.tsx

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {useMdx} from '@diplodoc/mdx-extension';
2-
import {render} from '@testing-library/react';
2+
import {render, waitFor} from '@testing-library/react';
33

44
import {MdxPortals} from '../MdxPortals';
55

@@ -10,7 +10,67 @@ jest.mock('@diplodoc/mdx-extension', () => ({
1010
const mockedUseMdx = jest.mocked(useMdx);
1111

1212
describe('MdxPortals', () => {
13-
test('should use the legacy MDX loader when nonce is not provided', () => {
13+
afterEach(() => {
14+
mockedUseMdx.mockClear();
15+
document.head.querySelectorAll('script[data-test-csp-nonce]').forEach((script) => {
16+
script.remove();
17+
});
18+
});
19+
20+
test('should discover the CSP nonce from an existing script', async () => {
21+
const script = document.createElement('script');
22+
script.dataset.testCspNonce = 'true';
23+
script.nonce = 'page-nonce';
24+
document.head.appendChild(script);
25+
26+
render(
27+
<MdxPortals
28+
refCtr={{current: document.createElement('div')}}
29+
html=""
30+
mdxArtifacts={{idMdx: {}, idTagName: {}}}
31+
/>,
32+
);
33+
34+
await waitFor(() => {
35+
expect(mockedUseMdx).toHaveBeenLastCalledWith(
36+
expect.objectContaining({
37+
idMdxComponentLoader: expect.objectContaining({
38+
isSuccess: true,
39+
nonce: 'page-nonce',
40+
}),
41+
}),
42+
);
43+
});
44+
});
45+
46+
test('should use the explicitly provided nonce', async () => {
47+
const script = document.createElement('script');
48+
script.dataset.testCspNonce = 'true';
49+
script.nonce = 'page-nonce';
50+
document.head.appendChild(script);
51+
52+
render(
53+
<MdxPortals
54+
refCtr={{current: document.createElement('div')}}
55+
html=""
56+
mdxArtifacts={{idMdx: {}, idTagName: {}}}
57+
nonce="explicit-nonce"
58+
/>,
59+
);
60+
61+
await waitFor(() => {
62+
expect(mockedUseMdx).toHaveBeenLastCalledWith(
63+
expect.objectContaining({
64+
idMdxComponentLoader: expect.objectContaining({
65+
isSuccess: true,
66+
nonce: 'explicit-nonce',
67+
}),
68+
}),
69+
);
70+
});
71+
});
72+
73+
test('should use the legacy MDX loader when nonce is not available', () => {
1474
render(
1575
<MdxPortals
1676
refCtr={{current: document.createElement('div')}}

src/components/atoms/MarkdownRenderer/__tests__/asyncExecuteCode.unit.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ describe('asyncExecuteCode', () => {
1414
});
1515

1616
test('should execute code through a script using the provided nonce', async () => {
17+
const pageScript = document.createElement('script');
18+
pageScript.nonce = 'page-nonce';
19+
document.head.appendChild(pageScript);
20+
1721
const executor = () => 'result';
1822
let scriptSource = '';
1923

@@ -39,6 +43,34 @@ describe('asyncExecuteCode', () => {
3943
expect((window as unknown as Record<string, unknown>)[HANDLERS_KEY]).toBeUndefined();
4044
});
4145

46+
test('should discover the nonce from an existing script', async () => {
47+
const emptyNonceScript = document.createElement('script');
48+
emptyNonceScript.setAttribute('nonce', '');
49+
document.head.appendChild(emptyNonceScript);
50+
51+
const pageScript = document.createElement('script');
52+
pageScript.nonce = 'page-nonce';
53+
document.head.appendChild(pageScript);
54+
55+
const executor = () => 'result';
56+
57+
jest.spyOn(document.head, 'appendChild').mockImplementation((node: Node) => {
58+
const script = node as HTMLScriptElement;
59+
const handlers = (window as unknown as Record<string, unknown>)[HANDLERS_KEY] as Record<
60+
string,
61+
ExecutionHandler<typeof executor>
62+
>;
63+
const [handler] = Object.values(handlers);
64+
65+
expect(script.nonce).toBe('page-nonce');
66+
handler.resolve(executor);
67+
68+
return node;
69+
});
70+
71+
await expect(asyncExecuteCode('return "result";')).resolves.toBe(executor);
72+
});
73+
4274
test('should reject and clean up when the script is not executed', async () => {
4375
const blockedScripts = document.createElement('div');
4476

src/components/atoms/MarkdownRenderer/asyncExecuteCode.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,34 @@ interface ExecutionHandler<T> {
77

88
type ExecutionHandlers = Record<string, ExecutionHandler<unknown>>;
99

10+
export function resolveCspNonce(nonce?: string) {
11+
if (nonce || typeof document === 'undefined') {
12+
return nonce;
13+
}
14+
15+
for (const script of document.querySelectorAll<HTMLScriptElement>('script[nonce]')) {
16+
if (script.nonce) {
17+
return script.nonce;
18+
}
19+
}
20+
21+
return undefined;
22+
}
23+
1024
/** Executes code through a nonce-aware script without using `eval` or `new Function`. */
1125
export async function asyncExecuteCode<T>(code: string, nonce?: string): Promise<T> {
1226
const globalScope = window as unknown as Record<string, unknown>;
1327
const handlers = (globalScope[HANDLERS_KEY] ??= {}) as ExecutionHandlers;
28+
const resolvedNonce = resolveCspNonce(nonce);
1429

1530
let id: string;
1631
do {
1732
id = Math.random().toString(36).substring(2);
1833
} while (handlers[id]);
1934

2035
const script = document.createElement('script');
21-
if (nonce) {
22-
script.setAttribute('nonce', nonce);
36+
if (resolvedNonce) {
37+
script.setAttribute('nonce', resolvedNonce);
2338
}
2439

2540
const promise = new Promise<T>((resolve, reject) => {

0 commit comments

Comments
 (0)