feat: apply per-response development nonces after Vite finishes rendering HTML

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Matthew Meszaros
2026-10-06 05:57:08 +00:00
co-authored by Devin AI
parent 589bc823ed
commit 34fbbc3c54
2 changed files with 35 additions and 9 deletions
+7 -8
View File
@@ -40,6 +40,13 @@ export function dashboardCspPlugin(env: Record<string, string>): Plugin {
return {
name: "warmbly:dashboard-csp",
configureServer(server) {
const transformHtml = server.transformIndexHtml.bind(server);
server.transformIndexHtml = async (...args) => {
const request = requests.getStore();
const html = await transformHtml(...args);
// Vite adds nonce attributes after user HTML hooks have finished.
return request ? html.replaceAll(noncePlaceholder, request.nonce) : html;
};
server.middlewares.use((request, response, next) => {
const nonce = randomBytes(24).toString("base64");
const preview = request.url?.split("?")[0] === "/mail-preview.html";
@@ -49,14 +56,6 @@ export function dashboardCspPlugin(env: Record<string, string>): Plugin {
requests.run({ nonce }, next);
});
},
transformIndexHtml: {
order: "post",
handler(html) {
const request = requests.getStore();
if (!request) return html;
return html.replaceAll(noncePlaceholder, request.nonce);
},
},
generateBundle: { order: "post", handler(_options, bundle) {
const index = bundle["index.html"];
if (!index || index.type !== "asset") throw new Error("CSP requires the dashboard HTML asset");
+28 -1
View File
@@ -6,13 +6,40 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { build } from "vite";
import { build, createServer } from "vite";
import { configuredSources, dashboardCspPlugin, inlineHashes, noncePlaceholder, origin } from "../csp";
import { dashboardCsp, mailPreviewCsp } from "../securityHeaders";
const directory = process.cwd();
describe("dashboard CSP", () => {
it("serves final Vite HTML with a matching fresh nonce for concurrent requests", async () => {
const temporary = mkdtempSync(path.join(tmpdir(), "warmbly-csp-dev-"));
const server = await createServer({ root: temporary, configFile: false, html: { cspNonce: noncePlaceholder }, plugins: [dashboardCspPlugin({})], server: { host: "127.0.0.1", port: 0 }, logLevel: "silent" });
try {
writeFileSync(path.join(temporary, "index.html"), '<html><head><script>window.theme="dark";</script><style>body { margin: 0; }</style></head><body></body></html>');
await server.listen();
const address = server.httpServer!.address();
if (!address || typeof address === "string") throw new Error("Expected local HTTP port");
const results = await Promise.all([1, 2].map(async () => {
const response = await fetch(`http://127.0.0.1:${address.port}/app/contacts`);
expect(response.status).toBe(200);
const nonce = response.headers.get("Content-Security-Policy")?.match(/'nonce-([^']+)'/)?.[1];
const html = await response.text();
expect(nonce).toBeTruthy();
expect(html).not.toContain(noncePlaceholder);
const nonces = [...html.matchAll(/nonce="([^"]+)"/g)].map((match) => match[1]);
expect(nonces.length).toBeGreaterThanOrEqual(3);
expect(new Set(nonces)).toEqual(new Set([nonce]));
return nonce;
}));
expect(results[0]).not.toBe(results[1]);
} finally {
await server.close();
rmSync(temporary, { recursive: true, force: true });
}
});
it("generates hashes from Vite's final HTML asset", async () => {
const temporary = mkdtempSync(path.join(tmpdir(), "warmbly-csp-build-"));
try {