Implement SSR stream nonce injection as workaround for React #24883
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Readable } from "node:stream";
|
||||
import { wrapSsrStreamWithNonce } from "./nonce-stream-transform.js";
|
||||
|
||||
function streamToString(stream: NodeJS.ReadableStream): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
stream.on("data", (chunk: Buffer) => chunks.push(Buffer.from(chunk)));
|
||||
stream.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
|
||||
stream.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function createStream(chunks: string[]): NodeJS.ReadableStream {
|
||||
return new Readable({
|
||||
read() {
|
||||
for (const chunk of chunks) {
|
||||
this.push(chunk);
|
||||
}
|
||||
this.push(null);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const NONCE = "test-nonce-abc123";
|
||||
|
||||
describe("wrapSsrStreamWithNonce", () => {
|
||||
it("injects nonce on bare <script> tags", async () => {
|
||||
const input = createStream(["<html><head><script>console.log('hi')</script></head></html>"]);
|
||||
const output = wrapSsrStreamWithNonce(input, NONCE);
|
||||
const result = await streamToString(output);
|
||||
expect(result).toContain(`<script nonce="${NONCE}">`);
|
||||
});
|
||||
|
||||
it("injects nonce on <script src> tags", async () => {
|
||||
const input = createStream([`<script src="/app.js"></script>`]);
|
||||
const output = wrapSsrStreamWithNonce(input, NONCE);
|
||||
const result = await streamToString(output);
|
||||
expect(result).toContain(`<script nonce="${NONCE}" src="/app.js">`);
|
||||
});
|
||||
|
||||
it("injects nonce on <script type='module'> tags", async () => {
|
||||
const input = createStream([`<script type="module" src="/app.js"></script>`]);
|
||||
const output = wrapSsrStreamWithNonce(input, NONCE);
|
||||
const result = await streamToString(output);
|
||||
expect(result).toContain(`<script nonce="${NONCE}" type="module" src="/app.js">`);
|
||||
});
|
||||
|
||||
it("does not double-inject on scripts that already have nonce", async () => {
|
||||
const input = createStream([`<script nonce="existing">code</script>`]);
|
||||
const output = wrapSsrStreamWithNonce(input, NONCE);
|
||||
const result = await streamToString(output);
|
||||
expect(result).toBe(`<script nonce="existing">code</script>`);
|
||||
expect(result).not.toContain(NONCE);
|
||||
});
|
||||
|
||||
it("handles multiple script tags in one chunk", async () => {
|
||||
const input = createStream([
|
||||
`<script>a</script><script nonce="x">b</script><script src="/c.js"></script>`,
|
||||
]);
|
||||
const output = wrapSsrStreamWithNonce(input, NONCE);
|
||||
const result = await streamToString(output);
|
||||
expect(result).toContain(`<script nonce="${NONCE}">a</script>`);
|
||||
expect(result).toContain(`<script nonce="x">b</script>`);
|
||||
expect(result).toContain(`<script nonce="${NONCE}" src="/c.js">`);
|
||||
});
|
||||
|
||||
it("handles chunks split mid-tag", async () => {
|
||||
const input = createStream([
|
||||
`<html><scr`,
|
||||
`ipt src="/app.js"></script></html>`,
|
||||
]);
|
||||
const output = wrapSsrStreamWithNonce(input, NONCE);
|
||||
const result = await streamToString(output);
|
||||
expect(result).toContain(`<script nonce="${NONCE}" src="/app.js">`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Transform } from "node:stream";
|
||||
|
||||
/**
|
||||
* Regex matching `<script` opening tags.
|
||||
* Captures everything between `<script` and `>` to check for existing nonce.
|
||||
*/
|
||||
const SCRIPT_OPEN_RE = /<script(\s[^>]*)?\s*>/gi;
|
||||
|
||||
/**
|
||||
* Wraps an SSR HTML stream to inject `nonce="..."` on every `<script>` tag
|
||||
* that doesn't already have a nonce attribute.
|
||||
*
|
||||
* Workaround for React issue #24883: renderToPipeableStream({ nonce }) only
|
||||
* applies the nonce to inline bootstrapScriptContent, not to external
|
||||
* bootstrapScripts src URLs.
|
||||
*/
|
||||
export function wrapSsrStreamWithNonce(
|
||||
stream: NodeJS.ReadableStream,
|
||||
nonce: string,
|
||||
): NodeJS.ReadableStream {
|
||||
let buffer = "";
|
||||
|
||||
const transform = new Transform({
|
||||
decodeStrings: false,
|
||||
|
||||
transform(chunk: Buffer | string, _encoding, callback) {
|
||||
buffer += typeof chunk === "string" ? chunk : chunk.toString("utf-8");
|
||||
|
||||
// Keep a trailing partial `<script...` that might be split across chunks.
|
||||
// Find the last `<scr` that might be an incomplete tag.
|
||||
const lastOpenBracket = buffer.lastIndexOf("<scr");
|
||||
if (lastOpenBracket !== -1 && !buffer.includes(">", lastOpenBracket)) {
|
||||
// Incomplete tag — hold the partial in the buffer
|
||||
const ready = buffer.slice(0, lastOpenBracket);
|
||||
buffer = buffer.slice(lastOpenBracket);
|
||||
this.push(injectNonce(ready, nonce));
|
||||
} else {
|
||||
// No incomplete tag — flush everything
|
||||
this.push(injectNonce(buffer, nonce));
|
||||
buffer = "";
|
||||
}
|
||||
|
||||
callback();
|
||||
},
|
||||
|
||||
flush(callback) {
|
||||
if (buffer.length > 0) {
|
||||
this.push(injectNonce(buffer, nonce));
|
||||
buffer = "";
|
||||
}
|
||||
callback();
|
||||
},
|
||||
});
|
||||
|
||||
stream.pipe(transform);
|
||||
return transform;
|
||||
}
|
||||
|
||||
function injectNonce(html: string, nonce: string): string {
|
||||
return html.replace(SCRIPT_OPEN_RE, (match, attrs: string | undefined) => {
|
||||
const attrStr = attrs ?? "";
|
||||
// Don't inject if nonce already present
|
||||
if (/\bnonce\s*=/i.test(attrStr)) {
|
||||
return match;
|
||||
}
|
||||
return `<script nonce="${nonce}"${attrStr}>`;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user