@mandujs/core 0.53.3 → 0.54.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/README.md +654 -654
- package/package.json +2 -2
- package/src/a11y/__tests__/run-audit.test.ts +333 -333
- package/src/a11y/fix-hints.ts +76 -76
- package/src/a11y/index.ts +18 -18
- package/src/a11y/run-audit.ts +394 -394
- package/src/a11y/types.ts +125 -125
- package/src/auth/__tests__/login.test.ts +1 -1
- package/src/auth/__tests__/password.test.ts +122 -122
- package/src/auth/__tests__/tokens.test.ts +274 -274
- package/src/auth/__tests__/verification.test.ts +274 -274
- package/src/auth/index.ts +76 -76
- package/src/auth/login.ts +225 -225
- package/src/auth/password.ts +120 -120
- package/src/auth/reset.ts +243 -243
- package/src/auth/tokens.ts +612 -612
- package/src/auth/verification.ts +253 -253
- package/src/brain/__tests__/redactor.test.ts +94 -94
- package/src/brain/adapters/__tests__/_helpers.ts +83 -83
- package/src/brain/adapters/__tests__/anthropic-oauth.test.ts +196 -196
- package/src/brain/adapters/__tests__/chatgpt-auth.test.ts +193 -193
- package/src/brain/adapters/__tests__/openai-oauth.test.ts +209 -209
- package/src/brain/adapters/__tests__/resolver.test.ts +143 -143
- package/src/brain/adapters/anthropic-oauth.ts +1 -1
- package/src/brain/adapters/chatgpt-auth.ts +300 -300
- package/src/brain/adapters/index.ts +319 -319
- package/src/brain/adapters/oauth-flow.ts +439 -439
- package/src/brain/consent.ts +240 -240
- package/src/brain/credentials.ts +396 -396
- package/src/bundler/__tests__/build-runner.ts +113 -113
- package/src/bundler/__tests__/dev-reliability.test.ts +619 -619
- package/src/bundler/__tests__/extended-watch.test.ts +711 -711
- package/src/bundler/__tests__/fast-refresh.test.ts +10 -10
- package/src/bundler/__tests__/hdr.test.ts +353 -353
- package/src/bundler/__tests__/hmr-client.test.ts +532 -532
- package/src/bundler/__tests__/manifest-schema.test.ts +266 -266
- package/src/bundler/__tests__/prod-smoke.test.ts +138 -138
- package/src/bundler/__tests__/reverse-import-graph.test.ts +519 -519
- package/src/bundler/__tests__/slot-dispatch.test.ts +573 -573
- package/src/bundler/__tests__/url-cap-and-slot-regex.test.ts +286 -286
- package/src/bundler/__tests__/vendor-cache.test.ts +455 -455
- package/src/bundler/budget.ts +404 -404
- package/src/bundler/build.test.ts +179 -179
- package/src/bundler/build.ts +55 -55
- package/src/bundler/dev.ts +42 -42
- package/src/bundler/manifest-schema.ts +301 -301
- package/src/bundler/plugins/__tests__/block-generated-imports.test.ts +263 -263
- package/src/bundler/plugins/__tests__/react-compiler-config.test.ts +83 -83
- package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -110
- package/src/bundler/plugins/block-generated-imports.ts +155 -155
- package/src/bundler/plugins/index.ts +83 -83
- package/src/bundler/plugins/react-compiler-config.ts +108 -108
- package/src/bundler/plugins/react-compiler-lint.ts +253 -253
- package/src/bundler/plugins/react-compiler.ts +162 -162
- package/src/bundler/reverse-import-graph.ts +339 -339
- package/src/bundler/safe-build.test.ts +201 -201
- package/src/bundler/safe-build.ts +103 -103
- package/src/bundler/scenario-matrix.ts +229 -229
- package/src/bundler/types.ts +10 -10
- package/src/bundler/vendor-cache-types.ts +130 -130
- package/src/bundler/vendor-cache.ts +526 -526
- package/src/change/snapshot.ts +18 -18
- package/src/client/Form.tsx +105 -105
- package/src/client/Link.tsx +9 -9
- package/src/client/__tests__/use-sse.test.ts +153 -153
- package/src/client/globals.ts +1 -1
- package/src/client/hooks.ts +362 -362
- package/src/client/hydrate.ts +340 -340
- package/src/client/prefetch-helper.ts +55 -55
- package/src/client/router.ts +11 -11
- package/src/client/runtime.ts +47 -47
- package/src/client/serialize.ts +404 -404
- package/src/client/use-fetch.ts +6 -6
- package/src/client/use-head.ts +197 -197
- package/src/client/use-sse.ts +378 -378
- package/src/client/window-state.ts +101 -101
- package/src/components/Image.tsx +162 -162
- package/src/config/validate.ts +3 -3
- package/src/config/watcher.ts +311 -311
- package/src/constants.ts +40 -40
- package/src/content/collection.ts +8 -8
- package/src/content/content-layer.ts +7 -7
- package/src/content/data-store.ts +245 -245
- package/src/content/frontmatter.ts +189 -189
- package/src/content/loader-context.ts +171 -171
- package/src/content/loaders/api.ts +216 -216
- package/src/content/loaders/file.ts +172 -172
- package/src/content/loaders/glob.ts +253 -253
- package/src/content/loaders/index.ts +34 -34
- package/src/content/loaders/types.ts +137 -137
- package/src/content/meta-store.ts +209 -209
- package/src/content/prebuild.test.ts +571 -571
- package/src/content/prebuild.ts +636 -636
- package/src/content/schema.ts +20 -20
- package/src/content/sidebar.ts +630 -630
- package/src/content/slug.ts +110 -110
- package/src/content/types.ts +282 -282
- package/src/content/watcher.ts +135 -135
- package/src/contract/client-safe.test.ts +42 -42
- package/src/contract/client-safe.ts +114 -114
- package/src/contract/define.ts +11 -11
- package/src/contract/index.ts +1 -1
- package/src/contract/normalize.test.ts +276 -276
- package/src/contract/normalize.ts +410 -410
- package/src/contract/registry.test.ts +206 -206
- package/src/contract/route-helpers.ts +1 -1
- package/src/contract/rpc.ts +443 -443
- package/src/contract/schema.ts +48 -48
- package/src/contract/types.ts +58 -58
- package/src/db/__tests__/db.test.ts +482 -482
- package/src/db/index.ts +138 -138
- package/src/db/migrations/history-table.ts +345 -345
- package/src/db/migrations/index.ts +3 -3
- package/src/db/migrations/lock.ts +324 -324
- package/src/db/migrations/runner.ts +650 -650
- package/src/deploy/cache.ts +140 -140
- package/src/deploy/compile/vercel.ts +344 -344
- package/src/deploy/index.ts +87 -87
- package/src/deploy/inference/brain.ts +268 -268
- package/src/deploy/inference/context.ts +182 -182
- package/src/deploy/inference/filling-extract.ts +245 -245
- package/src/deploy/inference/heuristic.ts +182 -182
- package/src/deploy/intent.ts +173 -173
- package/src/deploy/plan.ts +178 -178
- package/src/design/__tests__/agents-link.test.ts +109 -109
- package/src/design/__tests__/extract-patch-diff.test.ts +265 -265
- package/src/design/__tests__/lint.test.ts +110 -110
- package/src/design/__tests__/parser.test.ts +195 -195
- package/src/design/__tests__/tailwind-theme.test.ts +229 -229
- package/src/design/agents-link.ts +165 -165
- package/src/design/diff.ts +138 -138
- package/src/design/extract.ts +285 -285
- package/src/design/index.ts +102 -102
- package/src/design/lint.ts +209 -209
- package/src/design/parser.ts +555 -555
- package/src/design/patch.ts +241 -241
- package/src/design/scaffold.ts +147 -147
- package/src/design/tailwind-theme.ts +441 -441
- package/src/design/types.ts +210 -210
- package/src/desktop/__tests__/webview-fallback.test.ts +254 -254
- package/src/desktop/__tests__/window.test.ts +248 -248
- package/src/desktop/__tests__/worker.test.ts +266 -266
- package/src/desktop/index.ts +43 -43
- package/src/desktop/types.ts +158 -158
- package/src/desktop/worker.ts +180 -180
- package/src/dev-error-overlay/__tests__/overlay-injector.test.ts +241 -241
- package/src/dev-error-overlay/index.ts +30 -30
- package/src/dev-error-overlay/overlay-injector.ts +243 -243
- package/src/dev-error-overlay/overlay-styles.ts +52 -52
- package/src/dev-error-overlay/types.ts +66 -66
- package/src/devtools/ai/context-builder.ts +375 -375
- package/src/devtools/ai/index.ts +25 -25
- package/src/devtools/ai/mcp-connector.ts +25 -25
- package/src/devtools/client/catchers/error-catcher.ts +344 -344
- package/src/devtools/client/catchers/index.ts +18 -18
- package/src/devtools/client/components/index.ts +39 -39
- package/src/devtools/client/components/mandu-character.tsx +331 -331
- package/src/devtools/client/components/overlay.tsx +368 -368
- package/src/devtools/client/components/panel/errors-panel.tsx +259 -259
- package/src/devtools/client/components/panel/guard-panel.tsx +30 -30
- package/src/devtools/client/components/panel/islands-panel.tsx +320 -320
- package/src/devtools/client/components/panel/network-panel.tsx +291 -291
- package/src/devtools/client/components/panel/panel-container.tsx +500 -500
- package/src/devtools/client/components/panel/preview-panel.tsx +46 -46
- package/src/devtools/client/filters/context-filters.ts +282 -282
- package/src/devtools/client/filters/index.ts +16 -16
- package/src/devtools/client/index.ts +63 -63
- package/src/devtools/client/persistence.ts +335 -335
- package/src/devtools/hook/create-hook.ts +207 -207
- package/src/devtools/hook/index.ts +13 -13
- package/src/devtools/index.ts +439 -439
- package/src/devtools/init.ts +265 -265
- package/src/devtools/protocol.ts +237 -237
- package/src/devtools/server/index.ts +17 -17
- package/src/devtools/server/source-context.ts +450 -450
- package/src/devtools/types.ts +35 -35
- package/src/devtools/worker/index.ts +25 -25
- package/src/devtools/worker/redaction-worker.ts +233 -233
- package/src/devtools/worker/worker-manager.ts +410 -410
- package/src/diagnose/__tests__/checks.test.ts +451 -451
- package/src/diagnose/checks.ts +832 -832
- package/src/diagnose/index.ts +17 -17
- package/src/diagnose/run.ts +93 -93
- package/src/diagnose/types.ts +53 -53
- package/src/email/__tests__/email.test.ts +355 -355
- package/src/email/index.ts +282 -282
- package/src/email/smtp.ts +64 -64
- package/src/error/domains.ts +265 -265
- package/src/error/result.ts +60 -60
- package/src/error/types.ts +6 -6
- package/src/errors/extractor.ts +409 -409
- package/src/errors/index.ts +19 -19
- package/src/filling/__tests__/session-sqlite.test.ts +454 -454
- package/src/filling/auth.ts +308 -308
- package/src/filling/cookie-codec.ts +299 -299
- package/src/filling/deps.ts +265 -265
- package/src/filling/session-sqlite.ts +617 -617
- package/src/filling/sse.ts +5 -5
- package/src/filling/ws.ts +78 -78
- package/src/generator/index.ts +3 -3
- package/src/guard/__tests__/design-inline-class.test.ts +219 -219
- package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -347
- package/src/guard/analyzer.ts +360 -360
- package/src/guard/auto-correct.ts +1 -1
- package/src/guard/contract-guard.ts +9 -9
- package/src/guard/define-rule.ts +243 -243
- package/src/guard/design-inline-class.ts +393 -393
- package/src/guard/file-type.test.ts +24 -24
- package/src/guard/graph.ts +1 -1
- package/src/guard/healing.ts +36 -36
- package/src/guard/presets/atomic.ts +70 -70
- package/src/guard/presets/clean.ts +77 -77
- package/src/guard/presets/fsd.ts +79 -79
- package/src/guard/presets/hexagonal.ts +68 -68
- package/src/guard/reporter.ts +442 -442
- package/src/guard/rule-presets.ts +379 -379
- package/src/guard/semantic-slots.ts +1 -1
- package/src/guard/suggestions.ts +358 -358
- package/src/guard/tsgolint-bridge.ts +512 -512
- package/src/guard/types.ts +348 -348
- package/src/guard/watcher.ts +405 -405
- package/src/i18n/define.ts +126 -126
- package/src/i18n/index.ts +52 -52
- package/src/i18n/message-registry.ts +173 -173
- package/src/i18n/types.ts +112 -112
- package/src/id/index.ts +105 -105
- package/src/index.ts +2 -2
- package/src/kitchen/api/agent-devtools-api.ts +779 -544
- package/src/kitchen/api/errors-grouping.ts +126 -0
- package/src/kitchen/kitchen-handler.ts +192 -63
- package/src/kitchen/kitchen-ui.ts +842 -464
- package/src/logging/index.ts +22 -22
- package/src/logging/transports.ts +365 -365
- package/src/middleware/bridge.ts +147 -147
- package/src/middleware/compose.ts +134 -134
- package/src/middleware/compress.ts +62 -62
- package/src/middleware/cors.ts +47 -47
- package/src/middleware/csrf.ts +328 -328
- package/src/middleware/define.ts +132 -132
- package/src/middleware/jwt.ts +134 -134
- package/src/middleware/logger.ts +58 -58
- package/src/middleware/oauth/__tests__/oauth.test.ts +1 -1
- package/src/middleware/oauth/index.ts +505 -505
- package/src/middleware/oauth/providers.ts +115 -115
- package/src/middleware/rate-limit/index.ts +522 -522
- package/src/middleware/rate-limit/sqlite-store.ts +382 -382
- package/src/middleware/scheduler-cron.ts +96 -96
- package/src/middleware/secure/__tests__/secure.test.ts +360 -360
- package/src/middleware/secure/csp.ts +193 -193
- package/src/middleware/session.ts +174 -174
- package/src/middleware/timeout.ts +55 -55
- package/src/observability/logger-adapter.ts +36 -36
- package/src/observability/sqlite-store.ts +254 -254
- package/src/openapi/generator.ts +1 -1
- package/src/openapi/openapi.test.ts +43 -43
- package/src/perf/__tests__/user-marks.test.ts +354 -354
- package/src/perf/index.ts +133 -133
- package/src/plugins/__tests__/lifecycle-integration.test.ts +272 -272
- package/src/plugins/__tests__/runner.test.ts +409 -409
- package/src/plugins/define.ts +124 -124
- package/src/plugins/examples/dep-check-plugin.ts +80 -80
- package/src/plugins/examples/prerender-cache-plugin.ts +111 -111
- package/src/plugins/examples/sitemap-plugin.ts +65 -65
- package/src/plugins/runner.ts +361 -361
- package/src/plugins/types.ts +368 -368
- package/src/report/index.ts +1 -1
- package/src/resource/__tests__/generator.test.ts +32 -32
- package/src/resource/ddl/__tests__/diff.test.ts +639 -639
- package/src/resource/ddl/__tests__/emit.test.ts +823 -823
- package/src/resource/ddl/__tests__/snapshot.test.ts +499 -499
- package/src/resource/ddl/emit.ts +559 -559
- package/src/resource/ddl/persistence-types.ts +218 -218
- package/src/resource/ddl/type-map.ts +223 -223
- package/src/resource/ddl/types.ts +232 -232
- package/src/resource/generator-repo.ts +630 -630
- package/src/resource/schema.ts +1 -1
- package/src/router/fs-patterns.test.ts +96 -96
- package/src/router/fs-routes.ts +6 -6
- package/src/routes/index.ts +74 -74
- package/src/routes/metadata-routes.ts +427 -427
- package/src/routes/types.ts +341 -341
- package/src/runtime/__tests__/error-boundary-redaction.test.ts +141 -141
- package/src/runtime/__tests__/hdr-client.test.ts +223 -223
- package/src/runtime/__tests__/http-errors.test.ts +117 -117
- package/src/runtime/__tests__/not-found.test.ts +152 -152
- package/src/runtime/adapter.ts +47 -47
- package/src/runtime/boundary.tsx +252 -252
- package/src/runtime/cache.ts +494 -494
- package/src/runtime/compose.ts +222 -222
- package/src/runtime/fast-refresh-runtime.ts +322 -322
- package/src/runtime/handler.ts +65 -65
- package/src/runtime/handlers.ts +300 -300
- package/src/runtime/http-errors.ts +113 -113
- package/src/runtime/image-handler.ts +1 -1
- package/src/runtime/lifecycle.ts +381 -381
- package/src/runtime/logger.test.ts +345 -345
- package/src/runtime/middleware.ts +264 -264
- package/src/runtime/not-found.ts +93 -93
- package/src/runtime/openapi-endpoint.ts +236 -236
- package/src/runtime/ppr.ts +74 -74
- package/src/runtime/registry.ts +171 -171
- package/src/runtime/router.ts +105 -105
- package/src/runtime/server.ts +67 -67
- package/src/runtime/shims.ts +48 -48
- package/src/runtime/trace.ts +144 -144
- package/src/scheduler/validate.ts +169 -169
- package/src/seo/index.ts +219 -219
- package/src/seo/integration/ssr.ts +306 -306
- package/src/seo/render/basic.ts +435 -435
- package/src/seo/render/index.ts +143 -143
- package/src/seo/render/jsonld.ts +539 -539
- package/src/seo/render/opengraph.ts +197 -197
- package/src/seo/render/robots.ts +116 -116
- package/src/seo/render/sitemap.ts +137 -137
- package/src/seo/render/twitter.ts +127 -127
- package/src/seo/resolve/opengraph.ts +143 -143
- package/src/seo/resolve/robots.ts +73 -73
- package/src/seo/resolve/title.ts +94 -94
- package/src/seo/resolve/twitter.ts +73 -73
- package/src/seo/resolve/url.ts +104 -104
- package/src/seo/routes/index.ts +290 -290
- package/src/seo/types.ts +588 -588
- package/src/slot/validator.ts +39 -39
- package/src/storage/s3/__tests__/s3.test.ts +479 -479
- package/src/storage/s3/index.ts +412 -412
- package/src/testing/__tests__/assertions.test.ts +632 -632
- package/src/testing/__tests__/reporter.test.ts +454 -454
- package/src/testing/assertions.ts +986 -986
- package/src/testing/db.ts +157 -157
- package/src/testing/mocks.ts +203 -203
- package/src/testing/session.ts +190 -190
- package/src/types/branded.ts +56 -56
- package/src/types/index.ts +1 -1
- package/src/utils/safe-io.ts +188 -188
- package/src/utils/string-safe.ts +298 -298
- package/src/watcher/watcher.ts +18 -18
|
@@ -1,360 +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
|
-
});
|
|
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
|
+
});
|