@mandujs/core 0.21.0 → 0.22.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +101 -69
- package/src/auth/__tests__/login.test.ts +419 -0
- package/src/auth/__tests__/password.test.ts +122 -0
- package/src/auth/__tests__/reset.test.ts +296 -0
- package/src/auth/__tests__/tokens.test.ts +274 -0
- package/src/auth/__tests__/verification.test.ts +274 -0
- package/src/auth/index.ts +76 -0
- package/src/auth/login.ts +225 -0
- package/src/auth/password.ts +120 -0
- package/src/auth/reset.ts +243 -0
- package/src/auth/tokens.ts +612 -0
- package/src/auth/verification.ts +253 -0
- package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -0
- package/src/bundler/__tests__/cold-start.test.ts +504 -0
- package/src/bundler/__tests__/csp-nonce.test.ts +278 -0
- package/src/bundler/__tests__/dev-reliability.test.ts +619 -0
- package/src/bundler/__tests__/extended-watch.test.ts +710 -0
- package/src/bundler/__tests__/fast-refresh.test.ts +596 -0
- package/src/bundler/__tests__/hdr.test.ts +353 -0
- package/src/bundler/__tests__/hmr-client.test.ts +532 -0
- package/src/bundler/__tests__/manifest-schema.test.ts +266 -0
- package/src/bundler/__tests__/prod-smoke.test.ts +138 -0
- package/src/bundler/__tests__/slot-dispatch.test.ts +573 -0
- package/src/bundler/__tests__/url-cap-and-slot-regex.test.ts +286 -0
- package/src/bundler/__tests__/vendor-cache.test.ts +455 -0
- package/src/bundler/build.test.ts +8 -1
- package/src/bundler/build.ts +310 -18
- package/src/bundler/css.ts +326 -323
- package/src/bundler/dev.ts +1611 -59
- package/src/bundler/fast-refresh-plugin.ts +307 -0
- package/src/bundler/hmr-types.ts +252 -0
- package/src/bundler/manifest-schema.ts +301 -0
- package/src/bundler/safe-build.test.ts +128 -0
- package/src/bundler/safe-build.ts +77 -0
- package/src/bundler/scenario-matrix.ts +229 -0
- package/src/bundler/types.ts +11 -0
- package/src/bundler/vendor-cache-types.ts +130 -0
- package/src/bundler/vendor-cache.ts +526 -0
- package/src/client/router.ts +214 -56
- package/src/db/__tests__/db.test.ts +485 -0
- package/src/db/index.ts +513 -0
- package/src/db/migrations/__tests__/runner.test.ts +661 -0
- package/src/db/migrations/history-table.ts +345 -0
- package/src/db/migrations/lock.ts +269 -0
- package/src/db/migrations/runner.ts +633 -0
- package/src/desktop/__tests__/smoke.test.ts +100 -0
- package/src/desktop/__tests__/window.test.ts +172 -0
- package/src/desktop/__tests__/worker.test.ts +266 -0
- package/src/desktop/index.ts +43 -0
- package/src/desktop/types.ts +158 -0
- package/src/desktop/window.ts +492 -0
- package/src/desktop/worker.ts +180 -0
- package/src/email/__tests__/email.test.ts +355 -0
- package/src/email/index.ts +282 -0
- package/src/email/resend.ts +163 -0
- package/src/email/smtp.ts +64 -0
- package/src/filling/__tests__/session-sqlite.test.ts +454 -0
- package/src/filling/context.ts +72 -78
- package/src/filling/cookie-codec.ts +299 -0
- package/src/filling/deps.ts +25 -1
- package/src/filling/filling.ts +28 -3
- package/src/filling/session-sqlite.ts +617 -0
- package/src/filling/session.ts +265 -216
- package/src/guard/decision-memory.test.ts +52 -22
- package/src/id/__tests__/id.test.ts +120 -0
- package/src/id/index.ts +105 -0
- package/src/kitchen/index.ts +2 -2
- package/src/kitchen/kitchen-handler.ts +86 -0
- package/src/kitchen/stream/activity-sse.ts +2 -1
- package/src/middleware/csrf.ts +328 -0
- package/src/middleware/index.ts +40 -0
- package/src/middleware/oauth/__tests__/oauth.test.ts +574 -0
- package/src/middleware/oauth/index.ts +505 -0
- package/src/middleware/oauth/providers.ts +115 -0
- package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -0
- package/src/middleware/rate-limit/index.ts +522 -0
- package/src/middleware/rate-limit/sqlite-store.ts +382 -0
- package/src/middleware/secure/__tests__/secure.test.ts +360 -0
- package/src/middleware/secure/csp.ts +193 -0
- package/src/middleware/secure/index.ts +417 -0
- package/src/middleware/session.ts +174 -0
- package/src/observability/event-bus.ts +81 -79
- package/src/paths.ts +37 -0
- package/src/perf/hmr-markers.ts +215 -0
- package/src/perf/index.ts +104 -0
- package/src/resource/__tests__/generator.test.ts +603 -2
- package/src/resource/ddl/__tests__/diff.test.ts +639 -0
- package/src/resource/ddl/__tests__/emit.test.ts +799 -0
- package/src/resource/ddl/__tests__/snapshot.test.ts +499 -0
- package/src/resource/ddl/diff.ts +392 -0
- package/src/resource/ddl/emit.ts +548 -0
- package/src/resource/ddl/persistence-types.ts +218 -0
- package/src/resource/ddl/snapshot.ts +447 -0
- package/src/resource/ddl/type-map.ts +223 -0
- package/src/resource/ddl/types.ts +232 -0
- package/src/resource/generator-repo.ts +610 -0
- package/src/resource/generator-schema.ts +476 -0
- package/src/resource/generator.ts +117 -1
- package/src/resource/index.ts +17 -1
- package/src/resource/schema.ts +30 -0
- package/src/router/fs-scanner.ts +3 -0
- package/src/runtime/__tests__/error-boundary-redaction.test.ts +141 -0
- package/src/runtime/__tests__/hdr-client.test.ts +223 -0
- package/src/runtime/__tests__/http-errors.test.ts +117 -0
- package/src/runtime/__tests__/not-found.test.ts +152 -0
- package/src/runtime/boundary.tsx +21 -1
- package/src/runtime/fast-refresh-runtime.ts +322 -0
- package/src/runtime/fast-refresh-types.ts +128 -0
- package/src/runtime/hmr-client.ts +409 -0
- package/src/runtime/http-errors.ts +113 -0
- package/src/runtime/index.ts +6 -0
- package/src/runtime/logger.ts +678 -677
- package/src/runtime/not-found.ts +93 -0
- package/src/runtime/redirect.ts +133 -0
- package/src/runtime/server.ts +518 -20
- package/src/runtime/ssr.ts +340 -10
- package/src/runtime/streaming-ssr.ts +222 -19
- package/src/scheduler/__tests__/scheduler.test.ts +514 -0
- package/src/scheduler/index.ts +343 -0
- package/src/storage/s3/__tests__/s3.test.ts +479 -0
- package/src/storage/s3/index.ts +412 -0
- package/src/testing/index.ts +58 -0
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secure headers middleware tests
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* - `buildCsp` directive merging + nonce interpolation
|
|
6
|
+
* - `secure` middleware plugin (beforeHandle/afterHandle) applied to a real
|
|
7
|
+
* `ManduContext` + `Response`, matching how the lifecycle would drive it
|
|
8
|
+
*
|
|
9
|
+
* Fixture style mirrors `tests/middleware/csrf.test.ts` and
|
|
10
|
+
* `src/middleware/oauth/__tests__/oauth.test.ts`:
|
|
11
|
+
* - Real `Request` / `Response` / `ManduContext` — no mocks
|
|
12
|
+
* - Middleware is executed manually (before → handler → after) to simulate
|
|
13
|
+
* the Elysia-style lifecycle pipeline
|
|
14
|
+
*/
|
|
15
|
+
import { describe, it, expect } from "bun:test";
|
|
16
|
+
import {
|
|
17
|
+
secure,
|
|
18
|
+
buildCsp,
|
|
19
|
+
applySecureHeadersToResponse,
|
|
20
|
+
DEFAULT_CSP_DIRECTIVES,
|
|
21
|
+
type SecureMiddlewareOptions,
|
|
22
|
+
type CspOptions,
|
|
23
|
+
} from "../index";
|
|
24
|
+
import { ManduContext } from "../../../filling/context";
|
|
25
|
+
import type { MiddlewarePlugin } from "../../../filling/filling";
|
|
26
|
+
|
|
27
|
+
// ========== Helpers ==========
|
|
28
|
+
|
|
29
|
+
function makeReq(
|
|
30
|
+
url: string,
|
|
31
|
+
init: RequestInit & { cookie?: string } = {}
|
|
32
|
+
): Request {
|
|
33
|
+
const { cookie, headers: rawHeaders, ...rest } = init;
|
|
34
|
+
const headers = new Headers(rawHeaders as HeadersInit | undefined);
|
|
35
|
+
if (cookie) headers.set("cookie", cookie);
|
|
36
|
+
return new Request(url, { ...rest, headers });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function makeCtx(req: Request): ManduContext {
|
|
40
|
+
return new ManduContext(req);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Drive the full plugin pipeline:
|
|
45
|
+
* 1. beforeHandle (may mutate ctx)
|
|
46
|
+
* 2. handler produces a Response
|
|
47
|
+
* 3. afterHandle (returns possibly-wrapped Response)
|
|
48
|
+
*/
|
|
49
|
+
async function runPlugin(
|
|
50
|
+
plugin: MiddlewarePlugin,
|
|
51
|
+
ctx: ManduContext,
|
|
52
|
+
handler: (ctx: ManduContext) => Response | Promise<Response>
|
|
53
|
+
): Promise<Response> {
|
|
54
|
+
if (plugin.beforeHandle) {
|
|
55
|
+
const early = await plugin.beforeHandle(ctx);
|
|
56
|
+
if (early instanceof Response) return early;
|
|
57
|
+
}
|
|
58
|
+
let res = await handler(ctx);
|
|
59
|
+
if (plugin.afterHandle) {
|
|
60
|
+
res = await plugin.afterHandle(ctx, res);
|
|
61
|
+
}
|
|
62
|
+
return res;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ============================================
|
|
66
|
+
// buildCsp
|
|
67
|
+
// ============================================
|
|
68
|
+
|
|
69
|
+
describe("buildCsp", () => {
|
|
70
|
+
it("emits default directives including default-src 'self'", () => {
|
|
71
|
+
const built = buildCsp();
|
|
72
|
+
expect(built.name).toBe("Content-Security-Policy");
|
|
73
|
+
expect(built.header).toContain("default-src 'self'");
|
|
74
|
+
expect(built.header).toContain("object-src 'none'");
|
|
75
|
+
expect(built.header).toContain("frame-ancestors 'none'");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("interpolates {NONCE} into script-src when nonce is enabled", () => {
|
|
79
|
+
const built = buildCsp({ nonce: true });
|
|
80
|
+
expect(built.nonce).toBeDefined();
|
|
81
|
+
expect(built.nonce!.length).toBeGreaterThan(0);
|
|
82
|
+
// The nonce should appear as part of a `'nonce-...'` token in script-src.
|
|
83
|
+
const scriptSrcMatch = built.header.match(/script-src ([^;]+)/);
|
|
84
|
+
expect(scriptSrcMatch).not.toBeNull();
|
|
85
|
+
expect(scriptSrcMatch![1]).toContain(`'nonce-${built.nonce}'`);
|
|
86
|
+
// Placeholder literal should NOT leak through.
|
|
87
|
+
expect(built.header).not.toContain("{NONCE}");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("reuses the same nonce across script-src and style-src", () => {
|
|
91
|
+
const built = buildCsp({ nonce: true });
|
|
92
|
+
const nonce = built.nonce!;
|
|
93
|
+
const scriptMatch = built.header.match(/script-src [^;]*'nonce-([^']+)'/);
|
|
94
|
+
const styleMatch = built.header.match(/style-src [^;]*'nonce-([^']+)'/);
|
|
95
|
+
expect(scriptMatch).not.toBeNull();
|
|
96
|
+
expect(styleMatch).not.toBeNull();
|
|
97
|
+
expect(scriptMatch![1]).toBe(nonce);
|
|
98
|
+
expect(styleMatch![1]).toBe(nonce);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("accepts a caller-supplied literal nonce verbatim", () => {
|
|
102
|
+
const built = buildCsp({ nonce: "mY-NoNcE_123" });
|
|
103
|
+
expect(built.nonce).toBe("mY-NoNcE_123");
|
|
104
|
+
expect(built.header).toContain("'nonce-mY-NoNcE_123'");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("flips header name to Content-Security-Policy-Report-Only when reportOnly is set", () => {
|
|
108
|
+
const built = buildCsp({ reportOnly: true });
|
|
109
|
+
expect(built.name).toBe("Content-Security-Policy-Report-Only");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("emits bare directive names for empty-array directives", () => {
|
|
113
|
+
// `upgrade-insecure-requests` is a flag-style directive in defaults.
|
|
114
|
+
const built = buildCsp();
|
|
115
|
+
// Should appear as a standalone token, not as "upgrade-insecure-requests ".
|
|
116
|
+
expect(built.header).toMatch(/\bupgrade-insecure-requests(;|$)/);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("merges caller directives by replacing default values for that key", () => {
|
|
120
|
+
const built = buildCsp({
|
|
121
|
+
directives: { "default-src": ["'self'", "https://cdn.example.com"] },
|
|
122
|
+
});
|
|
123
|
+
expect(built.header).toContain("default-src 'self' https://cdn.example.com");
|
|
124
|
+
// Other defaults must survive.
|
|
125
|
+
expect(built.header).toContain("object-src 'none'");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("preserves literal CSP keywords like 'self' and 'none' exactly", () => {
|
|
129
|
+
const built = buildCsp({
|
|
130
|
+
directives: { "script-src": ["'self'", "'none'", "'strict-dynamic'"] },
|
|
131
|
+
});
|
|
132
|
+
const match = built.header.match(/script-src ([^;]+)/);
|
|
133
|
+
expect(match).not.toBeNull();
|
|
134
|
+
expect(match![1]).toBe("'self' 'none' 'strict-dynamic'");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("normalizes camelCase directive names to kebab-case", () => {
|
|
138
|
+
const built = buildCsp({
|
|
139
|
+
directives: { scriptSrc: ["'self'"], frameAncestors: ["'self'"] },
|
|
140
|
+
});
|
|
141
|
+
// Normalized directive names should land in the header.
|
|
142
|
+
expect(built.header).toContain("script-src 'self'");
|
|
143
|
+
expect(built.header).toContain("frame-ancestors 'self'");
|
|
144
|
+
// And should NOT appear in their camelCase form.
|
|
145
|
+
expect(built.header).not.toMatch(/\bscriptSrc\b/);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("strips broken nonce tokens when nonce is disabled (defaults path)", () => {
|
|
149
|
+
const built = buildCsp({ nonce: false });
|
|
150
|
+
// No `'nonce-'` token should leak through from defaults.
|
|
151
|
+
expect(built.header).not.toContain("'nonce-");
|
|
152
|
+
// The surviving script-src must still list 'self' from defaults.
|
|
153
|
+
expect(built.header).toContain("script-src 'self'");
|
|
154
|
+
// Sanity: DEFAULT_CSP_DIRECTIVES declared a placeholder.
|
|
155
|
+
expect(DEFAULT_CSP_DIRECTIVES["script-src"]).toContain("'nonce-{NONCE}'");
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// ============================================
|
|
160
|
+
// secure() middleware — default behavior
|
|
161
|
+
// ============================================
|
|
162
|
+
|
|
163
|
+
describe("secure middleware: defaults", () => {
|
|
164
|
+
it("sets all expected default headers on the response", async () => {
|
|
165
|
+
const plugin = secure();
|
|
166
|
+
const ctx = makeCtx(makeReq("https://example.com/"));
|
|
167
|
+
const res = await runPlugin(plugin, ctx, (c) => c.ok({ ok: true }));
|
|
168
|
+
|
|
169
|
+
expect(res.headers.get("Content-Security-Policy")).toBeTruthy();
|
|
170
|
+
expect(res.headers.get("X-Frame-Options")).toBe("DENY");
|
|
171
|
+
expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff");
|
|
172
|
+
expect(res.headers.get("Referrer-Policy")).toBe("strict-origin-when-cross-origin");
|
|
173
|
+
expect(res.headers.get("Permissions-Policy")).toContain("camera=()");
|
|
174
|
+
expect(res.headers.get("X-XSS-Protection")).toBe("0");
|
|
175
|
+
// HSTS requires https — which the fixture uses.
|
|
176
|
+
expect(res.headers.get("Strict-Transport-Security")).toContain("max-age=");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("omits the CSP header when csp: false", async () => {
|
|
180
|
+
const plugin = secure({ csp: false });
|
|
181
|
+
const ctx = makeCtx(makeReq("https://example.com/"));
|
|
182
|
+
const res = await runPlugin(plugin, ctx, (c) => c.ok({ ok: true }));
|
|
183
|
+
expect(res.headers.get("Content-Security-Policy")).toBeNull();
|
|
184
|
+
expect(res.headers.get("Content-Security-Policy-Report-Only")).toBeNull();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("omits Strict-Transport-Security when hsts: false", async () => {
|
|
188
|
+
const plugin = secure({ hsts: false });
|
|
189
|
+
const ctx = makeCtx(makeReq("https://example.com/"));
|
|
190
|
+
const res = await runPlugin(plugin, ctx, (c) => c.ok({ ok: true }));
|
|
191
|
+
expect(res.headers.get("Strict-Transport-Security")).toBeNull();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("emits HSTS only for HTTPS requests", async () => {
|
|
195
|
+
const plugin = secure();
|
|
196
|
+
const httpCtx = makeCtx(makeReq("http://example.com/"));
|
|
197
|
+
const httpRes = await runPlugin(plugin, httpCtx, (c) => c.ok({ ok: true }));
|
|
198
|
+
expect(httpRes.headers.get("Strict-Transport-Security")).toBeNull();
|
|
199
|
+
|
|
200
|
+
const httpsCtx = makeCtx(makeReq("https://example.com/"));
|
|
201
|
+
const httpsRes = await runPlugin(plugin, httpsCtx, (c) => c.ok({ ok: true }));
|
|
202
|
+
expect(httpsRes.headers.get("Strict-Transport-Security")).toContain("max-age=");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("respects x-forwarded-proto: https for HSTS emission", async () => {
|
|
206
|
+
const plugin = secure();
|
|
207
|
+
const req = makeReq("http://example.com/", {
|
|
208
|
+
headers: { "x-forwarded-proto": "https" },
|
|
209
|
+
});
|
|
210
|
+
const res = await runPlugin(plugin, makeCtx(req), (c) => c.ok({ ok: true }));
|
|
211
|
+
expect(res.headers.get("Strict-Transport-Security")).toContain("max-age=15552000");
|
|
212
|
+
expect(res.headers.get("Strict-Transport-Security")).toContain("includeSubDomains");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("supports frameOptions: SAMEORIGIN", async () => {
|
|
216
|
+
const plugin = secure({ frameOptions: "SAMEORIGIN" });
|
|
217
|
+
const ctx = makeCtx(makeReq("https://example.com/"));
|
|
218
|
+
const res = await runPlugin(plugin, ctx, (c) => c.ok({ ok: true }));
|
|
219
|
+
expect(res.headers.get("X-Frame-Options")).toBe("SAMEORIGIN");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("omits X-Content-Type-Options when noSniff: false", async () => {
|
|
223
|
+
const plugin = secure({ noSniff: false });
|
|
224
|
+
const ctx = makeCtx(makeReq("https://example.com/"));
|
|
225
|
+
const res = await runPlugin(plugin, ctx, (c) => c.ok({ ok: true }));
|
|
226
|
+
expect(res.headers.get("X-Content-Type-Options")).toBeNull();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("applies a custom referrerPolicy value", async () => {
|
|
230
|
+
const plugin = secure({ referrerPolicy: "no-referrer" });
|
|
231
|
+
const ctx = makeCtx(makeReq("https://example.com/"));
|
|
232
|
+
const res = await runPlugin(plugin, ctx, (c) => c.ok({ ok: true }));
|
|
233
|
+
expect(res.headers.get("Referrer-Policy")).toBe("no-referrer");
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("emits default Permissions-Policy with deny-by-default list", async () => {
|
|
237
|
+
const plugin = secure();
|
|
238
|
+
const ctx = makeCtx(makeReq("https://example.com/"));
|
|
239
|
+
const res = await runPlugin(plugin, ctx, (c) => c.ok({ ok: true }));
|
|
240
|
+
const pp = res.headers.get("Permissions-Policy");
|
|
241
|
+
expect(pp).toBeTruthy();
|
|
242
|
+
expect(pp).toContain("camera=()");
|
|
243
|
+
expect(pp).toContain("microphone=()");
|
|
244
|
+
expect(pp).toContain("geolocation=()");
|
|
245
|
+
expect(pp).toContain("payment=()");
|
|
246
|
+
expect(pp).toContain("usb=()");
|
|
247
|
+
expect(pp).toContain("interest-cohort=()");
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("sets extra headers verbatim", async () => {
|
|
251
|
+
const plugin = secure({
|
|
252
|
+
extra: { "X-Custom-Header": "hello", "X-Build-Id": "abc123" },
|
|
253
|
+
});
|
|
254
|
+
const ctx = makeCtx(makeReq("https://example.com/"));
|
|
255
|
+
const res = await runPlugin(plugin, ctx, (c) => c.ok({ ok: true }));
|
|
256
|
+
expect(res.headers.get("X-Custom-Header")).toBe("hello");
|
|
257
|
+
expect(res.headers.get("X-Build-Id")).toBe("abc123");
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("stashes the CSP nonce on ctx.get('csp-nonce') during request", async () => {
|
|
261
|
+
const plugin = secure({ csp: { nonce: true } });
|
|
262
|
+
const ctx = makeCtx(makeReq("https://example.com/"));
|
|
263
|
+
|
|
264
|
+
let nonceDuringHandler: string | undefined;
|
|
265
|
+
const res = await runPlugin(plugin, ctx, (c) => {
|
|
266
|
+
nonceDuringHandler = c.get<string>("csp-nonce");
|
|
267
|
+
return c.ok({ ok: true });
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
expect(nonceDuringHandler).toBeDefined();
|
|
271
|
+
expect(nonceDuringHandler!.length).toBeGreaterThan(0);
|
|
272
|
+
// Header must agree with the nonce the handler saw.
|
|
273
|
+
const cspHeader = res.headers.get("Content-Security-Policy");
|
|
274
|
+
expect(cspHeader).toContain(`'nonce-${nonceDuringHandler}'`);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("preserves handler-set headers (e.g. Content-Type)", async () => {
|
|
278
|
+
const plugin = secure();
|
|
279
|
+
const ctx = makeCtx(makeReq("https://example.com/"));
|
|
280
|
+
const res = await runPlugin(plugin, ctx, () =>
|
|
281
|
+
new Response("hi", {
|
|
282
|
+
status: 200,
|
|
283
|
+
headers: { "Content-Type": "text/plain; charset=utf-8", "X-App-Header": "keep-me" },
|
|
284
|
+
})
|
|
285
|
+
);
|
|
286
|
+
expect(res.headers.get("Content-Type")).toBe("text/plain; charset=utf-8");
|
|
287
|
+
expect(res.headers.get("X-App-Header")).toBe("keep-me");
|
|
288
|
+
// Secure headers still added alongside.
|
|
289
|
+
expect(res.headers.get("X-Frame-Options")).toBe("DENY");
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("is idempotent — running the middleware twice yields the same headers", async () => {
|
|
293
|
+
const plugin = secure();
|
|
294
|
+
const ctx = makeCtx(makeReq("https://example.com/"));
|
|
295
|
+
const firstPass = await runPlugin(plugin, ctx, (c) => c.ok({ ok: true }));
|
|
296
|
+
// Apply afterHandle a second time to simulate accidental double-wiring.
|
|
297
|
+
const secondPass = await plugin.afterHandle!(ctx, firstPass);
|
|
298
|
+
// Each unique header name should have exactly one value.
|
|
299
|
+
const xfoValues = secondPass.headers.getSetCookie?.() ?? []; // no set-cookie involvement
|
|
300
|
+
expect(xfoValues.length).toBe(0);
|
|
301
|
+
expect(secondPass.headers.get("X-Frame-Options")).toBe("DENY");
|
|
302
|
+
expect(secondPass.headers.get("X-Content-Type-Options")).toBe("nosniff");
|
|
303
|
+
// Verify CSP is a single header (not duplicated).
|
|
304
|
+
const csp = secondPass.headers.get("Content-Security-Policy");
|
|
305
|
+
expect(csp).toBeTruthy();
|
|
306
|
+
// The value should not contain a semicolon-doubled policy (rough smoke check).
|
|
307
|
+
expect(csp!.split("default-src").length).toBe(2);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("honors Report-Only mode via csp.reportOnly", async () => {
|
|
311
|
+
const plugin = secure({ csp: { reportOnly: true } });
|
|
312
|
+
const ctx = makeCtx(makeReq("https://example.com/"));
|
|
313
|
+
const res = await runPlugin(plugin, ctx, (c) => c.ok({ ok: true }));
|
|
314
|
+
expect(res.headers.get("Content-Security-Policy-Report-Only")).toBeTruthy();
|
|
315
|
+
expect(res.headers.get("Content-Security-Policy")).toBeNull();
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("allows disabling X-XSS-Protection via xssProtection: false", async () => {
|
|
319
|
+
const plugin = secure({ xssProtection: false });
|
|
320
|
+
const ctx = makeCtx(makeReq("https://example.com/"));
|
|
321
|
+
const res = await runPlugin(plugin, ctx, (c) => c.ok({ ok: true }));
|
|
322
|
+
expect(res.headers.get("X-XSS-Protection")).toBeNull();
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("supports custom HSTS maxAge + preload combo", async () => {
|
|
326
|
+
const plugin = secure({
|
|
327
|
+
hsts: { maxAge: 63072000, includeSubDomains: true, preload: true },
|
|
328
|
+
});
|
|
329
|
+
const ctx = makeCtx(makeReq("https://example.com/"));
|
|
330
|
+
const res = await runPlugin(plugin, ctx, (c) => c.ok({ ok: true }));
|
|
331
|
+
const hsts = res.headers.get("Strict-Transport-Security");
|
|
332
|
+
expect(hsts).toBe("max-age=63072000; includeSubDomains; preload");
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
// ============================================
|
|
337
|
+
// Stand-alone applySecureHeadersToResponse
|
|
338
|
+
// ============================================
|
|
339
|
+
|
|
340
|
+
describe("applySecureHeadersToResponse", () => {
|
|
341
|
+
it("wraps an arbitrary Response with the default header set", () => {
|
|
342
|
+
const base = new Response("hello", {
|
|
343
|
+
status: 200,
|
|
344
|
+
headers: { "Content-Type": "text/plain" },
|
|
345
|
+
});
|
|
346
|
+
const { response } = applySecureHeadersToResponse(base, { hsts: false });
|
|
347
|
+
expect(response.headers.get("X-Frame-Options")).toBe("DENY");
|
|
348
|
+
expect(response.headers.get("Content-Type")).toBe("text/plain");
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("returns the generated nonce when csp.nonce: true", () => {
|
|
352
|
+
const base = new Response(null, { status: 204 });
|
|
353
|
+
const { nonce, response } = applySecureHeadersToResponse(base, {
|
|
354
|
+
hsts: false,
|
|
355
|
+
csp: { nonce: true },
|
|
356
|
+
});
|
|
357
|
+
expect(nonce).toBeDefined();
|
|
358
|
+
expect(response.headers.get("Content-Security-Policy")).toContain(`'nonce-${nonce}'`);
|
|
359
|
+
});
|
|
360
|
+
});
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content-Security-Policy builder
|
|
3
|
+
*
|
|
4
|
+
* Standalone because CSP has many directives, user overrides need to merge
|
|
5
|
+
* deterministically with defaults, and per-request nonce interpolation needs
|
|
6
|
+
* a single authoritative code path so `script-src` and `style-src` are
|
|
7
|
+
* guaranteed to share the same nonce (OWASP recommendation — one nonce per
|
|
8
|
+
* request, reused across directives).
|
|
9
|
+
*
|
|
10
|
+
* Consumers:
|
|
11
|
+
* - `secure()` middleware (via `buildCsp`)
|
|
12
|
+
* - Callers who want ONLY CSP (public re-export from the secure barrel)
|
|
13
|
+
*
|
|
14
|
+
* @see https://www.w3.org/TR/CSP3/
|
|
15
|
+
* @see https://owasp.org/www-project-secure-headers/
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
// ========== Types ==========
|
|
19
|
+
|
|
20
|
+
export interface CspOptions {
|
|
21
|
+
/** Directive map. Values are joined with space. Merged over defaults. */
|
|
22
|
+
directives?: Record<string, string[]>;
|
|
23
|
+
/** When true, emits `Content-Security-Policy-Report-Only` instead. */
|
|
24
|
+
reportOnly?: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Nonce source:
|
|
27
|
+
* - `true` → generate a fresh 128-bit random nonce per call
|
|
28
|
+
* - string → use the provided literal nonce value (must be base64url-safe)
|
|
29
|
+
* - `false` / undefined → no nonce; any `{NONCE}` placeholder is stripped
|
|
30
|
+
*/
|
|
31
|
+
nonce?: boolean | string | false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface BuiltCsp {
|
|
35
|
+
/** Final header value. */
|
|
36
|
+
header: string;
|
|
37
|
+
/** Which header name to set. */
|
|
38
|
+
name: "Content-Security-Policy" | "Content-Security-Policy-Report-Only";
|
|
39
|
+
/** Populated only when `nonce` resolved to a concrete value. */
|
|
40
|
+
nonce?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ========== Defaults ==========
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Production-safe SSR + islands defaults.
|
|
47
|
+
*
|
|
48
|
+
* Key choices:
|
|
49
|
+
* - `'strict-dynamic'` on `script-src` means: once a trusted nonce-authorized
|
|
50
|
+
* script loads, further scripts it creates inherit trust. This allows
|
|
51
|
+
* hydration bundles to lazy-load chunks without reloading their nonce.
|
|
52
|
+
* Modern browsers (CSP 3+) honor this; older browsers fall back to the
|
|
53
|
+
* nonce allowlist, which still works for the SSR-emitted scripts.
|
|
54
|
+
* - `frame-ancestors 'none'` supersedes `X-Frame-Options: DENY` for modern
|
|
55
|
+
* browsers. We still emit XFO for legacy clients.
|
|
56
|
+
* - `upgrade-insecure-requests` silently rewrites `http:` subresources to
|
|
57
|
+
* `https:`. Safe default for modern apps; cheap insurance against mixed
|
|
58
|
+
* content from third-party embeds.
|
|
59
|
+
* - NO `'unsafe-inline'` / `'unsafe-eval'`. If your app needs them, opt in
|
|
60
|
+
* explicitly via `directives` — don't let them sneak in as defaults.
|
|
61
|
+
*/
|
|
62
|
+
export const DEFAULT_CSP_DIRECTIVES: Readonly<Record<string, readonly string[]>> = Object.freeze({
|
|
63
|
+
"default-src": ["'self'"],
|
|
64
|
+
"script-src": ["'self'", "'nonce-{NONCE}'", "'strict-dynamic'"],
|
|
65
|
+
"style-src": ["'self'", "'nonce-{NONCE}'", "https://fonts.googleapis.com"],
|
|
66
|
+
"font-src": ["'self'", "https://fonts.gstatic.com", "data:"],
|
|
67
|
+
"img-src": ["'self'", "data:", "https:"],
|
|
68
|
+
"connect-src": ["'self'"],
|
|
69
|
+
"frame-ancestors": ["'none'"],
|
|
70
|
+
"base-uri": ["'self'"],
|
|
71
|
+
"form-action": ["'self'"],
|
|
72
|
+
"object-src": ["'none'"],
|
|
73
|
+
"upgrade-insecure-requests": [],
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
/** Placeholder literal that `buildCsp` rewrites to the real nonce value. */
|
|
77
|
+
export const NONCE_PLACEHOLDER = "{NONCE}";
|
|
78
|
+
|
|
79
|
+
// ========== Public API ==========
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Build a single `Content-Security-Policy` header from defaults + overrides.
|
|
83
|
+
*
|
|
84
|
+
* Merge semantics:
|
|
85
|
+
* - User-supplied directives REPLACE the default values for that directive
|
|
86
|
+
* (caller owns the full value list). We do not concatenate, because CSP
|
|
87
|
+
* parsing is order-sensitive and partial merges surprise callers.
|
|
88
|
+
* - Directive names are normalized to lowercase-kebab. Caller may supply
|
|
89
|
+
* either `"script-src"` or `"scriptSrc"`; both map to `script-src`.
|
|
90
|
+
* - Empty-array directives emit as bare directive names (e.g.
|
|
91
|
+
* `upgrade-insecure-requests`) — required by the CSP grammar for flag
|
|
92
|
+
* directives.
|
|
93
|
+
*
|
|
94
|
+
* Nonce handling:
|
|
95
|
+
* - If `nonce === true`, we generate a per-call nonce via
|
|
96
|
+
* `crypto.getRandomValues` (16 bytes → 128 bits of entropy, base64url).
|
|
97
|
+
* - If `nonce === string`, we trust the caller's value verbatim. Useful for
|
|
98
|
+
* integrating with a pre-existing per-request nonce generator.
|
|
99
|
+
* - The SAME nonce is substituted into every `{NONCE}` occurrence across
|
|
100
|
+
* ALL directives (script-src, style-src, ...). Per OWASP guidance.
|
|
101
|
+
* - If no nonce, `{NONCE}` placeholders are stripped along with their
|
|
102
|
+
* surrounding `'nonce-...'` wrapper to avoid emitting broken tokens.
|
|
103
|
+
*/
|
|
104
|
+
export function buildCsp(options: CspOptions = {}): BuiltCsp {
|
|
105
|
+
const nonce = resolveNonce(options.nonce);
|
|
106
|
+
|
|
107
|
+
// Normalize + merge.
|
|
108
|
+
const merged: Record<string, string[]> = {};
|
|
109
|
+
for (const [k, v] of Object.entries(DEFAULT_CSP_DIRECTIVES)) {
|
|
110
|
+
merged[normalizeDirectiveName(k)] = [...v];
|
|
111
|
+
}
|
|
112
|
+
if (options.directives) {
|
|
113
|
+
for (const [k, v] of Object.entries(options.directives)) {
|
|
114
|
+
if (!Array.isArray(v)) continue;
|
|
115
|
+
merged[normalizeDirectiveName(k)] = [...v];
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const parts: string[] = [];
|
|
120
|
+
for (const [name, values] of Object.entries(merged)) {
|
|
121
|
+
const processed = values
|
|
122
|
+
.map((v) => interpolateNonce(v, nonce))
|
|
123
|
+
// Strip broken nonce tokens when no nonce was produced.
|
|
124
|
+
.filter((v): v is string => v !== null);
|
|
125
|
+
|
|
126
|
+
if (processed.length === 0) {
|
|
127
|
+
// Flag-style directive (no value list) — emit bare name.
|
|
128
|
+
parts.push(name);
|
|
129
|
+
} else {
|
|
130
|
+
parts.push(`${name} ${processed.join(" ")}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
header: parts.join("; "),
|
|
136
|
+
name: options.reportOnly
|
|
137
|
+
? "Content-Security-Policy-Report-Only"
|
|
138
|
+
: "Content-Security-Policy",
|
|
139
|
+
nonce,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ========== Internal ==========
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Resolve the `nonce` option to a concrete string (or undefined for "off").
|
|
147
|
+
* Generation uses `crypto.getRandomValues` rather than `crypto.randomUUID`
|
|
148
|
+
* to match OWASP's recommendation of ≥128 bits of entropy with raw random
|
|
149
|
+
* bytes (UUIDs have lower effective entropy due to version/variant bits).
|
|
150
|
+
*/
|
|
151
|
+
function resolveNonce(opt: CspOptions["nonce"]): string | undefined {
|
|
152
|
+
if (opt === true) {
|
|
153
|
+
const bytes = new Uint8Array(16);
|
|
154
|
+
crypto.getRandomValues(bytes);
|
|
155
|
+
return base64UrlEncode(bytes);
|
|
156
|
+
}
|
|
157
|
+
if (typeof opt === "string" && opt.length > 0) {
|
|
158
|
+
return opt;
|
|
159
|
+
}
|
|
160
|
+
return undefined;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Replace `{NONCE}` in a single directive value.
|
|
165
|
+
*
|
|
166
|
+
* Returns `null` to signal "this value depended on a nonce that was never
|
|
167
|
+
* produced — drop it entirely" so callers don't emit `'nonce-'` (broken
|
|
168
|
+
* token). Returns the original value when no placeholder is present.
|
|
169
|
+
*/
|
|
170
|
+
function interpolateNonce(value: string, nonce: string | undefined): string | null {
|
|
171
|
+
if (!value.includes(NONCE_PLACEHOLDER)) return value;
|
|
172
|
+
if (!nonce) return null;
|
|
173
|
+
return value.split(NONCE_PLACEHOLDER).join(nonce);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Accepts `"scriptSrc"` / `"script-src"` / `"Script-Src"` → `"script-src"`.
|
|
178
|
+
* camelCase → kebab-case via the standard regex; already-kebab inputs are
|
|
179
|
+
* lowercased without further transformation.
|
|
180
|
+
*/
|
|
181
|
+
function normalizeDirectiveName(name: string): string {
|
|
182
|
+
return name
|
|
183
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
|
|
184
|
+
.toLowerCase();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function base64UrlEncode(bytes: Uint8Array): string {
|
|
188
|
+
let binary = "";
|
|
189
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
190
|
+
binary += String.fromCharCode(bytes[i]);
|
|
191
|
+
}
|
|
192
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
193
|
+
}
|