@mandujs/core 0.41.2 → 0.43.0
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 +21 -4
- package/src/auth/__tests__/login.test.ts +420 -419
- package/src/auth/__tests__/reset.test.ts +296 -296
- package/src/brain/adapters/anthropic-oauth.ts +421 -420
- package/src/brain/adapters/index.ts +2 -1
- package/src/brain/adapters/ollama.ts +1 -1
- package/src/brain/adapters/openai-oauth.ts +534 -533
- package/src/brain/brain.ts +2 -1
- package/src/brain/redactor.ts +196 -196
- package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -149
- package/src/bundler/__tests__/cold-start.test.ts +504 -504
- package/src/bundler/__tests__/fast-refresh.test.ts +607 -606
- package/src/bundler/__tests__/hdr.test.ts +1 -1
- package/src/bundler/analyzer.ts +958 -958
- package/src/bundler/build.ts +104 -14
- package/src/bundler/dev.ts +125 -0
- package/src/bundler/hmr-types.ts +1 -0
- package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -0
- package/src/bundler/plugins/index.ts +14 -0
- package/src/bundler/plugins/react-compiler-lint.ts +253 -0
- package/src/bundler/plugins/react-compiler.ts +162 -0
- package/src/bundler/types.ts +12 -0
- package/src/change/integrity.ts +2 -1
- package/src/client/index.ts +10 -0
- package/src/client/island.ts +38 -11
- package/src/client/router.ts +6 -1
- package/src/config/mandu.ts +57 -0
- package/src/config/validate.ts +42 -0
- package/src/content/collection.ts +844 -809
- package/src/content/content-layer.ts +316 -314
- package/src/content/content.test.ts +433 -433
- package/src/content/digest.ts +133 -133
- package/src/content/generate-types.ts +168 -168
- package/src/content/index.ts +6 -1
- package/src/content/llms-txt.ts +277 -277
- package/src/contract/define.ts +474 -474
- package/src/contract/route-helpers.ts +2 -1
- package/src/contract/zod-utils.ts +158 -155
- package/src/db/index.ts +513 -513
- package/src/desktop/__tests__/smoke.test.ts +100 -100
- package/src/desktop/webview-fallback.ts +583 -583
- package/src/desktop/window.ts +3 -1
- package/src/dev-error-overlay/overlay-client.ts +300 -300
- package/src/devtools/ai/mcp-connector.ts +499 -498
- package/src/devtools/client/components/kitchen-root.tsx +7 -2
- package/src/email/resend.ts +163 -163
- package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -0
- package/src/guard/ast-analyzer.ts +806 -806
- package/src/guard/graph.ts +898 -898
- package/src/guard/index.ts +16 -0
- package/src/guard/statistics.ts +578 -578
- package/src/guard/tsgolint-bridge.ts +512 -0
- package/src/i18n/locale-resolver.ts +214 -214
- package/src/id/__tests__/id.test.ts +120 -120
- package/src/intent/index.ts +321 -321
- package/src/island/index.ts +39 -23
- package/src/kitchen/api/contract-api.ts +15 -8
- package/src/kitchen/kitchen-ui.ts +2137 -2137
- package/src/lockfile/index.ts +3 -2
- package/src/middleware/oauth/__tests__/oauth.test.ts +575 -574
- package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -642
- package/src/middleware/secure/index.ts +417 -417
- package/src/observability/event-bus.ts +2 -2
- package/src/observability/metrics.ts +334 -334
- package/src/observability/tracing.ts +694 -694
- package/src/openapi/generator.ts +1 -1
- package/src/perf/user-marks.ts +553 -553
- package/src/plugins/registry.ts +387 -387
- package/src/resource/ddl/diff.ts +392 -392
- package/src/resource/ddl/snapshot.ts +448 -447
- package/src/resource/generator-schema.ts +477 -476
- package/src/resource/parser.ts +4 -2
- package/src/resource/schema.ts +1 -1
- package/src/router/fs-patterns.ts +422 -422
- package/src/runtime/fast-refresh-types.ts +126 -128
- package/src/runtime/image-handler.ts +206 -195
- package/src/runtime/router.test.ts +476 -476
- package/src/runtime/security.ts +155 -155
- package/src/runtime/server.ts +36 -19
- package/src/runtime/session-key.ts +328 -328
- package/src/scheduler/__tests__/scheduler.test.ts +514 -514
- package/src/seo/resolve/index.ts +353 -353
- package/src/spec/load.ts +1 -1
- package/src/testing/reporter.ts +676 -676
- package/src/testing/server.ts +196 -196
- package/src/testing/snapshot.ts +444 -444
- package/src/utils/__tests__/lru-cache.test.ts +186 -186
- package/src/utils/bun.ts +8 -8
|
@@ -1,417 +1,417 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Secure HTTP Headers Middleware
|
|
3
|
-
*
|
|
4
|
-
* A Helmet-equivalent bundle shipping OWASP-recommended defaults for:
|
|
5
|
-
* - Content-Security-Policy (CSP) — see `./csp.ts`
|
|
6
|
-
* - Strict-Transport-Security (HSTS)
|
|
7
|
-
* - X-Frame-Options (legacy; superseded by CSP `frame-ancestors`)
|
|
8
|
-
* - X-Content-Type-Options
|
|
9
|
-
* - Referrer-Policy
|
|
10
|
-
* - Permissions-Policy
|
|
11
|
-
* - X-XSS-Protection (legacy; explicitly `"0"` on modern clients)
|
|
12
|
-
*
|
|
13
|
-
* Strategy: we implement `MiddlewarePlugin` rather than the bare
|
|
14
|
-
* `(ctx) => Response | void` signature so we can mutate the outgoing Response
|
|
15
|
-
* via `afterHandle`. This mirrors `cors.ts` and means we do NOT need any
|
|
16
|
-
* framework modifications, a new "pending headers" buffer in the context, or
|
|
17
|
-
* a custom wrapper helper that callers must remember to invoke.
|
|
18
|
-
*
|
|
19
|
-
* Ordering vs. session/cookies:
|
|
20
|
-
* Unlike session middleware, which must commit BEFORE the response is
|
|
21
|
-
* built (DX-3 + Phase 2.3), header-setting middleware can run AFTER the
|
|
22
|
-
* response is produced — we use `afterHandle` which receives the fully-
|
|
23
|
-
* constructed Response and returns a replacement. No ordering hazard with
|
|
24
|
-
* cookies/session state, because we never touch `ctx.cookies`.
|
|
25
|
-
*
|
|
26
|
-
* CSP nonce plumbing:
|
|
27
|
-
* When `csp.nonce === true`, we compute a fresh nonce in `beforeHandle`
|
|
28
|
-
* and stash it on the context under the key `"csp-nonce"`. SSR handlers
|
|
29
|
-
* that render inline `<script>` tags can read it with
|
|
30
|
-
* `ctx.get<string>("csp-nonce")`. The same nonce is interpolated into the
|
|
31
|
-
* CSP header in `afterHandle`, guaranteeing the tag and the header agree.
|
|
32
|
-
*
|
|
33
|
-
* Auto-injection of the nonce into `renderToStream`'s hydration script
|
|
34
|
-
* tags is intentionally out of scope for this middleware — see the
|
|
35
|
-
* Phase 6.2 follow-up item in `CLAUDE.md`.
|
|
36
|
-
*
|
|
37
|
-
* @example
|
|
38
|
-
* ```ts
|
|
39
|
-
* import { secure } from "@mandujs/core/middleware";
|
|
40
|
-
*
|
|
41
|
-
* export default Mandu.filling()
|
|
42
|
-
* .use(secure()) // all defaults
|
|
43
|
-
* .get((ctx) => ctx.ok({ hello: "world" }));
|
|
44
|
-
*
|
|
45
|
-
* // With CSP nonce:
|
|
46
|
-
* export default Mandu.filling()
|
|
47
|
-
* .use(secure({ csp: { nonce: true } }))
|
|
48
|
-
* .get((ctx) => {
|
|
49
|
-
* const nonce = ctx.get<string>("csp-nonce");
|
|
50
|
-
* // render inline script with nonce={nonce}
|
|
51
|
-
* return ctx.ok({ ok: true });
|
|
52
|
-
* });
|
|
53
|
-
* ```
|
|
54
|
-
*/
|
|
55
|
-
import type { MiddlewarePlugin } from "../../filling/filling";
|
|
56
|
-
import type { ManduContext } from "../../filling/context";
|
|
57
|
-
import { buildCsp, type CspOptions } from "./csp";
|
|
58
|
-
|
|
59
|
-
export { buildCsp, DEFAULT_CSP_DIRECTIVES } from "./csp";
|
|
60
|
-
export type { CspOptions, BuiltCsp } from "./csp";
|
|
61
|
-
|
|
62
|
-
// ========== Types ==========
|
|
63
|
-
|
|
64
|
-
export type ReferrerPolicyValue =
|
|
65
|
-
| "no-referrer"
|
|
66
|
-
| "no-referrer-when-downgrade"
|
|
67
|
-
| "origin"
|
|
68
|
-
| "origin-when-cross-origin"
|
|
69
|
-
| "same-origin"
|
|
70
|
-
| "strict-origin"
|
|
71
|
-
| "strict-origin-when-cross-origin"
|
|
72
|
-
| "unsafe-url";
|
|
73
|
-
|
|
74
|
-
export interface HstsOptions {
|
|
75
|
-
/** max-age in seconds. Default: 15552000 (180 days). */
|
|
76
|
-
maxAge?: number;
|
|
77
|
-
/** includeSubDomains directive. Default: true. */
|
|
78
|
-
includeSubDomains?: boolean;
|
|
79
|
-
/** preload directive. Default: false — opt-in; implies submitting to the preload list. */
|
|
80
|
-
preload?: boolean;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
export interface SecureMiddlewareOptions {
|
|
84
|
-
/** Content-Security-Policy options, or `false` to disable. */
|
|
85
|
-
csp?: CspOptions | false;
|
|
86
|
-
/** Strict-Transport-Security options, or `false` to disable. */
|
|
87
|
-
hsts?: HstsOptions | false;
|
|
88
|
-
/** X-Frame-Options. Default: `"DENY"`. `false` to disable. */
|
|
89
|
-
frameOptions?: "DENY" | "SAMEORIGIN" | false;
|
|
90
|
-
/** X-Content-Type-Options: nosniff. Default: true. */
|
|
91
|
-
noSniff?: boolean;
|
|
92
|
-
/** Referrer-Policy. Default: `"strict-origin-when-cross-origin"`. */
|
|
93
|
-
referrerPolicy?: ReferrerPolicyValue | false;
|
|
94
|
-
/**
|
|
95
|
-
* Permissions-Policy map. Each entry becomes `feature=(allowlist)`.
|
|
96
|
-
* Use an empty array to deny a feature entirely (`feature=()`).
|
|
97
|
-
* `false` disables the header.
|
|
98
|
-
*/
|
|
99
|
-
permissionsPolicy?: Record<string, string[]> | false;
|
|
100
|
-
/**
|
|
101
|
-
* X-XSS-Protection. Default: `"0"` — modern browsers should use CSP; the
|
|
102
|
-
* legacy auditor has known bypasses and is best disabled. `false` omits.
|
|
103
|
-
*/
|
|
104
|
-
xssProtection?: "0" | "1" | "1; mode=block" | false;
|
|
105
|
-
/**
|
|
106
|
-
* Extra headers to set verbatim. Keys are set as-given (casing preserved
|
|
107
|
-
* by `Headers` normalization rules). Values overwrite any existing header.
|
|
108
|
-
*/
|
|
109
|
-
extra?: Record<string, string>;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// ========== Defaults ==========
|
|
113
|
-
|
|
114
|
-
const DEFAULT_HSTS: Required<HstsOptions> = Object.freeze({
|
|
115
|
-
maxAge: 15552000, // 180 days — matches Chrome's preload minimum
|
|
116
|
-
includeSubDomains: true,
|
|
117
|
-
preload: false,
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
const DEFAULT_REFERRER_POLICY: ReferrerPolicyValue = "strict-origin-when-cross-origin";
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* OWASP-recommended deny-by-default for commonly abused browser capabilities.
|
|
124
|
-
*
|
|
125
|
-
* - `camera` / `microphone`: prevent drive-by media capture; app pages that
|
|
126
|
-
* need these must opt in explicitly via override.
|
|
127
|
-
* - `geolocation`: prevents third-party scripts from geo-tagging users.
|
|
128
|
-
* - `payment`: blocks Payment Request API unless you explicitly own it.
|
|
129
|
-
* - `usb`: blocks WebUSB (e.g. hardware key attacks).
|
|
130
|
-
* - `interest-cohort`: opts out of FLoC/Topics tracking. Still widely
|
|
131
|
-
* respected; cheap insurance.
|
|
132
|
-
*/
|
|
133
|
-
const DEFAULT_PERMISSIONS_POLICY: Record<string, string[]> = Object.freeze({
|
|
134
|
-
camera: [],
|
|
135
|
-
microphone: [],
|
|
136
|
-
geolocation: [],
|
|
137
|
-
payment: [],
|
|
138
|
-
usb: [],
|
|
139
|
-
"interest-cohort": [],
|
|
140
|
-
}) as Record<string, string[]>;
|
|
141
|
-
|
|
142
|
-
const CSP_NONCE_KEY = "csp-nonce";
|
|
143
|
-
|
|
144
|
-
// ========== Public API ==========
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* Build the secure headers middleware. The options argument is consumed
|
|
148
|
-
* eagerly at construction time — subsequent mutations to the passed object
|
|
149
|
-
* do not affect the already-installed middleware.
|
|
150
|
-
*
|
|
151
|
-
* Returns a `MiddlewarePlugin` with:
|
|
152
|
-
* - `beforeHandle`: generates the per-request CSP nonce (if enabled) and
|
|
153
|
-
* stashes it on the context for handler use.
|
|
154
|
-
* - `afterHandle`: computes the final headers bundle and returns a new
|
|
155
|
-
* Response with the headers applied. Existing headers from the handler
|
|
156
|
-
* (e.g. `Content-Type`) are preserved.
|
|
157
|
-
*/
|
|
158
|
-
export function secure(options: SecureMiddlewareOptions = {}): MiddlewarePlugin {
|
|
159
|
-
// Normalize / snapshot options up front so we don't re-read user input on
|
|
160
|
-
// every request (protects against surprise mutation mid-session).
|
|
161
|
-
const cfg = normalizeOptions(options);
|
|
162
|
-
|
|
163
|
-
return {
|
|
164
|
-
beforeHandle: async (ctx: ManduContext): Promise<void> => {
|
|
165
|
-
// If CSP is enabled AND nonce is requested, compute the nonce here so
|
|
166
|
-
// the handler can read it before producing the response. We build the
|
|
167
|
-
// full CSP string in `afterHandle` (cheap) using the same nonce so
|
|
168
|
-
// the header and any handler-rendered <script nonce={...}> agree.
|
|
169
|
-
if (cfg.csp && cfg.csp.nonce === true) {
|
|
170
|
-
// buildCsp will synthesize a fresh nonce; we capture it here and
|
|
171
|
-
// pin it for the afterHandle pass via a per-request override.
|
|
172
|
-
const built = buildCsp({ ...cfg.csp, nonce: true });
|
|
173
|
-
if (built.nonce) {
|
|
174
|
-
ctx.set<string>(CSP_NONCE_KEY, built.nonce);
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
},
|
|
178
|
-
|
|
179
|
-
afterHandle: async (
|
|
180
|
-
ctx: ManduContext,
|
|
181
|
-
response: Response
|
|
182
|
-
): Promise<Response> => {
|
|
183
|
-
const headers = new Headers(response.headers);
|
|
184
|
-
applySecureHeaders(ctx, headers, cfg);
|
|
185
|
-
|
|
186
|
-
// Only re-wrap if we actually added/changed something. In practice the
|
|
187
|
-
// headers map is always non-empty (we always set at least one header
|
|
188
|
-
// when options are default), but this keeps us honest about
|
|
189
|
-
// Response-body identity and matches `cors.ts`'s pattern.
|
|
190
|
-
return new Response(response.body, {
|
|
191
|
-
status: response.status,
|
|
192
|
-
statusText: response.statusText,
|
|
193
|
-
headers,
|
|
194
|
-
});
|
|
195
|
-
},
|
|
196
|
-
};
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
/**
|
|
200
|
-
* Manual escape hatch: apply secure headers to an arbitrary Response using
|
|
201
|
-
* the options shape above.
|
|
202
|
-
*
|
|
203
|
-
* Prefer `.use(secure(...))` — this helper exists for callers outside the
|
|
204
|
-
* filling pipeline (e.g. custom error responders, static file handlers).
|
|
205
|
-
* Since no context is available, CSP nonce mode falls back to the one-shot
|
|
206
|
-
* nonce generated inside `buildCsp` — the caller is responsible for wiring
|
|
207
|
-
* that nonce into whatever they render.
|
|
208
|
-
*/
|
|
209
|
-
export function applySecureHeadersToResponse(
|
|
210
|
-
response: Response,
|
|
211
|
-
options: SecureMiddlewareOptions = {}
|
|
212
|
-
): { response: Response; nonce?: string } {
|
|
213
|
-
const cfg = normalizeOptions(options);
|
|
214
|
-
const headers = new Headers(response.headers);
|
|
215
|
-
|
|
216
|
-
// Derive a fresh nonce for this standalone call (no context to cache it on).
|
|
217
|
-
let emittedNonce: string | undefined;
|
|
218
|
-
if (cfg.csp && cfg.csp.nonce === true) {
|
|
219
|
-
const built = buildCsp({ ...cfg.csp, nonce: true });
|
|
220
|
-
emittedNonce = built.nonce;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
// Pseudo-context shim so applySecureHeaders can read the nonce consistently.
|
|
224
|
-
const shim = {
|
|
225
|
-
request: response as unknown as Request, // only used for URL scheme check; standalone callers skip HSTS logic path below via hsts=false typically
|
|
226
|
-
get: <T>(_key: string): T | undefined => emittedNonce as T | undefined,
|
|
227
|
-
};
|
|
228
|
-
applySecureHeaders(shim as unknown as ManduContext, headers, {
|
|
229
|
-
...cfg,
|
|
230
|
-
// If caller didn't explicitly disable HSTS, keep it — but the scheme
|
|
231
|
-
// probe will simply no-op on a non-Request shim. Callers who want HSTS
|
|
232
|
-
// on standalone responses must ensure `response.url` carries an
|
|
233
|
-
// `https:` URL, or they should pass a pre-built Request with
|
|
234
|
-
// `x-forwarded-proto: https`.
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
return {
|
|
238
|
-
response: new Response(response.body, {
|
|
239
|
-
status: response.status,
|
|
240
|
-
statusText: response.statusText,
|
|
241
|
-
headers,
|
|
242
|
-
}),
|
|
243
|
-
nonce: emittedNonce,
|
|
244
|
-
};
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
// ========== Internal: normalization ==========
|
|
248
|
-
|
|
249
|
-
interface NormalizedOptions {
|
|
250
|
-
csp: CspOptions | null;
|
|
251
|
-
hsts: Required<HstsOptions> | null;
|
|
252
|
-
frameOptions: "DENY" | "SAMEORIGIN" | null;
|
|
253
|
-
noSniff: boolean;
|
|
254
|
-
referrerPolicy: ReferrerPolicyValue | null;
|
|
255
|
-
permissionsPolicy: Record<string, string[]> | null;
|
|
256
|
-
xssProtection: "0" | "1" | "1; mode=block" | null;
|
|
257
|
-
extra: Record<string, string>;
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
function normalizeOptions(options: SecureMiddlewareOptions): NormalizedOptions {
|
|
261
|
-
return {
|
|
262
|
-
csp: options.csp === false ? null : options.csp ?? {},
|
|
263
|
-
hsts:
|
|
264
|
-
options.hsts === false
|
|
265
|
-
? null
|
|
266
|
-
: { ...DEFAULT_HSTS, ...(options.hsts ?? {}) },
|
|
267
|
-
frameOptions:
|
|
268
|
-
options.frameOptions === false
|
|
269
|
-
? null
|
|
270
|
-
: options.frameOptions ?? "DENY",
|
|
271
|
-
noSniff: options.noSniff !== false,
|
|
272
|
-
referrerPolicy:
|
|
273
|
-
options.referrerPolicy === false
|
|
274
|
-
? null
|
|
275
|
-
: options.referrerPolicy ?? DEFAULT_REFERRER_POLICY,
|
|
276
|
-
permissionsPolicy:
|
|
277
|
-
options.permissionsPolicy === false
|
|
278
|
-
? null
|
|
279
|
-
: options.permissionsPolicy ?? { ...DEFAULT_PERMISSIONS_POLICY },
|
|
280
|
-
xssProtection:
|
|
281
|
-
options.xssProtection === false ? null : options.xssProtection ?? "0",
|
|
282
|
-
extra: options.extra ?? {},
|
|
283
|
-
};
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
// ========== Internal: header application ==========
|
|
287
|
-
|
|
288
|
-
function applySecureHeaders(
|
|
289
|
-
ctx: ManduContext,
|
|
290
|
-
headers: Headers,
|
|
291
|
-
cfg: NormalizedOptions
|
|
292
|
-
): void {
|
|
293
|
-
// --- CSP ---
|
|
294
|
-
if (cfg.csp) {
|
|
295
|
-
// If a nonce was pre-computed in beforeHandle, reuse it to ensure the
|
|
296
|
-
// handler's nonce=… values match what we emit in the header.
|
|
297
|
-
const pinnedNonce = ctx.get<string>(CSP_NONCE_KEY);
|
|
298
|
-
const effective =
|
|
299
|
-
pinnedNonce && cfg.csp.nonce === true
|
|
300
|
-
? { ...cfg.csp, nonce: pinnedNonce }
|
|
301
|
-
: cfg.csp;
|
|
302
|
-
|
|
303
|
-
const built = buildCsp(effective);
|
|
304
|
-
headers.set(built.name, built.header);
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
// --- HSTS (only when the request is already HTTPS) ---
|
|
308
|
-
//
|
|
309
|
-
// RFC 6797 §7.2: UAs MUST ignore STS on insecure transport, but we also
|
|
310
|
-
// suppress it server-side to avoid leaking the policy across a plaintext
|
|
311
|
-
// channel (where an active MITM could strip it for first-visit users
|
|
312
|
-
// anyway — the "TOFU" problem HSTS is designed to reduce).
|
|
313
|
-
if (cfg.hsts && isHttps(ctx.request)) {
|
|
314
|
-
const parts = [`max-age=${Math.floor(cfg.hsts.maxAge)}`];
|
|
315
|
-
if (cfg.hsts.includeSubDomains) parts.push("includeSubDomains");
|
|
316
|
-
if (cfg.hsts.preload) parts.push("preload");
|
|
317
|
-
headers.set("Strict-Transport-Security", parts.join("; "));
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
// --- X-Frame-Options ---
|
|
321
|
-
if (cfg.frameOptions) {
|
|
322
|
-
headers.set("X-Frame-Options", cfg.frameOptions);
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
// --- X-Content-Type-Options ---
|
|
326
|
-
if (cfg.noSniff) {
|
|
327
|
-
headers.set("X-Content-Type-Options", "nosniff");
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
// --- Referrer-Policy ---
|
|
331
|
-
if (cfg.referrerPolicy) {
|
|
332
|
-
headers.set("Referrer-Policy", cfg.referrerPolicy);
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
// --- Permissions-Policy ---
|
|
336
|
-
if (cfg.permissionsPolicy) {
|
|
337
|
-
const pp = buildPermissionsPolicy(cfg.permissionsPolicy);
|
|
338
|
-
if (pp.length > 0) {
|
|
339
|
-
headers.set("Permissions-Policy", pp);
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
// --- X-XSS-Protection ---
|
|
344
|
-
if (cfg.xssProtection !== null) {
|
|
345
|
-
headers.set("X-XSS-Protection", cfg.xssProtection);
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
// --- Arbitrary extras (caller overrides always win) ---
|
|
349
|
-
for (const [k, v] of Object.entries(cfg.extra)) {
|
|
350
|
-
headers.set(k, v);
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
/**
|
|
355
|
-
* Build a Permissions-Policy header value.
|
|
356
|
-
*
|
|
357
|
-
* Grammar (simplified): `feature=(allowlist) , feature=(allowlist)`
|
|
358
|
-
* - Bare tokens like `self` go inside the parens as-is.
|
|
359
|
-
* - Origins (URLs) must be wrapped in double quotes per spec.
|
|
360
|
-
* - Empty allowlist `()` denies the feature entirely.
|
|
361
|
-
*
|
|
362
|
-
* We accept the caller's array verbatim; they are responsible for quoting
|
|
363
|
-
* their URL-shaped entries. We do wrap origins that look URL-ish (contain
|
|
364
|
-
* `://`) when they are unquoted, because that's by far the most common
|
|
365
|
-
* mistake and the cost of the heuristic is tiny.
|
|
366
|
-
*/
|
|
367
|
-
function buildPermissionsPolicy(map: Record<string, string[]>): string {
|
|
368
|
-
const entries: string[] = [];
|
|
369
|
-
for (const [feature, allowlist] of Object.entries(map)) {
|
|
370
|
-
if (!Array.isArray(allowlist)) continue;
|
|
371
|
-
const items = allowlist.map(normalizePermissionsItem).join(" ");
|
|
372
|
-
entries.push(`${feature}=(${items})`);
|
|
373
|
-
}
|
|
374
|
-
return entries.join(", ");
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
function normalizePermissionsItem(raw: string): string {
|
|
378
|
-
if (raw === "self" || raw === "*") return raw;
|
|
379
|
-
if (raw.startsWith('"') && raw.endsWith('"')) return raw;
|
|
380
|
-
if (raw.includes("://")) return `"${raw}"`;
|
|
381
|
-
return raw;
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
/**
|
|
385
|
-
* Determine whether the inbound request is HTTPS.
|
|
386
|
-
*
|
|
387
|
-
* Recognizes:
|
|
388
|
-
* 1. Direct `https:` scheme in `request.url`
|
|
389
|
-
* 2. `X-Forwarded-Proto: https` (de-facto reverse-proxy header)
|
|
390
|
-
* 3. `Forwarded: proto=https` (RFC 7239)
|
|
391
|
-
*
|
|
392
|
-
* We intentionally don't trust these headers on direct (non-proxied)
|
|
393
|
-
* connections — but since the caller is the one opting into HSTS, they're
|
|
394
|
-
* also responsible for running behind a reverse proxy that sanitizes
|
|
395
|
-
* client-supplied `Forwarded` / `X-Forwarded-*` headers. This matches
|
|
396
|
-
* Helmet's and express's documented behavior.
|
|
397
|
-
*/
|
|
398
|
-
function isHttps(request: Request): boolean {
|
|
399
|
-
try {
|
|
400
|
-
if (request.url.startsWith("https:")) return true;
|
|
401
|
-
} catch {
|
|
402
|
-
// Some shims may throw on `.url`; fall through to header checks.
|
|
403
|
-
}
|
|
404
|
-
const xfp = safeGetHeader(request, "x-forwarded-proto");
|
|
405
|
-
if (xfp && xfp.split(",")[0]!.trim().toLowerCase() === "https") return true;
|
|
406
|
-
const fwd = safeGetHeader(request, "forwarded");
|
|
407
|
-
if (fwd && /\bproto=https\b/i.test(fwd)) return true;
|
|
408
|
-
return false;
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
function safeGetHeader(request: Request, name: string): string | null {
|
|
412
|
-
try {
|
|
413
|
-
return request.headers?.get(name) ?? null;
|
|
414
|
-
} catch {
|
|
415
|
-
return null;
|
|
416
|
-
}
|
|
417
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Secure HTTP Headers Middleware
|
|
3
|
+
*
|
|
4
|
+
* A Helmet-equivalent bundle shipping OWASP-recommended defaults for:
|
|
5
|
+
* - Content-Security-Policy (CSP) — see `./csp.ts`
|
|
6
|
+
* - Strict-Transport-Security (HSTS)
|
|
7
|
+
* - X-Frame-Options (legacy; superseded by CSP `frame-ancestors`)
|
|
8
|
+
* - X-Content-Type-Options
|
|
9
|
+
* - Referrer-Policy
|
|
10
|
+
* - Permissions-Policy
|
|
11
|
+
* - X-XSS-Protection (legacy; explicitly `"0"` on modern clients)
|
|
12
|
+
*
|
|
13
|
+
* Strategy: we implement `MiddlewarePlugin` rather than the bare
|
|
14
|
+
* `(ctx) => Response | void` signature so we can mutate the outgoing Response
|
|
15
|
+
* via `afterHandle`. This mirrors `cors.ts` and means we do NOT need any
|
|
16
|
+
* framework modifications, a new "pending headers" buffer in the context, or
|
|
17
|
+
* a custom wrapper helper that callers must remember to invoke.
|
|
18
|
+
*
|
|
19
|
+
* Ordering vs. session/cookies:
|
|
20
|
+
* Unlike session middleware, which must commit BEFORE the response is
|
|
21
|
+
* built (DX-3 + Phase 2.3), header-setting middleware can run AFTER the
|
|
22
|
+
* response is produced — we use `afterHandle` which receives the fully-
|
|
23
|
+
* constructed Response and returns a replacement. No ordering hazard with
|
|
24
|
+
* cookies/session state, because we never touch `ctx.cookies`.
|
|
25
|
+
*
|
|
26
|
+
* CSP nonce plumbing:
|
|
27
|
+
* When `csp.nonce === true`, we compute a fresh nonce in `beforeHandle`
|
|
28
|
+
* and stash it on the context under the key `"csp-nonce"`. SSR handlers
|
|
29
|
+
* that render inline `<script>` tags can read it with
|
|
30
|
+
* `ctx.get<string>("csp-nonce")`. The same nonce is interpolated into the
|
|
31
|
+
* CSP header in `afterHandle`, guaranteeing the tag and the header agree.
|
|
32
|
+
*
|
|
33
|
+
* Auto-injection of the nonce into `renderToStream`'s hydration script
|
|
34
|
+
* tags is intentionally out of scope for this middleware — see the
|
|
35
|
+
* Phase 6.2 follow-up item in `CLAUDE.md`.
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```ts
|
|
39
|
+
* import { secure } from "@mandujs/core/middleware";
|
|
40
|
+
*
|
|
41
|
+
* export default Mandu.filling()
|
|
42
|
+
* .use(secure()) // all defaults
|
|
43
|
+
* .get((ctx) => ctx.ok({ hello: "world" }));
|
|
44
|
+
*
|
|
45
|
+
* // With CSP nonce:
|
|
46
|
+
* export default Mandu.filling()
|
|
47
|
+
* .use(secure({ csp: { nonce: true } }))
|
|
48
|
+
* .get((ctx) => {
|
|
49
|
+
* const nonce = ctx.get<string>("csp-nonce");
|
|
50
|
+
* // render inline script with nonce={nonce}
|
|
51
|
+
* return ctx.ok({ ok: true });
|
|
52
|
+
* });
|
|
53
|
+
* ```
|
|
54
|
+
*/
|
|
55
|
+
import type { MiddlewarePlugin } from "../../filling/filling";
|
|
56
|
+
import type { ManduContext } from "../../filling/context";
|
|
57
|
+
import { buildCsp, type CspOptions } from "./csp";
|
|
58
|
+
|
|
59
|
+
export { buildCsp, DEFAULT_CSP_DIRECTIVES } from "./csp";
|
|
60
|
+
export type { CspOptions, BuiltCsp } from "./csp";
|
|
61
|
+
|
|
62
|
+
// ========== Types ==========
|
|
63
|
+
|
|
64
|
+
export type ReferrerPolicyValue =
|
|
65
|
+
| "no-referrer"
|
|
66
|
+
| "no-referrer-when-downgrade"
|
|
67
|
+
| "origin"
|
|
68
|
+
| "origin-when-cross-origin"
|
|
69
|
+
| "same-origin"
|
|
70
|
+
| "strict-origin"
|
|
71
|
+
| "strict-origin-when-cross-origin"
|
|
72
|
+
| "unsafe-url";
|
|
73
|
+
|
|
74
|
+
export interface HstsOptions {
|
|
75
|
+
/** max-age in seconds. Default: 15552000 (180 days). */
|
|
76
|
+
maxAge?: number;
|
|
77
|
+
/** includeSubDomains directive. Default: true. */
|
|
78
|
+
includeSubDomains?: boolean;
|
|
79
|
+
/** preload directive. Default: false — opt-in; implies submitting to the preload list. */
|
|
80
|
+
preload?: boolean;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface SecureMiddlewareOptions {
|
|
84
|
+
/** Content-Security-Policy options, or `false` to disable. */
|
|
85
|
+
csp?: CspOptions | false;
|
|
86
|
+
/** Strict-Transport-Security options, or `false` to disable. */
|
|
87
|
+
hsts?: HstsOptions | false;
|
|
88
|
+
/** X-Frame-Options. Default: `"DENY"`. `false` to disable. */
|
|
89
|
+
frameOptions?: "DENY" | "SAMEORIGIN" | false;
|
|
90
|
+
/** X-Content-Type-Options: nosniff. Default: true. */
|
|
91
|
+
noSniff?: boolean;
|
|
92
|
+
/** Referrer-Policy. Default: `"strict-origin-when-cross-origin"`. */
|
|
93
|
+
referrerPolicy?: ReferrerPolicyValue | false;
|
|
94
|
+
/**
|
|
95
|
+
* Permissions-Policy map. Each entry becomes `feature=(allowlist)`.
|
|
96
|
+
* Use an empty array to deny a feature entirely (`feature=()`).
|
|
97
|
+
* `false` disables the header.
|
|
98
|
+
*/
|
|
99
|
+
permissionsPolicy?: Record<string, string[]> | false;
|
|
100
|
+
/**
|
|
101
|
+
* X-XSS-Protection. Default: `"0"` — modern browsers should use CSP; the
|
|
102
|
+
* legacy auditor has known bypasses and is best disabled. `false` omits.
|
|
103
|
+
*/
|
|
104
|
+
xssProtection?: "0" | "1" | "1; mode=block" | false;
|
|
105
|
+
/**
|
|
106
|
+
* Extra headers to set verbatim. Keys are set as-given (casing preserved
|
|
107
|
+
* by `Headers` normalization rules). Values overwrite any existing header.
|
|
108
|
+
*/
|
|
109
|
+
extra?: Record<string, string>;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ========== Defaults ==========
|
|
113
|
+
|
|
114
|
+
const DEFAULT_HSTS: Required<HstsOptions> = Object.freeze({
|
|
115
|
+
maxAge: 15552000, // 180 days — matches Chrome's preload minimum
|
|
116
|
+
includeSubDomains: true,
|
|
117
|
+
preload: false,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const DEFAULT_REFERRER_POLICY: ReferrerPolicyValue = "strict-origin-when-cross-origin";
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* OWASP-recommended deny-by-default for commonly abused browser capabilities.
|
|
124
|
+
*
|
|
125
|
+
* - `camera` / `microphone`: prevent drive-by media capture; app pages that
|
|
126
|
+
* need these must opt in explicitly via override.
|
|
127
|
+
* - `geolocation`: prevents third-party scripts from geo-tagging users.
|
|
128
|
+
* - `payment`: blocks Payment Request API unless you explicitly own it.
|
|
129
|
+
* - `usb`: blocks WebUSB (e.g. hardware key attacks).
|
|
130
|
+
* - `interest-cohort`: opts out of FLoC/Topics tracking. Still widely
|
|
131
|
+
* respected; cheap insurance.
|
|
132
|
+
*/
|
|
133
|
+
const DEFAULT_PERMISSIONS_POLICY: Record<string, string[]> = Object.freeze({
|
|
134
|
+
camera: [],
|
|
135
|
+
microphone: [],
|
|
136
|
+
geolocation: [],
|
|
137
|
+
payment: [],
|
|
138
|
+
usb: [],
|
|
139
|
+
"interest-cohort": [],
|
|
140
|
+
}) as Record<string, string[]>;
|
|
141
|
+
|
|
142
|
+
const CSP_NONCE_KEY = "csp-nonce";
|
|
143
|
+
|
|
144
|
+
// ========== Public API ==========
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Build the secure headers middleware. The options argument is consumed
|
|
148
|
+
* eagerly at construction time — subsequent mutations to the passed object
|
|
149
|
+
* do not affect the already-installed middleware.
|
|
150
|
+
*
|
|
151
|
+
* Returns a `MiddlewarePlugin` with:
|
|
152
|
+
* - `beforeHandle`: generates the per-request CSP nonce (if enabled) and
|
|
153
|
+
* stashes it on the context for handler use.
|
|
154
|
+
* - `afterHandle`: computes the final headers bundle and returns a new
|
|
155
|
+
* Response with the headers applied. Existing headers from the handler
|
|
156
|
+
* (e.g. `Content-Type`) are preserved.
|
|
157
|
+
*/
|
|
158
|
+
export function secure(options: SecureMiddlewareOptions = {}): MiddlewarePlugin {
|
|
159
|
+
// Normalize / snapshot options up front so we don't re-read user input on
|
|
160
|
+
// every request (protects against surprise mutation mid-session).
|
|
161
|
+
const cfg = normalizeOptions(options);
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
beforeHandle: async (ctx: ManduContext): Promise<void> => {
|
|
165
|
+
// If CSP is enabled AND nonce is requested, compute the nonce here so
|
|
166
|
+
// the handler can read it before producing the response. We build the
|
|
167
|
+
// full CSP string in `afterHandle` (cheap) using the same nonce so
|
|
168
|
+
// the header and any handler-rendered <script nonce={...}> agree.
|
|
169
|
+
if (cfg.csp && cfg.csp.nonce === true) {
|
|
170
|
+
// buildCsp will synthesize a fresh nonce; we capture it here and
|
|
171
|
+
// pin it for the afterHandle pass via a per-request override.
|
|
172
|
+
const built = buildCsp({ ...cfg.csp, nonce: true });
|
|
173
|
+
if (built.nonce) {
|
|
174
|
+
ctx.set<string>(CSP_NONCE_KEY, built.nonce);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
afterHandle: async (
|
|
180
|
+
ctx: ManduContext,
|
|
181
|
+
response: Response
|
|
182
|
+
): Promise<Response> => {
|
|
183
|
+
const headers = new Headers(response.headers);
|
|
184
|
+
applySecureHeaders(ctx, headers, cfg);
|
|
185
|
+
|
|
186
|
+
// Only re-wrap if we actually added/changed something. In practice the
|
|
187
|
+
// headers map is always non-empty (we always set at least one header
|
|
188
|
+
// when options are default), but this keeps us honest about
|
|
189
|
+
// Response-body identity and matches `cors.ts`'s pattern.
|
|
190
|
+
return new Response(response.body, {
|
|
191
|
+
status: response.status,
|
|
192
|
+
statusText: response.statusText,
|
|
193
|
+
headers,
|
|
194
|
+
});
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Manual escape hatch: apply secure headers to an arbitrary Response using
|
|
201
|
+
* the options shape above.
|
|
202
|
+
*
|
|
203
|
+
* Prefer `.use(secure(...))` — this helper exists for callers outside the
|
|
204
|
+
* filling pipeline (e.g. custom error responders, static file handlers).
|
|
205
|
+
* Since no context is available, CSP nonce mode falls back to the one-shot
|
|
206
|
+
* nonce generated inside `buildCsp` — the caller is responsible for wiring
|
|
207
|
+
* that nonce into whatever they render.
|
|
208
|
+
*/
|
|
209
|
+
export function applySecureHeadersToResponse(
|
|
210
|
+
response: Response,
|
|
211
|
+
options: SecureMiddlewareOptions = {}
|
|
212
|
+
): { response: Response; nonce?: string } {
|
|
213
|
+
const cfg = normalizeOptions(options);
|
|
214
|
+
const headers = new Headers(response.headers);
|
|
215
|
+
|
|
216
|
+
// Derive a fresh nonce for this standalone call (no context to cache it on).
|
|
217
|
+
let emittedNonce: string | undefined;
|
|
218
|
+
if (cfg.csp && cfg.csp.nonce === true) {
|
|
219
|
+
const built = buildCsp({ ...cfg.csp, nonce: true });
|
|
220
|
+
emittedNonce = built.nonce;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Pseudo-context shim so applySecureHeaders can read the nonce consistently.
|
|
224
|
+
const shim = {
|
|
225
|
+
request: response as unknown as Request, // only used for URL scheme check; standalone callers skip HSTS logic path below via hsts=false typically
|
|
226
|
+
get: <T>(_key: string): T | undefined => emittedNonce as T | undefined,
|
|
227
|
+
};
|
|
228
|
+
applySecureHeaders(shim as unknown as ManduContext, headers, {
|
|
229
|
+
...cfg,
|
|
230
|
+
// If caller didn't explicitly disable HSTS, keep it — but the scheme
|
|
231
|
+
// probe will simply no-op on a non-Request shim. Callers who want HSTS
|
|
232
|
+
// on standalone responses must ensure `response.url` carries an
|
|
233
|
+
// `https:` URL, or they should pass a pre-built Request with
|
|
234
|
+
// `x-forwarded-proto: https`.
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
response: new Response(response.body, {
|
|
239
|
+
status: response.status,
|
|
240
|
+
statusText: response.statusText,
|
|
241
|
+
headers,
|
|
242
|
+
}),
|
|
243
|
+
nonce: emittedNonce,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ========== Internal: normalization ==========
|
|
248
|
+
|
|
249
|
+
interface NormalizedOptions {
|
|
250
|
+
csp: CspOptions | null;
|
|
251
|
+
hsts: Required<HstsOptions> | null;
|
|
252
|
+
frameOptions: "DENY" | "SAMEORIGIN" | null;
|
|
253
|
+
noSniff: boolean;
|
|
254
|
+
referrerPolicy: ReferrerPolicyValue | null;
|
|
255
|
+
permissionsPolicy: Record<string, string[]> | null;
|
|
256
|
+
xssProtection: "0" | "1" | "1; mode=block" | null;
|
|
257
|
+
extra: Record<string, string>;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function normalizeOptions(options: SecureMiddlewareOptions): NormalizedOptions {
|
|
261
|
+
return {
|
|
262
|
+
csp: options.csp === false ? null : options.csp ?? {},
|
|
263
|
+
hsts:
|
|
264
|
+
options.hsts === false
|
|
265
|
+
? null
|
|
266
|
+
: { ...DEFAULT_HSTS, ...(options.hsts ?? {}) },
|
|
267
|
+
frameOptions:
|
|
268
|
+
options.frameOptions === false
|
|
269
|
+
? null
|
|
270
|
+
: options.frameOptions ?? "DENY",
|
|
271
|
+
noSniff: options.noSniff !== false,
|
|
272
|
+
referrerPolicy:
|
|
273
|
+
options.referrerPolicy === false
|
|
274
|
+
? null
|
|
275
|
+
: options.referrerPolicy ?? DEFAULT_REFERRER_POLICY,
|
|
276
|
+
permissionsPolicy:
|
|
277
|
+
options.permissionsPolicy === false
|
|
278
|
+
? null
|
|
279
|
+
: options.permissionsPolicy ?? { ...DEFAULT_PERMISSIONS_POLICY },
|
|
280
|
+
xssProtection:
|
|
281
|
+
options.xssProtection === false ? null : options.xssProtection ?? "0",
|
|
282
|
+
extra: options.extra ?? {},
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ========== Internal: header application ==========
|
|
287
|
+
|
|
288
|
+
function applySecureHeaders(
|
|
289
|
+
ctx: ManduContext,
|
|
290
|
+
headers: Headers,
|
|
291
|
+
cfg: NormalizedOptions
|
|
292
|
+
): void {
|
|
293
|
+
// --- CSP ---
|
|
294
|
+
if (cfg.csp) {
|
|
295
|
+
// If a nonce was pre-computed in beforeHandle, reuse it to ensure the
|
|
296
|
+
// handler's nonce=… values match what we emit in the header.
|
|
297
|
+
const pinnedNonce = ctx.get<string>(CSP_NONCE_KEY);
|
|
298
|
+
const effective =
|
|
299
|
+
pinnedNonce && cfg.csp.nonce === true
|
|
300
|
+
? { ...cfg.csp, nonce: pinnedNonce }
|
|
301
|
+
: cfg.csp;
|
|
302
|
+
|
|
303
|
+
const built = buildCsp(effective);
|
|
304
|
+
headers.set(built.name, built.header);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// --- HSTS (only when the request is already HTTPS) ---
|
|
308
|
+
//
|
|
309
|
+
// RFC 6797 §7.2: UAs MUST ignore STS on insecure transport, but we also
|
|
310
|
+
// suppress it server-side to avoid leaking the policy across a plaintext
|
|
311
|
+
// channel (where an active MITM could strip it for first-visit users
|
|
312
|
+
// anyway — the "TOFU" problem HSTS is designed to reduce).
|
|
313
|
+
if (cfg.hsts && isHttps(ctx.request)) {
|
|
314
|
+
const parts = [`max-age=${Math.floor(cfg.hsts.maxAge)}`];
|
|
315
|
+
if (cfg.hsts.includeSubDomains) parts.push("includeSubDomains");
|
|
316
|
+
if (cfg.hsts.preload) parts.push("preload");
|
|
317
|
+
headers.set("Strict-Transport-Security", parts.join("; "));
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// --- X-Frame-Options ---
|
|
321
|
+
if (cfg.frameOptions) {
|
|
322
|
+
headers.set("X-Frame-Options", cfg.frameOptions);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// --- X-Content-Type-Options ---
|
|
326
|
+
if (cfg.noSniff) {
|
|
327
|
+
headers.set("X-Content-Type-Options", "nosniff");
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// --- Referrer-Policy ---
|
|
331
|
+
if (cfg.referrerPolicy) {
|
|
332
|
+
headers.set("Referrer-Policy", cfg.referrerPolicy);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// --- Permissions-Policy ---
|
|
336
|
+
if (cfg.permissionsPolicy) {
|
|
337
|
+
const pp = buildPermissionsPolicy(cfg.permissionsPolicy);
|
|
338
|
+
if (pp.length > 0) {
|
|
339
|
+
headers.set("Permissions-Policy", pp);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// --- X-XSS-Protection ---
|
|
344
|
+
if (cfg.xssProtection !== null) {
|
|
345
|
+
headers.set("X-XSS-Protection", cfg.xssProtection);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// --- Arbitrary extras (caller overrides always win) ---
|
|
349
|
+
for (const [k, v] of Object.entries(cfg.extra)) {
|
|
350
|
+
headers.set(k, v);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Build a Permissions-Policy header value.
|
|
356
|
+
*
|
|
357
|
+
* Grammar (simplified): `feature=(allowlist) , feature=(allowlist)`
|
|
358
|
+
* - Bare tokens like `self` go inside the parens as-is.
|
|
359
|
+
* - Origins (URLs) must be wrapped in double quotes per spec.
|
|
360
|
+
* - Empty allowlist `()` denies the feature entirely.
|
|
361
|
+
*
|
|
362
|
+
* We accept the caller's array verbatim; they are responsible for quoting
|
|
363
|
+
* their URL-shaped entries. We do wrap origins that look URL-ish (contain
|
|
364
|
+
* `://`) when they are unquoted, because that's by far the most common
|
|
365
|
+
* mistake and the cost of the heuristic is tiny.
|
|
366
|
+
*/
|
|
367
|
+
function buildPermissionsPolicy(map: Record<string, string[]>): string {
|
|
368
|
+
const entries: string[] = [];
|
|
369
|
+
for (const [feature, allowlist] of Object.entries(map)) {
|
|
370
|
+
if (!Array.isArray(allowlist)) continue;
|
|
371
|
+
const items = allowlist.map(normalizePermissionsItem).join(" ");
|
|
372
|
+
entries.push(`${feature}=(${items})`);
|
|
373
|
+
}
|
|
374
|
+
return entries.join(", ");
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function normalizePermissionsItem(raw: string): string {
|
|
378
|
+
if (raw === "self" || raw === "*") return raw;
|
|
379
|
+
if (raw.startsWith('"') && raw.endsWith('"')) return raw;
|
|
380
|
+
if (raw.includes("://")) return `"${raw}"`;
|
|
381
|
+
return raw;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Determine whether the inbound request is HTTPS.
|
|
386
|
+
*
|
|
387
|
+
* Recognizes:
|
|
388
|
+
* 1. Direct `https:` scheme in `request.url`
|
|
389
|
+
* 2. `X-Forwarded-Proto: https` (de-facto reverse-proxy header)
|
|
390
|
+
* 3. `Forwarded: proto=https` (RFC 7239)
|
|
391
|
+
*
|
|
392
|
+
* We intentionally don't trust these headers on direct (non-proxied)
|
|
393
|
+
* connections — but since the caller is the one opting into HSTS, they're
|
|
394
|
+
* also responsible for running behind a reverse proxy that sanitizes
|
|
395
|
+
* client-supplied `Forwarded` / `X-Forwarded-*` headers. This matches
|
|
396
|
+
* Helmet's and express's documented behavior.
|
|
397
|
+
*/
|
|
398
|
+
function isHttps(request: Request): boolean {
|
|
399
|
+
try {
|
|
400
|
+
if (request.url.startsWith("https:")) return true;
|
|
401
|
+
} catch {
|
|
402
|
+
// Some shims may throw on `.url`; fall through to header checks.
|
|
403
|
+
}
|
|
404
|
+
const xfp = safeGetHeader(request, "x-forwarded-proto");
|
|
405
|
+
if (xfp && xfp.split(",")[0]!.trim().toLowerCase() === "https") return true;
|
|
406
|
+
const fwd = safeGetHeader(request, "forwarded");
|
|
407
|
+
if (fwd && /\bproto=https\b/i.test(fwd)) return true;
|
|
408
|
+
return false;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function safeGetHeader(request: Request, name: string): string | null {
|
|
412
|
+
try {
|
|
413
|
+
return request.headers?.get(name) ?? null;
|
|
414
|
+
} catch {
|
|
415
|
+
return null;
|
|
416
|
+
}
|
|
417
|
+
}
|