Skip to content

Commit 17c4120

Browse files
committed
feat(security): enhance security headers with nonce support and middleware integration
1 parent d99b1ed commit 17c4120

6 files changed

Lines changed: 305 additions & 74 deletions

File tree

apps/web/functions/[[path]].ts

Lines changed: 15 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,18 @@
11
import { initializeApiBaseUrl } from "../src/config/api";
22
import { render } from "../src/entry-server";
3-
import {
4-
applySecurityHeadersToResponse,
5-
shouldApplySecurityHeaders,
6-
} from "../src/utils/security-headers";
3+
import { injectNonceIntoHTML } from "../src/utils/security-headers";
74

8-
interface Env {
9-
ASSETS: Fetcher;
10-
VITE_API_HOST?: string;
5+
export interface Data extends Record<string, unknown> {
6+
nonce: string;
117
}
128

13-
/**
14-
* Apply security headers to responses
15-
*/
16-
function addSecurityHeaders(response: Response): Response {
17-
const contentType = response.headers.get("content-type");
18-
if (shouldApplySecurityHeaders(contentType, response.status)) {
19-
return applySecurityHeadersToResponse(response, {
20-
environment: "production",
21-
enforceHttps: true,
22-
});
23-
}
24-
return response;
9+
export interface Env {
10+
ASSETS: Fetcher;
11+
VITE_API_HOST?: string;
12+
NONCE: string;
2513
}
2614

27-
export const onRequest: PagesFunction<Env> = async (context) => {
15+
export const onRequest: PagesFunction<Env, any, Data> = async (context) => {
2816
const { request, env } = context;
2917
initializeApiBaseUrl(env.VITE_API_HOST);
3018

@@ -50,19 +38,22 @@ export const onRequest: PagesFunction<Env> = async (context) => {
5038
const template = await indexHtmlResponse.text();
5139

5240
const rendered = await render(request.clone() as unknown as Request);
53-
const html = template
41+
let html = template
5442
.replace(`<!--app-head-->`, rendered.headHtml ?? "")
5543
.replace(`<!--app-html-->`, "");
5644

45+
// Inject nonce into script tags
46+
html = injectNonceIntoHTML(html, context.data.nonce);
47+
5748
const response = new Response(html, {
5849
headers: { "Content-Type": "text/html" },
5950
status: 200,
6051
});
6152

62-
return addSecurityHeaders(response);
53+
return response;
6354
} catch (e: any) {
6455
if (e instanceof Response) {
65-
return addSecurityHeaders(e);
56+
return e;
6657
}
6758
console.error("SSR Function Error:", e.stack || e);
6859
try {
@@ -83,6 +74,6 @@ export const onRequest: PagesFunction<Env> = async (context) => {
8374
}
8475
);
8576

86-
return addSecurityHeaders(errorResponse);
77+
return errorResponse;
8778
}
8879
};

apps/web/functions/_middleware.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import {
2+
addSecurityHeaders,
3+
generateNonce,
4+
} from "../src/utils/security-headers";
5+
import type { Data, Env } from "./[[path]].ts";
6+
7+
/**
8+
* Security middleware that applies security headers to all responses
9+
* and injects nonce into HTML responses
10+
*/
11+
async function securityMiddleware(context: EventContext<Env, any, Data>) {
12+
// Generate nonce for this request
13+
const nonce = generateNonce();
14+
15+
// Store nonce in context for use by the main handler
16+
context.data.nonce = nonce;
17+
18+
// Get the response from the next handler
19+
const response = await context.next();
20+
21+
// Apply security headers with nonce configuration
22+
return addSecurityHeaders(response, {
23+
nonce,
24+
environment:
25+
process.env.NODE_ENV === "development" ? "development" : "production",
26+
enforceHttps: true,
27+
});
28+
}
29+
30+
export const onRequest = [securityMiddleware];

apps/web/server.ts

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@ import express from "express";
44
import { createServer } from "vite";
55

66
import {
7-
applySecurityHeaders,
8-
shouldApplySecurityHeaders,
7+
addSecurityHeaders,
8+
generateNonce,
9+
injectNonceIntoHTML,
910
} from "./src/utils/security-headers";
1011

1112
const port = process.env.PORT || 3000;
@@ -18,27 +19,29 @@ app.use((_, res, next) => {
1819
const originalJson = res.json;
1920
const originalSend = res.send;
2021

22+
// Store nonce on the response object for later use
23+
res.locals.nonce = generateNonce();
24+
const environment =
25+
(process.env.NODE_ENV as "development" | "production") ?? "development";
26+
2127
res.send = function (body) {
22-
addSecurityHeaders(this);
28+
addSecurityHeaders(this, {
29+
environment,
30+
nonce: res.locals.nonce,
31+
enforceHttps: false,
32+
});
2333
return originalSend.call(this, body);
2434
};
2535

2636
res.json = function (obj) {
27-
addSecurityHeaders(this);
37+
addSecurityHeaders(this, {
38+
environment,
39+
nonce: res.locals.nonce,
40+
enforceHttps: false,
41+
});
2842
return originalJson.call(this, obj);
2943
};
3044

31-
function addSecurityHeaders(response: express.Response) {
32-
const contentType = response.get("Content-Type");
33-
if (shouldApplySecurityHeaders(contentType, response.statusCode)) {
34-
applySecurityHeaders(response, {
35-
environment:
36-
process.env.NODE_ENV === "production" ? "production" : "development",
37-
enforceHttps: process.env.NODE_ENV === "production",
38-
});
39-
}
40-
}
41-
4245
next();
4346
});
4447

@@ -69,10 +72,13 @@ app.use("*all", async (req, res) => {
6972

7073
const rendered = await render(fetchRequest);
7174

72-
const html = template
75+
let html = template
7376
.replace(`<!--app-head-->`, rendered.headHtml ?? "")
7477
.replace(`<!--app-html-->`, "");
7578

79+
// Inject nonce into script tags
80+
html = injectNonceIntoHTML(html, res.locals.nonce);
81+
7682
res.status(200).set({ "Content-Type": "text/html" }).send(html);
7783
} catch (e: any) {
7884
vite?.ssrFixStacktrace(e);
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { generateNonce, injectNonceIntoHTML } from "./security-headers";
4+
5+
describe("Security headers functionality", () => {
6+
describe("generateNonce", () => {
7+
it("should generate a unique nonce each time", () => {
8+
const nonce1 = generateNonce();
9+
const nonce2 = generateNonce();
10+
11+
expect(nonce1).toBeTruthy();
12+
expect(nonce2).toBeTruthy();
13+
expect(nonce1).not.toBe(nonce2);
14+
});
15+
16+
it("should generate a base64-encoded string", () => {
17+
const nonce = generateNonce();
18+
19+
// Base64 string should only contain valid base64 characters
20+
expect(nonce).toMatch(/^[A-Za-z0-9+/]+=*$/);
21+
});
22+
});
23+
24+
describe("injectNonceIntoHTML", () => {
25+
const testNonce = "test-nonce-123";
26+
27+
it("should inject nonce into simple script tag", () => {
28+
const html = '<script src="/test.js"></script>';
29+
const result = injectNonceIntoHTML(html, testNonce);
30+
31+
expect(result).toBe(
32+
`<script nonce="${testNonce}" src="/test.js"></script>`
33+
);
34+
});
35+
36+
it("should inject nonce into script tag with type module", () => {
37+
const html = '<script type="module" src="/test.js"></script>';
38+
const result = injectNonceIntoHTML(html, testNonce);
39+
40+
expect(result).toBe(
41+
`<script nonce="${testNonce}" type="module" src="/test.js"></script>`
42+
);
43+
});
44+
45+
it("should inject nonce into script tag with multiple attributes", () => {
46+
const html =
47+
'<script type="module" crossorigin src="/assets/index-CeBMaJSY.js"></script>';
48+
const result = injectNonceIntoHTML(html, testNonce);
49+
50+
expect(result).toBe(
51+
`<script nonce="${testNonce}" type="module" crossorigin src="/assets/index-CeBMaJSY.js"></script>`
52+
);
53+
});
54+
55+
it("should inject nonce into inline script tag", () => {
56+
const html = '<script type="module">console.log("test");</script>';
57+
const result = injectNonceIntoHTML(html, testNonce);
58+
59+
expect(result).toBe(
60+
`<script nonce="${testNonce}" type="module">console.log("test");</script>`
61+
);
62+
});
63+
64+
it("should inject nonce into complex inline script", () => {
65+
const html = `<script type="module">import { injectIntoGlobalHook } from "/@react-refresh";
66+
injectIntoGlobalHook(window);
67+
window.$RefreshReg$ = () => {};
68+
window.$RefreshSig$ = () => (type) => type;</script>`;
69+
const result = injectNonceIntoHTML(html, testNonce);
70+
71+
expect(result).toContain(`<script nonce="${testNonce}" type="module">`);
72+
});
73+
74+
it("should not modify script tags that already have nonce", () => {
75+
const html = '<script nonce="existing-nonce" src="/test.js"></script>';
76+
const result = injectNonceIntoHTML(html, testNonce);
77+
78+
expect(result).toBe(html); // Should remain unchanged
79+
});
80+
81+
it("should handle multiple script tags correctly", () => {
82+
const html = `
83+
<script src="/test1.js"></script>
84+
<script nonce="existing" src="/test2.js"></script>
85+
<script type="module" src="/test3.js"></script>
86+
`;
87+
const result = injectNonceIntoHTML(html, testNonce);
88+
89+
expect(result).toContain(
90+
`<script nonce="${testNonce}" src="/test1.js"></script>`
91+
);
92+
expect(result).toContain(
93+
`<script nonce="existing" src="/test2.js"></script>`
94+
);
95+
expect(result).toContain(
96+
`<script nonce="${testNonce}" type="module" src="/test3.js"></script>`
97+
);
98+
});
99+
100+
it("should handle the actual HTML from development mode", () => {
101+
const html = `<!doctype html>
102+
<html lang="en">
103+
<head>
104+
<script type="module">import { injectIntoGlobalHook } from "/@react-refresh";
105+
injectIntoGlobalHook(window);
106+
window.$RefreshReg$ = () => {};
107+
window.$RefreshSig$ = () => (type) => type;</script>
108+
109+
<script type="module" src="/@vite/client"></script>
110+
111+
<meta charset="UTF-8" />
112+
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
113+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
114+
<title>Dafthunk</title><meta name="description" content="Workflow execution platform." data-managed-tag="true"/><meta property="og:title" content="Dafthunk" data-managed-tag="true"/><meta property="og:description" content="Workflow execution platform." data-managed-tag="true"/><meta property="og:type" content="website" data-managed-tag="true"/><meta name="twitter:card" content="summary_large_image" data-managed-tag="true"/><meta name="twitter:title" content="Dafthunk" data-managed-tag="true"/><meta name="twitter:description" content="Workflow execution platform." data-managed-tag="true"/>
115+
</head>
116+
<body>
117+
<div id="root"></div>
118+
<script type="module" src="/src/entry-client.tsx"></script>
119+
</body>
120+
</html>`;
121+
122+
const result = injectNonceIntoHTML(html, testNonce);
123+
124+
// Should inject nonce into all three script tags
125+
const scriptMatches = result.match(/<script[^>]*>/g);
126+
expect(scriptMatches).toHaveLength(3);
127+
128+
// All script tags should have the nonce
129+
scriptMatches?.forEach((tag) => {
130+
expect(tag).toContain(`nonce="${testNonce}"`);
131+
});
132+
});
133+
134+
it("should not affect non-script tags", () => {
135+
const html = `
136+
<div>content</div>
137+
<script src="/test.js"></script>
138+
<link rel="stylesheet" href="/style.css">
139+
`;
140+
const result = injectNonceIntoHTML(html, testNonce);
141+
142+
expect(result).toContain("<div>content</div>");
143+
expect(result).toContain('<link rel="stylesheet" href="/style.css">');
144+
expect(result).toContain(
145+
`<script nonce="${testNonce}" src="/test.js"></script>`
146+
);
147+
});
148+
});
149+
});

0 commit comments

Comments
 (0)