@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
package/src/middleware/csrf.ts
CHANGED
|
@@ -1,328 +1,328 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* CSRF Middleware Plugin
|
|
3
|
-
*
|
|
4
|
-
* Double-submit cookie pattern (stateless, no session required).
|
|
5
|
-
*
|
|
6
|
-
* cookie: __csrf=<token>
|
|
7
|
-
* header: x-csrf-token: <token> (or form field _csrf)
|
|
8
|
-
*
|
|
9
|
-
* Both values MUST match AND the token's HMAC signature MUST verify.
|
|
10
|
-
*
|
|
11
|
-
* Internally delegates to `Bun.CSRF.generate` / `Bun.CSRF.verify` when
|
|
12
|
-
* available (Bun ≥ 1.3). Verified present and stable in Bun 1.3.10:
|
|
13
|
-
* Bun.CSRF.generate(secret, { maxAge? }) → URL-safe base64 token
|
|
14
|
-
* Bun.CSRF.verify(token, { secret, maxAge? }) → boolean
|
|
15
|
-
*
|
|
16
|
-
* A pure `crypto.subtle` + `crypto.getRandomValues` fallback is provided for
|
|
17
|
-
* compatibility. The exported `csrf()` API is identical either way.
|
|
18
|
-
*
|
|
19
|
-
* @example
|
|
20
|
-
* ```ts
|
|
21
|
-
* import { csrf } from "@mandujs/core/middleware";
|
|
22
|
-
*
|
|
23
|
-
* export default Mandu.filling()
|
|
24
|
-
* .use(csrf({ secret: process.env.CSRF_SECRET! }))
|
|
25
|
-
* .post((ctx) => ctx.ok({ ok: true }));
|
|
26
|
-
* ```
|
|
27
|
-
*/
|
|
28
|
-
import type { ManduContext, CookieOptions } from "../filling/context";
|
|
29
|
-
|
|
30
|
-
// ========== Types ==========
|
|
31
|
-
|
|
32
|
-
export interface CsrfMiddlewareOptions {
|
|
33
|
-
/** Required. Used to HMAC-sign CSRF tokens. */
|
|
34
|
-
secret: string;
|
|
35
|
-
/** Cookie name. Default: "__csrf". */
|
|
36
|
-
cookieName?: string;
|
|
37
|
-
/** Header name checked on unsafe methods. Default: "x-csrf-token". */
|
|
38
|
-
headerName?: string;
|
|
39
|
-
/** Form field name checked as fallback. Default: "_csrf". */
|
|
40
|
-
fieldName?: string;
|
|
41
|
-
/** Methods that skip validation. Default: ["GET","HEAD","OPTIONS"]. */
|
|
42
|
-
safeMethods?: string[];
|
|
43
|
-
/** Cookie attribute overrides (merged with sensible defaults). */
|
|
44
|
-
cookieOptions?: {
|
|
45
|
-
/** Default: false — client JS needs to read the token to submit it in a header. */
|
|
46
|
-
httpOnly?: boolean;
|
|
47
|
-
/** Default: NODE_ENV === "production". */
|
|
48
|
-
secure?: boolean;
|
|
49
|
-
/** Default: "lax". */
|
|
50
|
-
sameSite?: "strict" | "lax" | "none";
|
|
51
|
-
/** Default: "/". */
|
|
52
|
-
path?: string;
|
|
53
|
-
/** Default: 86400 (1 day). */
|
|
54
|
-
maxAge?: number;
|
|
55
|
-
/** Optional cookie domain. */
|
|
56
|
-
domain?: string;
|
|
57
|
-
};
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/** Middleware signature matching `jwt.ts`. */
|
|
61
|
-
type Middleware = (ctx: ManduContext) => Promise<Response | void>;
|
|
62
|
-
|
|
63
|
-
// ========== Implementation ==========
|
|
64
|
-
|
|
65
|
-
const DEFAULT_COOKIE_NAME = "__csrf";
|
|
66
|
-
const DEFAULT_HEADER_NAME = "x-csrf-token";
|
|
67
|
-
const DEFAULT_FIELD_NAME = "_csrf";
|
|
68
|
-
const DEFAULT_SAFE_METHODS: readonly string[] = ["GET", "HEAD", "OPTIONS"];
|
|
69
|
-
const DEFAULT_MAX_AGE = 86400; // 1 day
|
|
70
|
-
/** Guard against memory-exhaustion attacks via oversized tokens. */
|
|
71
|
-
const MAX_TOKEN_LENGTH = 512;
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* CSRF protection middleware (double-submit cookie pattern).
|
|
75
|
-
*
|
|
76
|
-
* Behavior:
|
|
77
|
-
* 1. Ensures a signed CSRF token cookie is present. Issues a fresh one if
|
|
78
|
-
* the existing cookie is missing or its signature fails to verify.
|
|
79
|
-
* 2. For safe methods (GET/HEAD/OPTIONS): continues without further checks.
|
|
80
|
-
* 3. For unsafe methods: reads the submitted token from the configured
|
|
81
|
-
* header (preferred) or form field (fallback for form content types),
|
|
82
|
-
* then confirms:
|
|
83
|
-
* (a) submitted token === cookie token (constant-time equality)
|
|
84
|
-
* (b) the token's HMAC signature still verifies with `secret`
|
|
85
|
-
* Any failure returns 403 without leaking which check failed.
|
|
86
|
-
*/
|
|
87
|
-
export function csrf(options: CsrfMiddlewareOptions): Middleware {
|
|
88
|
-
if (!options.secret || typeof options.secret !== "string") {
|
|
89
|
-
throw new Error("[Mandu CSRF] `secret` is required and must be a non-empty string");
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
const {
|
|
93
|
-
secret,
|
|
94
|
-
cookieName = DEFAULT_COOKIE_NAME,
|
|
95
|
-
headerName = DEFAULT_HEADER_NAME,
|
|
96
|
-
fieldName = DEFAULT_FIELD_NAME,
|
|
97
|
-
safeMethods = DEFAULT_SAFE_METHODS,
|
|
98
|
-
} = options;
|
|
99
|
-
|
|
100
|
-
const normalizedSafeMethods = new Set(safeMethods.map((m) => m.toUpperCase()));
|
|
101
|
-
const cookieOptions = resolveCookieOptions(options.cookieOptions);
|
|
102
|
-
const maxAgeSec = cookieOptions.maxAge ?? DEFAULT_MAX_AGE;
|
|
103
|
-
|
|
104
|
-
return async (ctx: ManduContext): Promise<Response | void> => {
|
|
105
|
-
const method = ctx.request.method.toUpperCase();
|
|
106
|
-
|
|
107
|
-
// 1. Ensure a valid CSRF cookie is present for the next unsafe request.
|
|
108
|
-
const existing = ctx.cookies.get(cookieName);
|
|
109
|
-
let activeCookieToken: string | null = null;
|
|
110
|
-
|
|
111
|
-
if (typeof existing === "string" && isAcceptableToken(existing)) {
|
|
112
|
-
const valid = await verifyToken(existing, secret, maxAgeSec);
|
|
113
|
-
if (valid) {
|
|
114
|
-
// Keep existing token (no unnecessary rotation).
|
|
115
|
-
activeCookieToken = existing;
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
if (activeCookieToken === null) {
|
|
120
|
-
activeCookieToken = await generateToken(secret, maxAgeSec);
|
|
121
|
-
ctx.cookies.set(cookieName, activeCookieToken, cookieOptions);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// 2. Safe methods pass through.
|
|
125
|
-
if (normalizedSafeMethods.has(method)) {
|
|
126
|
-
return;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// 3. Unsafe methods: read + validate submitted token.
|
|
130
|
-
const submitted = await extractSubmittedToken(ctx, headerName, fieldName);
|
|
131
|
-
|
|
132
|
-
if (!submitted || !isAcceptableToken(submitted)) {
|
|
133
|
-
return ctx.forbidden("CSRF token missing or invalid");
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
// Constant-time equality between submitted token and cookie token.
|
|
137
|
-
if (!safeEqual(submitted, activeCookieToken)) {
|
|
138
|
-
return ctx.forbidden("CSRF token missing or invalid");
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// HMAC verification on the submitted token (prevents forged cookies from
|
|
142
|
-
// sibling subdomains since they cannot sign with our secret).
|
|
143
|
-
const verified = await verifyToken(submitted, secret, maxAgeSec);
|
|
144
|
-
if (!verified) {
|
|
145
|
-
return ctx.forbidden("CSRF token missing or invalid");
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// Valid — continue.
|
|
149
|
-
};
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// ========== Helpers ==========
|
|
153
|
-
|
|
154
|
-
/**
|
|
155
|
-
* Resolve cookie options with production-safe defaults.
|
|
156
|
-
*
|
|
157
|
-
* `httpOnly: false` by default: a CSRF token cookie needs to be readable by
|
|
158
|
-
* client-side JS so the app can echo it back in the header. Callers who set
|
|
159
|
-
* the token from the server (e.g. via a hidden form field) may opt into
|
|
160
|
-
* `httpOnly: true`.
|
|
161
|
-
*/
|
|
162
|
-
function resolveCookieOptions(overrides?: CsrfMiddlewareOptions["cookieOptions"]): CookieOptions {
|
|
163
|
-
const isProd = typeof process !== "undefined" && process.env?.NODE_ENV === "production";
|
|
164
|
-
return {
|
|
165
|
-
httpOnly: overrides?.httpOnly ?? false,
|
|
166
|
-
secure: overrides?.secure ?? isProd,
|
|
167
|
-
sameSite: overrides?.sameSite ?? "lax",
|
|
168
|
-
path: overrides?.path ?? "/",
|
|
169
|
-
maxAge: overrides?.maxAge ?? DEFAULT_MAX_AGE,
|
|
170
|
-
domain: overrides?.domain,
|
|
171
|
-
};
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/** Read submitted token from header, falling back to form field if applicable. */
|
|
175
|
-
async function extractSubmittedToken(
|
|
176
|
-
ctx: ManduContext,
|
|
177
|
-
headerName: string,
|
|
178
|
-
fieldName: string
|
|
179
|
-
): Promise<string | null> {
|
|
180
|
-
// Header wins when present (cheap, safe, no body consumption).
|
|
181
|
-
const headerVal = ctx.headers.get(headerName);
|
|
182
|
-
if (typeof headerVal === "string" && headerVal.length > 0) {
|
|
183
|
-
return headerVal;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
// Form fallback: only when the request advertises a form-like content type.
|
|
187
|
-
// JSON bodies are NOT scanned — header submission is the canonical path.
|
|
188
|
-
const contentType = (ctx.headers.get("content-type") ?? "").toLowerCase();
|
|
189
|
-
const isForm =
|
|
190
|
-
contentType.includes("application/x-www-form-urlencoded") ||
|
|
191
|
-
contentType.includes("multipart/form-data");
|
|
192
|
-
if (!isForm) return null;
|
|
193
|
-
|
|
194
|
-
try {
|
|
195
|
-
// Clone so downstream handlers can still read the body.
|
|
196
|
-
const form = await ctx.request.clone().formData();
|
|
197
|
-
const fieldVal = form.get(fieldName);
|
|
198
|
-
return typeof fieldVal === "string" ? fieldVal : null;
|
|
199
|
-
} catch {
|
|
200
|
-
return null;
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/** Validate token shape before running expensive crypto. */
|
|
205
|
-
function isAcceptableToken(token: string): boolean {
|
|
206
|
-
return (
|
|
207
|
-
typeof token === "string" &&
|
|
208
|
-
token.length > 0 &&
|
|
209
|
-
token.length <= MAX_TOKEN_LENGTH
|
|
210
|
-
);
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/**
|
|
214
|
-
* Constant-time string comparison to avoid timing-oracle attacks.
|
|
215
|
-
* Returns `false` immediately on length mismatch (lengths themselves are not
|
|
216
|
-
* secret for our fixed-format tokens), then XORs character codes over the
|
|
217
|
-
* full length before folding into a single diff bit.
|
|
218
|
-
*/
|
|
219
|
-
function safeEqual(a: string, b: string): boolean {
|
|
220
|
-
if (a.length !== b.length) return false;
|
|
221
|
-
let diff = 0;
|
|
222
|
-
for (let i = 0; i < a.length; i++) {
|
|
223
|
-
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
224
|
-
}
|
|
225
|
-
return diff === 0;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// ========== Token crypto (Bun.CSRF preferred, fallback to crypto.subtle) ==========
|
|
229
|
-
|
|
230
|
-
/**
|
|
231
|
-
* Runtime capability probe for `Bun.CSRF`. Done once at module load — avoids
|
|
232
|
-
* re-checking on every request and allows non-Bun runtimes to use the
|
|
233
|
-
* fallback implementation.
|
|
234
|
-
*/
|
|
235
|
-
const bunCsrf = resolveBunCsrf();
|
|
236
|
-
|
|
237
|
-
function resolveBunCsrf():
|
|
238
|
-
| {
|
|
239
|
-
generate: (secret: string, options?: { maxAge?: number }) => string;
|
|
240
|
-
verify: (token: string, options: { secret: string; maxAge?: number }) => boolean;
|
|
241
|
-
}
|
|
242
|
-
| null {
|
|
243
|
-
if (typeof globalThis === "undefined") return null;
|
|
244
|
-
const bun = (globalThis as { Bun?: { CSRF?: unknown } }).Bun;
|
|
245
|
-
if (!bun || typeof bun !== "object" || bun === null) return null;
|
|
246
|
-
const csrfApi = (bun as { CSRF?: unknown }).CSRF;
|
|
247
|
-
if (!csrfApi || typeof csrfApi !== "object") return null;
|
|
248
|
-
const api = csrfApi as {
|
|
249
|
-
generate?: unknown;
|
|
250
|
-
verify?: unknown;
|
|
251
|
-
};
|
|
252
|
-
if (typeof api.generate !== "function" || typeof api.verify !== "function") {
|
|
253
|
-
return null;
|
|
254
|
-
}
|
|
255
|
-
return api as {
|
|
256
|
-
generate: (secret: string, options?: { maxAge?: number }) => string;
|
|
257
|
-
verify: (token: string, options: { secret: string; maxAge?: number }) => boolean;
|
|
258
|
-
};
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
async function generateToken(secret: string, maxAgeSec: number): Promise<string> {
|
|
262
|
-
if (bunCsrf) {
|
|
263
|
-
// Bun.CSRF handles timestamp + random + HMAC in native code.
|
|
264
|
-
return bunCsrf.generate(secret, { maxAge: maxAgeSec });
|
|
265
|
-
}
|
|
266
|
-
return fallbackGenerate(secret);
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
async function verifyToken(token: string, secret: string, maxAgeSec: number): Promise<boolean> {
|
|
270
|
-
if (bunCsrf) {
|
|
271
|
-
try {
|
|
272
|
-
return bunCsrf.verify(token, { secret, maxAge: maxAgeSec });
|
|
273
|
-
} catch {
|
|
274
|
-
return false;
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
return fallbackVerify(token, secret);
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
// ----- Fallback (no Bun.CSRF available) -----
|
|
281
|
-
|
|
282
|
-
/**
|
|
283
|
-
* Token format: `<random-b64url>.<hmac-b64url>`
|
|
284
|
-
* - random: 32 bytes via `crypto.getRandomValues`
|
|
285
|
-
* - hmac: HMAC-SHA256(random, secret)
|
|
286
|
-
*
|
|
287
|
-
* Same pattern as `packages/core/src/filling/session.ts` (`hmacSign`,
|
|
288
|
-
* line 216-227) so we don't introduce a second crypto code path.
|
|
289
|
-
*/
|
|
290
|
-
async function fallbackGenerate(secret: string): Promise<string> {
|
|
291
|
-
const random = new Uint8Array(32);
|
|
292
|
-
crypto.getRandomValues(random);
|
|
293
|
-
const randomPart = base64UrlEncode(random);
|
|
294
|
-
const sig = await hmacSignB64Url(randomPart, secret);
|
|
295
|
-
return `${randomPart}.${sig}`;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
async function fallbackVerify(token: string, secret: string): Promise<boolean> {
|
|
299
|
-
const dotIdx = token.lastIndexOf(".");
|
|
300
|
-
if (dotIdx <= 0 || dotIdx === token.length - 1) return false;
|
|
301
|
-
const randomPart = token.slice(0, dotIdx);
|
|
302
|
-
const signature = token.slice(dotIdx + 1);
|
|
303
|
-
if (!randomPart || !signature) return false;
|
|
304
|
-
const expected = await hmacSignB64Url(randomPart, secret);
|
|
305
|
-
// Constant-time comparison on signatures.
|
|
306
|
-
return safeEqual(signature, expected);
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
async function hmacSignB64Url(data: string, secret: string): Promise<string> {
|
|
310
|
-
const encoder = new TextEncoder();
|
|
311
|
-
const key = await crypto.subtle.importKey(
|
|
312
|
-
"raw",
|
|
313
|
-
encoder.encode(secret),
|
|
314
|
-
{ name: "HMAC", hash: "SHA-256" },
|
|
315
|
-
false,
|
|
316
|
-
["sign"]
|
|
317
|
-
);
|
|
318
|
-
const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(data));
|
|
319
|
-
return base64UrlEncode(new Uint8Array(sig));
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
function base64UrlEncode(bytes: Uint8Array): string {
|
|
323
|
-
let binary = "";
|
|
324
|
-
for (let i = 0; i < bytes.length; i++) {
|
|
325
|
-
binary += String.fromCharCode(bytes[i]);
|
|
326
|
-
}
|
|
327
|
-
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
328
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* CSRF Middleware Plugin
|
|
3
|
+
*
|
|
4
|
+
* Double-submit cookie pattern (stateless, no session required).
|
|
5
|
+
*
|
|
6
|
+
* cookie: __csrf=<token>
|
|
7
|
+
* header: x-csrf-token: <token> (or form field _csrf)
|
|
8
|
+
*
|
|
9
|
+
* Both values MUST match AND the token's HMAC signature MUST verify.
|
|
10
|
+
*
|
|
11
|
+
* Internally delegates to `Bun.CSRF.generate` / `Bun.CSRF.verify` when
|
|
12
|
+
* available (Bun ≥ 1.3). Verified present and stable in Bun 1.3.10:
|
|
13
|
+
* Bun.CSRF.generate(secret, { maxAge? }) → URL-safe base64 token
|
|
14
|
+
* Bun.CSRF.verify(token, { secret, maxAge? }) → boolean
|
|
15
|
+
*
|
|
16
|
+
* A pure `crypto.subtle` + `crypto.getRandomValues` fallback is provided for
|
|
17
|
+
* compatibility. The exported `csrf()` API is identical either way.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```ts
|
|
21
|
+
* import { csrf } from "@mandujs/core/middleware";
|
|
22
|
+
*
|
|
23
|
+
* export default Mandu.filling()
|
|
24
|
+
* .use(csrf({ secret: process.env.CSRF_SECRET! }))
|
|
25
|
+
* .post((ctx) => ctx.ok({ ok: true }));
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
import type { ManduContext, CookieOptions } from "../filling/context";
|
|
29
|
+
|
|
30
|
+
// ========== Types ==========
|
|
31
|
+
|
|
32
|
+
export interface CsrfMiddlewareOptions {
|
|
33
|
+
/** Required. Used to HMAC-sign CSRF tokens. */
|
|
34
|
+
secret: string;
|
|
35
|
+
/** Cookie name. Default: "__csrf". */
|
|
36
|
+
cookieName?: string;
|
|
37
|
+
/** Header name checked on unsafe methods. Default: "x-csrf-token". */
|
|
38
|
+
headerName?: string;
|
|
39
|
+
/** Form field name checked as fallback. Default: "_csrf". */
|
|
40
|
+
fieldName?: string;
|
|
41
|
+
/** Methods that skip validation. Default: ["GET","HEAD","OPTIONS"]. */
|
|
42
|
+
safeMethods?: string[];
|
|
43
|
+
/** Cookie attribute overrides (merged with sensible defaults). */
|
|
44
|
+
cookieOptions?: {
|
|
45
|
+
/** Default: false — client JS needs to read the token to submit it in a header. */
|
|
46
|
+
httpOnly?: boolean;
|
|
47
|
+
/** Default: NODE_ENV === "production". */
|
|
48
|
+
secure?: boolean;
|
|
49
|
+
/** Default: "lax". */
|
|
50
|
+
sameSite?: "strict" | "lax" | "none";
|
|
51
|
+
/** Default: "/". */
|
|
52
|
+
path?: string;
|
|
53
|
+
/** Default: 86400 (1 day). */
|
|
54
|
+
maxAge?: number;
|
|
55
|
+
/** Optional cookie domain. */
|
|
56
|
+
domain?: string;
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Middleware signature matching `jwt.ts`. */
|
|
61
|
+
type Middleware = (ctx: ManduContext) => Promise<Response | void>;
|
|
62
|
+
|
|
63
|
+
// ========== Implementation ==========
|
|
64
|
+
|
|
65
|
+
const DEFAULT_COOKIE_NAME = "__csrf";
|
|
66
|
+
const DEFAULT_HEADER_NAME = "x-csrf-token";
|
|
67
|
+
const DEFAULT_FIELD_NAME = "_csrf";
|
|
68
|
+
const DEFAULT_SAFE_METHODS: readonly string[] = ["GET", "HEAD", "OPTIONS"];
|
|
69
|
+
const DEFAULT_MAX_AGE = 86400; // 1 day
|
|
70
|
+
/** Guard against memory-exhaustion attacks via oversized tokens. */
|
|
71
|
+
const MAX_TOKEN_LENGTH = 512;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* CSRF protection middleware (double-submit cookie pattern).
|
|
75
|
+
*
|
|
76
|
+
* Behavior:
|
|
77
|
+
* 1. Ensures a signed CSRF token cookie is present. Issues a fresh one if
|
|
78
|
+
* the existing cookie is missing or its signature fails to verify.
|
|
79
|
+
* 2. For safe methods (GET/HEAD/OPTIONS): continues without further checks.
|
|
80
|
+
* 3. For unsafe methods: reads the submitted token from the configured
|
|
81
|
+
* header (preferred) or form field (fallback for form content types),
|
|
82
|
+
* then confirms:
|
|
83
|
+
* (a) submitted token === cookie token (constant-time equality)
|
|
84
|
+
* (b) the token's HMAC signature still verifies with `secret`
|
|
85
|
+
* Any failure returns 403 without leaking which check failed.
|
|
86
|
+
*/
|
|
87
|
+
export function csrf(options: CsrfMiddlewareOptions): Middleware {
|
|
88
|
+
if (!options.secret || typeof options.secret !== "string") {
|
|
89
|
+
throw new Error("[Mandu CSRF] `secret` is required and must be a non-empty string");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const {
|
|
93
|
+
secret,
|
|
94
|
+
cookieName = DEFAULT_COOKIE_NAME,
|
|
95
|
+
headerName = DEFAULT_HEADER_NAME,
|
|
96
|
+
fieldName = DEFAULT_FIELD_NAME,
|
|
97
|
+
safeMethods = DEFAULT_SAFE_METHODS,
|
|
98
|
+
} = options;
|
|
99
|
+
|
|
100
|
+
const normalizedSafeMethods = new Set(safeMethods.map((m) => m.toUpperCase()));
|
|
101
|
+
const cookieOptions = resolveCookieOptions(options.cookieOptions);
|
|
102
|
+
const maxAgeSec = cookieOptions.maxAge ?? DEFAULT_MAX_AGE;
|
|
103
|
+
|
|
104
|
+
return async (ctx: ManduContext): Promise<Response | void> => {
|
|
105
|
+
const method = ctx.request.method.toUpperCase();
|
|
106
|
+
|
|
107
|
+
// 1. Ensure a valid CSRF cookie is present for the next unsafe request.
|
|
108
|
+
const existing = ctx.cookies.get(cookieName);
|
|
109
|
+
let activeCookieToken: string | null = null;
|
|
110
|
+
|
|
111
|
+
if (typeof existing === "string" && isAcceptableToken(existing)) {
|
|
112
|
+
const valid = await verifyToken(existing, secret, maxAgeSec);
|
|
113
|
+
if (valid) {
|
|
114
|
+
// Keep existing token (no unnecessary rotation).
|
|
115
|
+
activeCookieToken = existing;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (activeCookieToken === null) {
|
|
120
|
+
activeCookieToken = await generateToken(secret, maxAgeSec);
|
|
121
|
+
ctx.cookies.set(cookieName, activeCookieToken, cookieOptions);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// 2. Safe methods pass through.
|
|
125
|
+
if (normalizedSafeMethods.has(method)) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 3. Unsafe methods: read + validate submitted token.
|
|
130
|
+
const submitted = await extractSubmittedToken(ctx, headerName, fieldName);
|
|
131
|
+
|
|
132
|
+
if (!submitted || !isAcceptableToken(submitted)) {
|
|
133
|
+
return ctx.forbidden("CSRF token missing or invalid");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Constant-time equality between submitted token and cookie token.
|
|
137
|
+
if (!safeEqual(submitted, activeCookieToken)) {
|
|
138
|
+
return ctx.forbidden("CSRF token missing or invalid");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// HMAC verification on the submitted token (prevents forged cookies from
|
|
142
|
+
// sibling subdomains since they cannot sign with our secret).
|
|
143
|
+
const verified = await verifyToken(submitted, secret, maxAgeSec);
|
|
144
|
+
if (!verified) {
|
|
145
|
+
return ctx.forbidden("CSRF token missing or invalid");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Valid — continue.
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ========== Helpers ==========
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Resolve cookie options with production-safe defaults.
|
|
156
|
+
*
|
|
157
|
+
* `httpOnly: false` by default: a CSRF token cookie needs to be readable by
|
|
158
|
+
* client-side JS so the app can echo it back in the header. Callers who set
|
|
159
|
+
* the token from the server (e.g. via a hidden form field) may opt into
|
|
160
|
+
* `httpOnly: true`.
|
|
161
|
+
*/
|
|
162
|
+
function resolveCookieOptions(overrides?: CsrfMiddlewareOptions["cookieOptions"]): CookieOptions {
|
|
163
|
+
const isProd = typeof process !== "undefined" && process.env?.NODE_ENV === "production";
|
|
164
|
+
return {
|
|
165
|
+
httpOnly: overrides?.httpOnly ?? false,
|
|
166
|
+
secure: overrides?.secure ?? isProd,
|
|
167
|
+
sameSite: overrides?.sameSite ?? "lax",
|
|
168
|
+
path: overrides?.path ?? "/",
|
|
169
|
+
maxAge: overrides?.maxAge ?? DEFAULT_MAX_AGE,
|
|
170
|
+
domain: overrides?.domain,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Read submitted token from header, falling back to form field if applicable. */
|
|
175
|
+
async function extractSubmittedToken(
|
|
176
|
+
ctx: ManduContext,
|
|
177
|
+
headerName: string,
|
|
178
|
+
fieldName: string
|
|
179
|
+
): Promise<string | null> {
|
|
180
|
+
// Header wins when present (cheap, safe, no body consumption).
|
|
181
|
+
const headerVal = ctx.headers.get(headerName);
|
|
182
|
+
if (typeof headerVal === "string" && headerVal.length > 0) {
|
|
183
|
+
return headerVal;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Form fallback: only when the request advertises a form-like content type.
|
|
187
|
+
// JSON bodies are NOT scanned — header submission is the canonical path.
|
|
188
|
+
const contentType = (ctx.headers.get("content-type") ?? "").toLowerCase();
|
|
189
|
+
const isForm =
|
|
190
|
+
contentType.includes("application/x-www-form-urlencoded") ||
|
|
191
|
+
contentType.includes("multipart/form-data");
|
|
192
|
+
if (!isForm) return null;
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
// Clone so downstream handlers can still read the body.
|
|
196
|
+
const form = await ctx.request.clone().formData();
|
|
197
|
+
const fieldVal = form.get(fieldName);
|
|
198
|
+
return typeof fieldVal === "string" ? fieldVal : null;
|
|
199
|
+
} catch {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Validate token shape before running expensive crypto. */
|
|
205
|
+
function isAcceptableToken(token: string): boolean {
|
|
206
|
+
return (
|
|
207
|
+
typeof token === "string" &&
|
|
208
|
+
token.length > 0 &&
|
|
209
|
+
token.length <= MAX_TOKEN_LENGTH
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Constant-time string comparison to avoid timing-oracle attacks.
|
|
215
|
+
* Returns `false` immediately on length mismatch (lengths themselves are not
|
|
216
|
+
* secret for our fixed-format tokens), then XORs character codes over the
|
|
217
|
+
* full length before folding into a single diff bit.
|
|
218
|
+
*/
|
|
219
|
+
function safeEqual(a: string, b: string): boolean {
|
|
220
|
+
if (a.length !== b.length) return false;
|
|
221
|
+
let diff = 0;
|
|
222
|
+
for (let i = 0; i < a.length; i++) {
|
|
223
|
+
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
224
|
+
}
|
|
225
|
+
return diff === 0;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ========== Token crypto (Bun.CSRF preferred, fallback to crypto.subtle) ==========
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Runtime capability probe for `Bun.CSRF`. Done once at module load — avoids
|
|
232
|
+
* re-checking on every request and allows non-Bun runtimes to use the
|
|
233
|
+
* fallback implementation.
|
|
234
|
+
*/
|
|
235
|
+
const bunCsrf = resolveBunCsrf();
|
|
236
|
+
|
|
237
|
+
function resolveBunCsrf():
|
|
238
|
+
| {
|
|
239
|
+
generate: (secret: string, options?: { maxAge?: number }) => string;
|
|
240
|
+
verify: (token: string, options: { secret: string; maxAge?: number }) => boolean;
|
|
241
|
+
}
|
|
242
|
+
| null {
|
|
243
|
+
if (typeof globalThis === "undefined") return null;
|
|
244
|
+
const bun = (globalThis as { Bun?: { CSRF?: unknown } }).Bun;
|
|
245
|
+
if (!bun || typeof bun !== "object" || bun === null) return null;
|
|
246
|
+
const csrfApi = (bun as { CSRF?: unknown }).CSRF;
|
|
247
|
+
if (!csrfApi || typeof csrfApi !== "object") return null;
|
|
248
|
+
const api = csrfApi as {
|
|
249
|
+
generate?: unknown;
|
|
250
|
+
verify?: unknown;
|
|
251
|
+
};
|
|
252
|
+
if (typeof api.generate !== "function" || typeof api.verify !== "function") {
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
return api as {
|
|
256
|
+
generate: (secret: string, options?: { maxAge?: number }) => string;
|
|
257
|
+
verify: (token: string, options: { secret: string; maxAge?: number }) => boolean;
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function generateToken(secret: string, maxAgeSec: number): Promise<string> {
|
|
262
|
+
if (bunCsrf) {
|
|
263
|
+
// Bun.CSRF handles timestamp + random + HMAC in native code.
|
|
264
|
+
return bunCsrf.generate(secret, { maxAge: maxAgeSec });
|
|
265
|
+
}
|
|
266
|
+
return fallbackGenerate(secret);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function verifyToken(token: string, secret: string, maxAgeSec: number): Promise<boolean> {
|
|
270
|
+
if (bunCsrf) {
|
|
271
|
+
try {
|
|
272
|
+
return bunCsrf.verify(token, { secret, maxAge: maxAgeSec });
|
|
273
|
+
} catch {
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return fallbackVerify(token, secret);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ----- Fallback (no Bun.CSRF available) -----
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Token format: `<random-b64url>.<hmac-b64url>`
|
|
284
|
+
* - random: 32 bytes via `crypto.getRandomValues`
|
|
285
|
+
* - hmac: HMAC-SHA256(random, secret)
|
|
286
|
+
*
|
|
287
|
+
* Same pattern as `packages/core/src/filling/session.ts` (`hmacSign`,
|
|
288
|
+
* line 216-227) so we don't introduce a second crypto code path.
|
|
289
|
+
*/
|
|
290
|
+
async function fallbackGenerate(secret: string): Promise<string> {
|
|
291
|
+
const random = new Uint8Array(32);
|
|
292
|
+
crypto.getRandomValues(random);
|
|
293
|
+
const randomPart = base64UrlEncode(random);
|
|
294
|
+
const sig = await hmacSignB64Url(randomPart, secret);
|
|
295
|
+
return `${randomPart}.${sig}`;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function fallbackVerify(token: string, secret: string): Promise<boolean> {
|
|
299
|
+
const dotIdx = token.lastIndexOf(".");
|
|
300
|
+
if (dotIdx <= 0 || dotIdx === token.length - 1) return false;
|
|
301
|
+
const randomPart = token.slice(0, dotIdx);
|
|
302
|
+
const signature = token.slice(dotIdx + 1);
|
|
303
|
+
if (!randomPart || !signature) return false;
|
|
304
|
+
const expected = await hmacSignB64Url(randomPart, secret);
|
|
305
|
+
// Constant-time comparison on signatures.
|
|
306
|
+
return safeEqual(signature, expected);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function hmacSignB64Url(data: string, secret: string): Promise<string> {
|
|
310
|
+
const encoder = new TextEncoder();
|
|
311
|
+
const key = await crypto.subtle.importKey(
|
|
312
|
+
"raw",
|
|
313
|
+
encoder.encode(secret),
|
|
314
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
315
|
+
false,
|
|
316
|
+
["sign"]
|
|
317
|
+
);
|
|
318
|
+
const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(data));
|
|
319
|
+
return base64UrlEncode(new Uint8Array(sig));
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function base64UrlEncode(bytes: Uint8Array): string {
|
|
323
|
+
let binary = "";
|
|
324
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
325
|
+
binary += String.fromCharCode(bytes[i]);
|
|
326
|
+
}
|
|
327
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
328
|
+
}
|