castrum 0.9.7 → 0.9.8
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/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/castrum.darwin-arm64.node +0 -0
- package/castrum.darwin-x64.node +0 -0
- package/castrum.linux-arm64-gnu.node +0 -0
- package/castrum.linux-arm64-musl.node +0 -0
- package/castrum.linux-x64-gnu.node +0 -0
- package/castrum.linux-x64-musl.node +0 -0
- package/castrum.linux-x64-v3-gnu.node +0 -0
- package/castrum.win32-x64-msvc.node +0 -0
- package/dist/index.d.ts +501 -5
- package/dist/index.js +1648 -1325
- package/docs/BENCHMARKS.md +13 -0
- package/docs/CASE_STUDY.md +1 -1
- package/docs/ENVIRONMENT.md +1 -1
- package/docs/FFI_BUN_GUIDE.md +1 -1
- package/docs/INGRESS.md +4 -4
- package/docs/REPO_MAP.md +7 -5
- package/docs/superpowers/plans/2026-09-18-castrum-hardening-cleanup.md +617 -0
- package/index.ts +1 -0
- package/package.json +3 -2
- package/rust/crypto/aead.rs +44 -0
- package/rust/crypto/argon2.rs +32 -2
- package/rust/crypto/jwt/tests.rs +19 -0
- package/rust/crypto/jwt/token.rs +43 -28
- package/rust/crypto/session.rs +6 -0
- package/rust/ffi/crypto.rs +63 -32
- package/rust/ffi/hashing.rs +48 -33
- package/rust/ffi/http.rs +62 -36
- package/rust/ffi/metrics.rs +6 -2
- package/rust/ffi/payload.rs +47 -27
- package/rust/ffi/task.rs +51 -32
- package/rust/ffi/tests.rs +24 -0
- package/rust/ffi/util.rs +40 -0
- package/rust/http/headers.rs +13 -0
- package/rust/http/multipart.rs +139 -0
- package/rust/ingress/native_route.rs +21 -3
- package/rust/task/completion.rs +7 -0
- package/rust/task/mod.rs +1 -1
- package/rust/task/tests.rs +30 -0
- package/src/ingress/decode/baked-result.ts +13 -5
- package/src/ingress/decode/fast-result.ts +1 -1
- package/src/ingress/decode/result-base.ts +16 -2
- package/src/ingress/handlers.ts +103 -31
- package/src/ingress/headers/memoized-headers.ts +40 -0
- package/src/ingress/native-route.ts +25 -6
- package/src/ingress/options.ts +26 -2
- package/src/ingress/packing/gather-raw-headers.ts +24 -8
- package/src/ingress/response/baked-response.ts +118 -28
- package/src/ingress/router.ts +14 -2
- package/src/ingress/routes/common.ts +26 -10
- package/src/ingress/routes/echo.ts +50 -7
- package/src/ingress/routes/json-write.ts +21 -8
- package/src/ingress/routes/responder.ts +4 -4
- package/src/ingress/server-node.ts +33 -14
- package/src/ingress/server.ts +3 -4
- package/src/ingress/shared.ts +1 -1
- package/src/ingress/types.ts +15 -0
- package/src/loader/ops.ts +1 -1
- package/src/native/ffi/build/codecs-selftest.ts +305 -0
- package/src/native/ffi/build/codecs.ts +0 -296
- package/src/native/ffi/build/instances-selftest.ts +264 -0
- package/src/native/ffi/build/instances.ts +10 -259
- package/src/native/ffi/build/parse-selftest.ts +238 -0
- package/src/native/ffi/build/parse.ts +11 -234
- package/src/native/ffi/build.ts +5 -1
- package/src/native/ffi/selftest.ts +3 -3
- package/src/native/ffi/symbols.ts +460 -0
- package/src/native/ffi.ts +2 -434
- package/src/native/ingress-binding.ts +39 -0
- package/src/runtime/codec.ts +24 -0
- package/src/runtime/types.ts +11 -0
- package/src/rust-ffi/batch/build.ts +18 -8
- package/src/rust-ffi/batch/types.ts +15 -0
- package/src/rust-ffi/options.ts +19 -0
- package/src/rust-ffi/scalar/crypto.ts +8 -0
- package/src/rust-ffi/scalar/factories.ts +47 -20
- package/src/rust-ffi/scalar/http.ts +60 -10
- package/src/rust-ffi/scalar/interface.ts +4 -1
- package/src/rust-ffi/scalar/payload.ts +27 -1
- package/src/shared/buffer-pool.ts +44 -9
- package/src/shared/codec.ts +26 -0
- package/src/shared/packed/parsers.ts +64 -7
- package/src/task/runtime.ts +29 -28
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
HV_RATE_LIMITED,
|
|
25
25
|
} from '../constants'
|
|
26
26
|
import type { BakedIngressResult } from '../decode/baked-result'
|
|
27
|
+
import { memoizedHeaders } from '../headers/memoized-headers'
|
|
27
28
|
import { secondsFromMs } from '../shared'
|
|
28
29
|
import { safeTerminalStatus } from '../status'
|
|
29
30
|
import type { BakedContext, OptimizedIngressHandler } from '../types'
|
|
@@ -31,6 +32,14 @@ import { ERROR_BODIES, ERROR_CODE_BODIES, rateLimitedBody } from './error-bodies
|
|
|
31
32
|
|
|
32
33
|
const EMPTY_BODY = new Uint8Array(0)
|
|
33
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Upper bound on the per-`(variant, origin)` memoized header arrays. Under
|
|
37
|
+
* wildcard CORS EVERY Origin is allowed, so this cache is attacker-keyed —
|
|
38
|
+
* without a cap a stream of distinct Origins grows it without bound (remote
|
|
39
|
+
* memory exhaustion). Evicts the oldest entry (Map preserves insertion order).
|
|
40
|
+
*/
|
|
41
|
+
export const ORIGIN_HEADER_CACHE_MAX = 64
|
|
42
|
+
|
|
34
43
|
/** Shared per-request state between `run()` (handlers.ts) and the builders. */
|
|
35
44
|
export interface BakedResponseState {
|
|
36
45
|
/** The pooled output buffer backing the current `run()` (null outside one). */
|
|
@@ -55,6 +64,33 @@ export interface BakedResponseBuildersDeps {
|
|
|
55
64
|
state: BakedResponseState
|
|
56
65
|
}
|
|
57
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Pre-warm the per-origin header-array cache AND the memoized `Headers`
|
|
69
|
+
* `WeakMap` for the configured exact allow-origins at the success `variant`.
|
|
70
|
+
*
|
|
71
|
+
* `variant` MUST be the one the success path actually returns
|
|
72
|
+
* (`HV_JSON | HV_CORS_SIMPLE`, plus `HV_RATE_ACTIVE` when rate limiting is
|
|
73
|
+
* enabled). Warming a different variant populates an unreachable cache entry
|
|
74
|
+
* and buys nothing.
|
|
75
|
+
*
|
|
76
|
+
* @param responseHeaders The handler's header builder (from this module).
|
|
77
|
+
* @param origins Exact, concrete allow-origins (wildcards excluded by caller).
|
|
78
|
+
* @param variant The success header variant to warm.
|
|
79
|
+
*/
|
|
80
|
+
export function prewarmOriginHeaders(
|
|
81
|
+
responseHeaders: (
|
|
82
|
+
variant: number,
|
|
83
|
+
requestIdHeader: string | null,
|
|
84
|
+
origin: string | null,
|
|
85
|
+
) => [string, string][],
|
|
86
|
+
origins: readonly string[],
|
|
87
|
+
variant: number,
|
|
88
|
+
): void {
|
|
89
|
+
for (const origin of origins) {
|
|
90
|
+
memoizedHeaders(responseHeaders(variant, null, origin))
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
58
94
|
/**
|
|
59
95
|
* Build the seven response-builder methods of `OptimizedIngressHandler`
|
|
60
96
|
* (everything except `run`), bound to this handler's header templates and
|
|
@@ -73,6 +109,72 @@ export function buildBakedResponseBuilders(
|
|
|
73
109
|
state,
|
|
74
110
|
} = deps
|
|
75
111
|
|
|
112
|
+
/**
|
|
113
|
+
* The cacheable base entries for a `(variant, origin)`: the baked template
|
|
114
|
+
* plus the reflected `access-control-allow-origin`. Memoized per origin so
|
|
115
|
+
* the Origin-present case (the common browser/bench case) stays
|
|
116
|
+
* allocation-free after the first hit for EACH distinct origin (a single-slot
|
|
117
|
+
* memo thrashed when clients alternated between allowed origins). Bounded by
|
|
118
|
+
* `ORIGIN_HEADER_CACHE_MAX`.
|
|
119
|
+
*/
|
|
120
|
+
function originBaseEntries(variant: number, origin: string): [string, string][] {
|
|
121
|
+
const key = `${variant & 31}\u0000${origin}`
|
|
122
|
+
const cached = originHeaderCache.get(key)
|
|
123
|
+
if (cached !== undefined) {
|
|
124
|
+
return cached
|
|
125
|
+
}
|
|
126
|
+
const template = headerTemplates[variant & 31] ?? headerTemplates[0] ?? []
|
|
127
|
+
const entries = new Array<[string, string]>(template.length + 1)
|
|
128
|
+
let i = 0
|
|
129
|
+
for (const pair of template) {
|
|
130
|
+
entries[i++] = pair
|
|
131
|
+
}
|
|
132
|
+
entries[i] = ['access-control-allow-origin', origin]
|
|
133
|
+
if (originHeaderCache.size >= ORIGIN_HEADER_CACHE_MAX) {
|
|
134
|
+
const oldest = originHeaderCache.keys().next().value
|
|
135
|
+
if (oldest !== undefined) originHeaderCache.delete(oldest)
|
|
136
|
+
}
|
|
137
|
+
originHeaderCache.set(key, entries)
|
|
138
|
+
return entries
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Success-path headers as a `Headers` instance. Uses the memoized base
|
|
143
|
+
* (template + reflected origin) so the memoized-`Headers` fast path is kept
|
|
144
|
+
* even when per-request rate / request-id extras are present — a fresh array
|
|
145
|
+
* there would force a `new Headers` on every response. Extras are applied to
|
|
146
|
+
* a CLONE of the base, so the memoized instance is never mutated.
|
|
147
|
+
*/
|
|
148
|
+
function successHeaders(
|
|
149
|
+
variant: number,
|
|
150
|
+
requestIdHeader: string | null,
|
|
151
|
+
origin: string | null,
|
|
152
|
+
rateRemaining?: number,
|
|
153
|
+
rateResetSecs?: number,
|
|
154
|
+
): Headers {
|
|
155
|
+
const needsRequestId = emitRequestIdHeader && requestIdHeader !== null
|
|
156
|
+
const needsOrigin =
|
|
157
|
+
((variant & HV_CORS_SIMPLE) !== 0 || (variant & HV_CORS_PREFLIGHT) !== 0) && origin !== null
|
|
158
|
+
const needsRate = (variant & HV_RATE_ACTIVE) !== 0
|
|
159
|
+
|
|
160
|
+
const base = needsOrigin
|
|
161
|
+
? memoizedHeaders(originBaseEntries(variant, origin as string))
|
|
162
|
+
: memoizedHeaders(headerTemplates[variant & 31] ?? headerTemplates[0] ?? [])
|
|
163
|
+
|
|
164
|
+
if (!needsRequestId && !needsRate) {
|
|
165
|
+
return base
|
|
166
|
+
}
|
|
167
|
+
const headers = new Headers(base)
|
|
168
|
+
if (needsRequestId) {
|
|
169
|
+
headers.set('x-request-id', requestIdHeader as string)
|
|
170
|
+
}
|
|
171
|
+
if (needsRate) {
|
|
172
|
+
headers.set('ratelimit-remaining', String(rateRemaining ?? 0))
|
|
173
|
+
headers.set('ratelimit-reset', String(rateResetSecs ?? 0))
|
|
174
|
+
}
|
|
175
|
+
return headers
|
|
176
|
+
}
|
|
177
|
+
|
|
76
178
|
function responseHeaders(
|
|
77
179
|
variant: number,
|
|
78
180
|
requestIdHeader: string | null,
|
|
@@ -97,24 +199,7 @@ export function buildBakedResponseBuilders(
|
|
|
97
199
|
}
|
|
98
200
|
|
|
99
201
|
if (!needsRequestId && !needsRate && !needsRetry && needsOrigin) {
|
|
100
|
-
|
|
101
|
-
// per (variant, origin) so the Origin-present case (the common
|
|
102
|
-
// browser/bench case) stays allocation-free after the first hit for EACH
|
|
103
|
-
// distinct origin (a single-slot memo thrashed when clients alternate
|
|
104
|
-
// between allowed origins, allocating a fresh array on every switch).
|
|
105
|
-
const key = `${variant & 31}\u0000${origin as string}`
|
|
106
|
-
const cached = originHeaderCache.get(key)
|
|
107
|
-
if (cached !== undefined) {
|
|
108
|
-
return cached
|
|
109
|
-
}
|
|
110
|
-
const entries = new Array<[string, string]>(template.length + 1)
|
|
111
|
-
let i = 0
|
|
112
|
-
for (const pair of template) {
|
|
113
|
-
entries[i++] = pair
|
|
114
|
-
}
|
|
115
|
-
entries[i] = ['access-control-allow-origin', origin as string]
|
|
116
|
-
originHeaderCache.set(key, entries)
|
|
117
|
-
return entries
|
|
202
|
+
return originBaseEntries(variant, origin as string)
|
|
118
203
|
}
|
|
119
204
|
|
|
120
205
|
let extra = 0
|
|
@@ -210,13 +295,15 @@ export function buildBakedResponseBuilders(
|
|
|
210
295
|
if (preflightAllowed) {
|
|
211
296
|
return new Response(null, {
|
|
212
297
|
status: 204,
|
|
213
|
-
headers:
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
298
|
+
headers: memoizedHeaders(
|
|
299
|
+
responseHeaders(
|
|
300
|
+
result.headerVariant,
|
|
301
|
+
ctx.requestIdHeader,
|
|
302
|
+
ctx.origin,
|
|
303
|
+
result.rateRemaining,
|
|
304
|
+
result.rateResetMs > 0 ? secondsFromMs(result.rateResetMs) : undefined,
|
|
305
|
+
result.retryAfterMs > 0 ? secondsFromMs(result.retryAfterMs) : undefined,
|
|
306
|
+
),
|
|
220
307
|
),
|
|
221
308
|
})
|
|
222
309
|
}
|
|
@@ -230,7 +317,7 @@ export function buildBakedResponseBuilders(
|
|
|
230
317
|
|
|
231
318
|
return new Response(body, {
|
|
232
319
|
status,
|
|
233
|
-
headers: terminalHeaders(result.headerVariant, ctx, result),
|
|
320
|
+
headers: memoizedHeaders(terminalHeaders(result.headerVariant, ctx, result)),
|
|
234
321
|
})
|
|
235
322
|
}
|
|
236
323
|
|
|
@@ -247,14 +334,16 @@ export function buildBakedResponseBuilders(
|
|
|
247
334
|
|
|
248
335
|
return new Response(body, {
|
|
249
336
|
status,
|
|
250
|
-
headers: terminalHeaders(result?.headerVariant ?? HV_JSON, ctx, result),
|
|
337
|
+
headers: memoizedHeaders(terminalHeaders(result?.headerVariant ?? HV_JSON, ctx, result)),
|
|
251
338
|
})
|
|
252
339
|
}
|
|
253
340
|
|
|
254
341
|
function internalErrorResponse(ctx: BakedContext, result?: BakedIngressResult): Response {
|
|
255
342
|
return new Response(ERROR_BODIES.internal, {
|
|
256
343
|
status: 500,
|
|
257
|
-
headers:
|
|
344
|
+
headers: memoizedHeaders(
|
|
345
|
+
terminalHeaders(result?.headerVariant ?? HV_JSON, ctx, result ?? null),
|
|
346
|
+
),
|
|
258
347
|
})
|
|
259
348
|
}
|
|
260
349
|
|
|
@@ -292,6 +381,7 @@ export function buildBakedResponseBuilders(
|
|
|
292
381
|
|
|
293
382
|
return {
|
|
294
383
|
responseHeaders,
|
|
384
|
+
successHeaders,
|
|
295
385
|
terminalHeaders,
|
|
296
386
|
terminalResponse,
|
|
297
387
|
errorResponse,
|
package/src/ingress/router.ts
CHANGED
|
@@ -22,9 +22,9 @@
|
|
|
22
22
|
// the deleted dead wire).
|
|
23
23
|
|
|
24
24
|
import type { BakedIngressResult } from './decode/baked-result'
|
|
25
|
-
import { type BakedIngressRuntime, createIngressHandler } from './handlers'
|
|
25
|
+
import { type BakedIngressRuntime, createIngressHandler, RATE_LIMIT_U32_MAX } from './handlers'
|
|
26
26
|
import type { NativeRoutePlan } from './native-route'
|
|
27
|
-
import type
|
|
27
|
+
import { type IngressHandlerOptions, warnRateLimitWithoutGetIp } from './options'
|
|
28
28
|
import type { PathMatch } from './path-matcher'
|
|
29
29
|
import { buildPathMatcher } from './path-matcher'
|
|
30
30
|
import type { BakedRoute, RouteHandler } from './server'
|
|
@@ -199,6 +199,18 @@ export function createIngressRouter(options: CreateIngressRouterOptions): Ingres
|
|
|
199
199
|
const handler = createIngressHandler(spec.options ?? {}, runtime)
|
|
200
200
|
compiled[path] = handler
|
|
201
201
|
|
|
202
|
+
// Rate limiting without a getIp resolver collapses every client into ONE
|
|
203
|
+
// bucket; warn once (a global bucket can be deliberate, so don't fail).
|
|
204
|
+
const routeLimit = (spec.options?.rateLimit as { limit?: number } | undefined)?.limit
|
|
205
|
+
if (
|
|
206
|
+
typeof routeLimit === 'number' &&
|
|
207
|
+
routeLimit > 0 &&
|
|
208
|
+
routeLimit !== RATE_LIMIT_U32_MAX &&
|
|
209
|
+
options.getIp === undefined
|
|
210
|
+
) {
|
|
211
|
+
warnRateLimitWithoutGetIp()
|
|
212
|
+
}
|
|
213
|
+
|
|
202
214
|
if (spec.responder) {
|
|
203
215
|
// Responder route: native decides + rejects; JS builds the 2xx.
|
|
204
216
|
bakedRoutes[path] = {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// src/ingress/routes/common.ts — Shared route-handler options + helpers.
|
|
2
2
|
|
|
3
3
|
import type { BakedIngressResult } from '../decode/baked-result'
|
|
4
|
+
import { memoizedHeaders } from '../headers/memoized-headers'
|
|
4
5
|
import { secondsFromMs } from '../shared'
|
|
5
6
|
import type { BakedContext, OptimizedIngressHandler } from '../types'
|
|
6
7
|
|
|
@@ -52,6 +53,10 @@ export function resolveIp(
|
|
|
52
53
|
* every pre-baked success path. A plain function call, so the hot path keeps
|
|
53
54
|
* its single init-object allocation and nothing else.
|
|
54
55
|
*
|
|
56
|
+
* The header array is wrapped in a memoized `Headers` (see
|
|
57
|
+
* `headers/memoized-headers.ts`) — identical bytes on the wire, ~4x cheaper
|
|
58
|
+
* Response construction for the recurring header sets.
|
|
59
|
+
*
|
|
55
60
|
* @param status Success status; defaults to 200.
|
|
56
61
|
*/
|
|
57
62
|
export function buildSuccessInit(
|
|
@@ -60,16 +65,27 @@ export function buildSuccessInit(
|
|
|
60
65
|
ctx: BakedContext,
|
|
61
66
|
status = 200,
|
|
62
67
|
): ResponseInit {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
68
|
+
const rateResetSecs = result.rateResetMs > 0 ? secondsFromMs(result.rateResetMs) : undefined
|
|
69
|
+
// Prefer the `Headers`-producing path (keeps the memoized base even with
|
|
70
|
+
// per-request extras); mocks that only implement `responseHeaders` fall back.
|
|
71
|
+
const headers = ingress.successHeaders
|
|
72
|
+
? ingress.successHeaders(
|
|
73
|
+
result.headerVariant,
|
|
74
|
+
ctx.requestIdHeader,
|
|
75
|
+
ctx.origin,
|
|
76
|
+
result.rateRemaining,
|
|
77
|
+
rateResetSecs,
|
|
78
|
+
)
|
|
79
|
+
: memoizedHeaders(
|
|
80
|
+
ingress.responseHeaders(
|
|
81
|
+
result.headerVariant,
|
|
82
|
+
ctx.requestIdHeader,
|
|
83
|
+
ctx.origin,
|
|
84
|
+
result.rateRemaining,
|
|
85
|
+
rateResetSecs,
|
|
86
|
+
),
|
|
87
|
+
)
|
|
88
|
+
return { status, headers }
|
|
73
89
|
}
|
|
74
90
|
|
|
75
91
|
/**
|
|
@@ -7,9 +7,40 @@ import { DEFAULT_BODY_TIMEOUT_MS, DEFAULT_MAX_BODY_BYTES, secondsFromMs } from '
|
|
|
7
7
|
import type { OptimizedIngressHandler } from '../types'
|
|
8
8
|
import { type BakedHandlerOptions, resolveIp } from './common'
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Content types safe to echo back for an attacker-supplied body. Anything else
|
|
12
|
+
* — notably `text/html`, `application/xhtml+xml`, `image/svg+xml` and XML — is
|
|
13
|
+
* coerced to `application/octet-stream`: reflecting an active type would let a
|
|
14
|
+
* `POST /echo` execute the request body as script in a browser.
|
|
15
|
+
*/
|
|
16
|
+
const SAFE_ECHO_CONTENT_TYPES = new Set([
|
|
17
|
+
'application/json',
|
|
18
|
+
'application/octet-stream',
|
|
19
|
+
'text/plain',
|
|
20
|
+
])
|
|
21
|
+
|
|
22
|
+
/** Lowercased media type with parameters stripped (`text/html; charset=x` → `text/html`). */
|
|
23
|
+
function echoMediaTypeOf(contentType: string): string {
|
|
24
|
+
const semi = contentType.indexOf(';')
|
|
25
|
+
return (semi >= 0 ? contentType.slice(0, semi) : contentType).trim().toLowerCase()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Coerce a client Content-Type into a type that is safe to echo back.
|
|
30
|
+
*
|
|
31
|
+
* @param contentType - The raw request `Content-Type` header value.
|
|
32
|
+
* @returns The value unchanged when it is a safe non-active type, else
|
|
33
|
+
* `application/octet-stream`.
|
|
34
|
+
*/
|
|
35
|
+
export function safeEchoContentType(contentType: string): string {
|
|
36
|
+
return SAFE_ECHO_CONTENT_TYPES.has(echoMediaTypeOf(contentType))
|
|
37
|
+
? contentType
|
|
38
|
+
: 'application/octet-stream'
|
|
39
|
+
}
|
|
40
|
+
|
|
10
41
|
/**
|
|
11
42
|
* Pre-baked echo handler: streams the request body back with the client's
|
|
12
|
-
* Content-Type, bounded by `maxBodyBytes`.
|
|
43
|
+
* Content-Type (coerced to a non-active type), bounded by `maxBodyBytes`.
|
|
13
44
|
*/
|
|
14
45
|
export function echoHandler(
|
|
15
46
|
ingress: OptimizedIngressHandler,
|
|
@@ -50,7 +81,19 @@ export function echoHandler(
|
|
|
50
81
|
|
|
51
82
|
const baseHeaders: ReadonlyArray<[string, string]> = prep.headers ?? []
|
|
52
83
|
|
|
53
|
-
const requestedContentType =
|
|
84
|
+
const requestedContentType = safeEchoContentType(
|
|
85
|
+
req.headers.get('content-type') ?? 'application/octet-stream',
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
// Content-Type + `nosniff` (unless the security config already emitted it),
|
|
89
|
+
// so the echoed body can never be sniffed into an active type.
|
|
90
|
+
const withEchoHeaders = (contentType: string): [string, string][] => {
|
|
91
|
+
const headers = ingress.withContentType(baseHeaders, contentType)
|
|
92
|
+
if (!headers.some(([name]) => name.toLowerCase() === 'x-content-type-options')) {
|
|
93
|
+
headers.push(['x-content-type-options', 'nosniff'])
|
|
94
|
+
}
|
|
95
|
+
return headers
|
|
96
|
+
}
|
|
54
97
|
|
|
55
98
|
const contentLengthHeader = req.headers.get('content-length')
|
|
56
99
|
const contentLength = contentLengthHeader === null ? NaN : Number(contentLengthHeader)
|
|
@@ -59,20 +102,20 @@ export function echoHandler(
|
|
|
59
102
|
if (contentLength > maxBodyBytes) {
|
|
60
103
|
return new Response(ERROR_BODIES.body_too_large, {
|
|
61
104
|
status: 413,
|
|
62
|
-
headers:
|
|
105
|
+
headers: withEchoHeaders('application/json'),
|
|
63
106
|
})
|
|
64
107
|
}
|
|
65
108
|
|
|
66
109
|
if (contentLength <= 0 || req.body === null) {
|
|
67
110
|
return new Response(null, {
|
|
68
111
|
status: 200,
|
|
69
|
-
headers:
|
|
112
|
+
headers: withEchoHeaders(requestedContentType),
|
|
70
113
|
})
|
|
71
114
|
}
|
|
72
115
|
|
|
73
116
|
return new Response(req.body, {
|
|
74
117
|
status: 200,
|
|
75
|
-
headers:
|
|
118
|
+
headers: withEchoHeaders(requestedContentType),
|
|
76
119
|
})
|
|
77
120
|
}
|
|
78
121
|
|
|
@@ -84,7 +127,7 @@ export function echoHandler(
|
|
|
84
127
|
|
|
85
128
|
return new Response(bodyBytes.byteLength > 0 ? bodyBytes : null, {
|
|
86
129
|
status: 200,
|
|
87
|
-
headers:
|
|
130
|
+
headers: withEchoHeaders(requestedContentType),
|
|
88
131
|
})
|
|
89
132
|
} catch (err) {
|
|
90
133
|
const code = (err as { code?: string } | null)?.code
|
|
@@ -99,7 +142,7 @@ export function echoHandler(
|
|
|
99
142
|
: ERROR_BODIES.bad_request,
|
|
100
143
|
{
|
|
101
144
|
status: isTooLarge ? 413 : isTimeout ? 408 : 400,
|
|
102
|
-
headers:
|
|
145
|
+
headers: withEchoHeaders('application/json'),
|
|
103
146
|
},
|
|
104
147
|
)
|
|
105
148
|
}
|
|
@@ -23,6 +23,18 @@ function defaultJsonValid(bytes: Uint8Array): boolean {
|
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Whether a `Content-Type` header denotes JSON: the media type (the part before
|
|
28
|
+
* any parameters) is exactly `application/json`, or a `+json` structured suffix
|
|
29
|
+
* (e.g. `application/vnd.api+json`). A plain substring test would wrongly
|
|
30
|
+
* accept `text/plain; application/json`.
|
|
31
|
+
*/
|
|
32
|
+
function isJsonContentType(contentType: string): boolean {
|
|
33
|
+
const semi = contentType.indexOf(';')
|
|
34
|
+
const mediaType = (semi >= 0 ? contentType.slice(0, semi) : contentType).trim().toLowerCase()
|
|
35
|
+
return mediaType === 'application/json' || mediaType.endsWith('+json')
|
|
36
|
+
}
|
|
37
|
+
|
|
26
38
|
/**
|
|
27
39
|
* Pre-baked JSON-write handler (POST/PUT/PATCH): enforces Content-Type,
|
|
28
40
|
* content-length/body-size limits, JSON validity and (optionally) schema
|
|
@@ -47,20 +59,21 @@ export function jsonWriteHandler(
|
|
|
47
59
|
const ip = resolveIp(req, srv, opts)
|
|
48
60
|
|
|
49
61
|
const contentType = req.headers.get('content-type') ?? ''
|
|
50
|
-
if (!contentType
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
62
|
+
if (!isJsonContentType(contentType)) {
|
|
63
|
+
// Always 415 for an unsupported media type. Do NOT short-circuit on the
|
|
64
|
+
// native terminal first: running with no body yields a "body required"
|
|
65
|
+
// 400, which previously masked the 415 (the native pipeline still applied
|
|
66
|
+
// rate limiting / CORS headers to this run).
|
|
67
|
+
return fallback.run(req, ip, null, (result, ctx) =>
|
|
68
|
+
fallback.errorResponse(
|
|
56
69
|
req,
|
|
57
70
|
result,
|
|
58
71
|
415,
|
|
59
72
|
'unsupported_media_type',
|
|
60
73
|
'Content-Type must be application/json',
|
|
61
74
|
ctx,
|
|
62
|
-
)
|
|
63
|
-
|
|
75
|
+
),
|
|
76
|
+
)
|
|
64
77
|
}
|
|
65
78
|
|
|
66
79
|
// Content-Length / body-size enforcement happens INSIDE readBodyWithLimit
|
|
@@ -45,7 +45,7 @@ export interface ResponderRouteOptions extends BakedHandlerOptions {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
/** ignex framework security posture for terminal responses. */
|
|
48
|
-
const
|
|
48
|
+
const IGNEX_SECURITY_HEADERS: [string, string][] = [
|
|
49
49
|
['x-frame-options', 'DENY'],
|
|
50
50
|
['x-content-type-options', 'nosniff'],
|
|
51
51
|
['referrer-policy', 'no-referrer'],
|
|
@@ -55,7 +55,7 @@ const IGNGEX_SECURITY_HEADERS: [string, string][] = [
|
|
|
55
55
|
function buildIgngexTerminal(result: BakedIngressResult, _ctx: BakedContext): Response {
|
|
56
56
|
const preflightAllowed = result.isPreflight && result.corsAllowed
|
|
57
57
|
if (preflightAllowed) {
|
|
58
|
-
return new Response(null, { status: 204, headers:
|
|
58
|
+
return new Response(null, { status: 204, headers: IGNEX_SECURITY_HEADERS })
|
|
59
59
|
}
|
|
60
60
|
const status = safeTerminalStatus(result)
|
|
61
61
|
const body = JSON.stringify({
|
|
@@ -63,7 +63,7 @@ function buildIgngexTerminal(result: BakedIngressResult, _ctx: BakedContext): Re
|
|
|
63
63
|
status,
|
|
64
64
|
code: errorCodeName(result.errorCode),
|
|
65
65
|
})
|
|
66
|
-
const headers = new Headers(
|
|
66
|
+
const headers = new Headers(IGNEX_SECURITY_HEADERS)
|
|
67
67
|
headers.set('content-type', 'application/json; charset=utf-8')
|
|
68
68
|
return new Response(body, { status, headers })
|
|
69
69
|
}
|
|
@@ -112,7 +112,7 @@ export function nativeResponderRoute(
|
|
|
112
112
|
const code = (err as Error & { code?: string }).code
|
|
113
113
|
const status = code === 'REQUEST_TIMEOUT' ? 408 : code === 'BODY_TOO_LARGE' ? 413 : 400
|
|
114
114
|
const payload = JSON.stringify({ error: 'Bad Request', status, code: 'BAD_REQUEST' })
|
|
115
|
-
const headers = new Headers(
|
|
115
|
+
const headers = new Headers(IGNEX_SECURITY_HEADERS)
|
|
116
116
|
headers.set('content-type', 'application/json; charset=utf-8')
|
|
117
117
|
return new Response(payload, { status, headers })
|
|
118
118
|
}
|
|
@@ -73,8 +73,7 @@ function nodeRequestToWebRequest(req: IncomingMessage): Request {
|
|
|
73
73
|
// request actually carries one (content-length > 0 or chunked), so DELETE/
|
|
74
74
|
// OPTIONS-with-a-body are no longer dropped.
|
|
75
75
|
const bodyForbidden = method === 'GET' || method === 'HEAD'
|
|
76
|
-
const hasBody =
|
|
77
|
-
Number(req.headers['content-length'] ?? 0) > 0 || req.headers['transfer-encoding'] !== undefined
|
|
76
|
+
const hasBody = requestHasBody(req)
|
|
78
77
|
|
|
79
78
|
const init: RequestInit & { duplex?: 'half' } = { method, headers }
|
|
80
79
|
|
|
@@ -143,7 +142,7 @@ async function writeResponse(res: ServerResponse, response: Response): Promise<v
|
|
|
143
142
|
*/
|
|
144
143
|
function drainUnreadBody(res: ServerResponse): void {
|
|
145
144
|
const req = res.req
|
|
146
|
-
if (req !== undefined && req !== null && !req.complete &&
|
|
145
|
+
if (req !== undefined && req !== null && !req.complete && requestHasBody(req)) {
|
|
147
146
|
req.resume()
|
|
148
147
|
// Only force-close when the request actually carried a body that was not
|
|
149
148
|
// consumed. A bodiless GET/HEAD is always safe to keep alive (bun's
|
|
@@ -156,7 +155,7 @@ function drainUnreadBody(res: ServerResponse): void {
|
|
|
156
155
|
}
|
|
157
156
|
|
|
158
157
|
/** Whether an incoming request carried a body (content-length or chunked). */
|
|
159
|
-
function
|
|
158
|
+
function requestHasBody(req: IncomingMessage): boolean {
|
|
160
159
|
return (
|
|
161
160
|
Number(req.headers['content-length'] ?? 0) > 0 || req.headers['transfer-encoding'] !== undefined
|
|
162
161
|
)
|
|
@@ -254,6 +253,35 @@ function makeRequestListener(
|
|
|
254
253
|
* (which owns the frame codec — e.g. the `ws` library). Previously a 101 was
|
|
255
254
|
* written as a normal response and the connection never became a socket.
|
|
256
255
|
*/
|
|
256
|
+
/** RFC 6455 subprotocol is an HTTP token (no separators / control bytes). */
|
|
257
|
+
const WS_PROTOCOL_TOKEN = /^[A-Za-z0-9!#$%&'*+\-.^_`|~]+$/
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Build the RFC 6455 `101 Switching Protocols` response head for a hijacked
|
|
261
|
+
* socket. `protocol` is emitted only when it is a valid HTTP token, so a caller
|
|
262
|
+
* that reflects an untrusted `Sec-WebSocket-Protocol` cannot inject CRLF or
|
|
263
|
+
* extra headers.
|
|
264
|
+
*
|
|
265
|
+
* @param accept - The `Sec-WebSocket-Accept` value (must contain no CR/LF).
|
|
266
|
+
* @param protocol - Optional negotiated subprotocol.
|
|
267
|
+
* @returns The raw response head, terminated by a blank line.
|
|
268
|
+
* @throws If `accept` contains a CR/LF.
|
|
269
|
+
*/
|
|
270
|
+
export function formatWebSocketHandshake(accept: string, protocol?: string | null): string {
|
|
271
|
+
if (/[\r\n]/.test(accept)) {
|
|
272
|
+
throw new Error('invalid Sec-WebSocket-Accept value')
|
|
273
|
+
}
|
|
274
|
+
let raw =
|
|
275
|
+
'HTTP/1.1 101 Switching Protocols\r\n' +
|
|
276
|
+
'Upgrade: websocket\r\n' +
|
|
277
|
+
'Connection: Upgrade\r\n' +
|
|
278
|
+
`Sec-WebSocket-Accept: ${accept}\r\n`
|
|
279
|
+
if (protocol && WS_PROTOCOL_TOKEN.test(protocol)) {
|
|
280
|
+
raw += `Sec-WebSocket-Protocol: ${protocol}\r\n`
|
|
281
|
+
}
|
|
282
|
+
return `${raw}\r\n`
|
|
283
|
+
}
|
|
284
|
+
|
|
257
285
|
function makeUpgradeListener(
|
|
258
286
|
options: CreateIngressServerOptions,
|
|
259
287
|
): (req: IncomingMessage, socket: Duplex, head: Buffer) => Promise<void> {
|
|
@@ -268,16 +296,7 @@ function makeUpgradeListener(
|
|
|
268
296
|
}
|
|
269
297
|
|
|
270
298
|
// Complete the RFC 6455 handshake on the hijacked socket.
|
|
271
|
-
|
|
272
|
-
'HTTP/1.1 101 Switching Protocols\r\n' +
|
|
273
|
-
'Upgrade: websocket\r\n' +
|
|
274
|
-
'Connection: Upgrade\r\n' +
|
|
275
|
-
`Sec-WebSocket-Accept: ${handshake.accept}\r\n`
|
|
276
|
-
if (handshake.protocol) {
|
|
277
|
-
raw += `Sec-WebSocket-Protocol: ${handshake.protocol}\r\n`
|
|
278
|
-
}
|
|
279
|
-
raw += '\r\n'
|
|
280
|
-
socket.write(raw)
|
|
299
|
+
socket.write(formatWebSocketHandshake(handshake.accept, handshake.protocol))
|
|
281
300
|
|
|
282
301
|
// Hand the upgraded socket to the caller (frame codec / message loop).
|
|
283
302
|
options.onUpgrade?.(socket, req, head)
|
package/src/ingress/server.ts
CHANGED
|
@@ -339,11 +339,10 @@ export function buildRouteHandlers(options: BuildRouteHandlersOptions): {
|
|
|
339
339
|
}
|
|
340
340
|
|
|
341
341
|
// ── Path matching (shared with server-node.ts + router.ts) ─────────
|
|
342
|
-
// `
|
|
343
|
-
// re-exported
|
|
344
|
-
// module).
|
|
342
|
+
// `PathMatch` / `safeDecode` live in path-matcher.ts and are not part of this
|
|
343
|
+
// module's surface; only `buildPathMatcher` is re-exported for back-compat.
|
|
345
344
|
|
|
346
|
-
export { buildPathMatcher
|
|
345
|
+
export { buildPathMatcher } from './path-matcher'
|
|
347
346
|
|
|
348
347
|
/** Typed options forwarded to `Bun.serve`. */
|
|
349
348
|
interface BunServerOptions {
|
package/src/ingress/shared.ts
CHANGED
|
@@ -36,7 +36,7 @@ export const DEFAULT_BAKED_OUTPUT_BUFFER_SIZE = 131_072
|
|
|
36
36
|
* (`handle_request_full_sync` allocates `vec![0u8; output_size]`), OOM-ing the
|
|
37
37
|
* process. Callers that genuinely need more can raise it here.
|
|
38
38
|
*/
|
|
39
|
-
|
|
39
|
+
const MAX_OUTPUT_BUFFER_SIZE = 64 * 1024 * 1024
|
|
40
40
|
|
|
41
41
|
/**
|
|
42
42
|
* Clamp an ingress output-buffer size into `[min, MAX_OUTPUT_BUFFER_SIZE]` so
|
package/src/ingress/types.ts
CHANGED
|
@@ -120,6 +120,21 @@ export interface OptimizedIngressHandler {
|
|
|
120
120
|
retryAfterSecs?: number,
|
|
121
121
|
): [string, string][]
|
|
122
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Success-path headers as a ready `Headers` instance: the memoized base
|
|
125
|
+
* (template + reflected origin) plus per-request rate/request-id extras,
|
|
126
|
+
* without rebuilding `Headers` from an array on every response. Optional so
|
|
127
|
+
* hand-rolled mocks need not provide it; callers fall back to
|
|
128
|
+
* `memoizedHeaders(responseHeaders(...))`.
|
|
129
|
+
*/
|
|
130
|
+
successHeaders?(
|
|
131
|
+
variant: number,
|
|
132
|
+
requestIdHeader: string | null,
|
|
133
|
+
origin: string | null,
|
|
134
|
+
rateRemaining?: number,
|
|
135
|
+
rateResetSecs?: number,
|
|
136
|
+
): Headers
|
|
137
|
+
|
|
123
138
|
terminalHeaders(
|
|
124
139
|
variant: number,
|
|
125
140
|
ctx: BakedContext,
|
package/src/loader/ops.ts
CHANGED
|
@@ -26,7 +26,7 @@ import { decoder } from '../shared/bytes'
|
|
|
26
26
|
import type { SchemaValidator } from '../shared/packed'
|
|
27
27
|
|
|
28
28
|
/** Empty bytes sentinel for skip-on-error scalar parity (e.g. AEAD decrypt). */
|
|
29
|
-
|
|
29
|
+
const EMPTY_BYTES = new Uint8Array(0)
|
|
30
30
|
|
|
31
31
|
/** Result kinds supported by the loader (mirrors the packed batch wire shapes). */
|
|
32
32
|
export type LoaderResultKind = 'boolean' | 'number' | 'bigint' | 'bytes'
|