castrum 0.9.7 → 0.9.9
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 +664 -8
- package/dist/index.js +2014 -1327
- package/docs/BENCHMARKS.md +13 -0
- package/docs/CASE_STUDY.md +1 -1
- package/docs/ENVIRONMENT.md +8 -1
- package/docs/FFI_BUN_GUIDE.md +1 -1
- package/docs/INGRESS.md +4 -4
- package/docs/REPO_MAP.md +7 -5
- package/docs/RND-CONCURRENCY.md +20 -4
- package/docs/superpowers/plans/2026-09-18-castrum-hardening-cleanup.md +617 -0
- package/docs/superpowers/plans/2026-09-18-elysia-learnings-adoption.md +634 -0
- package/index.ts +6 -0
- package/package.json +4 -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 +55 -35
- 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/api.rs +21 -5
- package/rust/ingress/mod.rs +7 -0
- package/rust/ingress/native_route.rs +25 -5
- package/rust/ingress/schema_cache.rs +266 -0
- package/rust/task/completion.rs +7 -0
- package/rust/task/mod.rs +2 -1
- package/rust/task/runtime.rs +55 -7
- package/rust/task/tests.rs +128 -1
- package/src/ingress/abort.ts +48 -0
- package/src/ingress/body.ts +104 -8
- 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 +104 -31
- package/src/ingress/headers/memoized-headers.ts +40 -0
- package/src/ingress/health.ts +22 -0
- package/src/ingress/index.ts +3 -1
- 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 +50 -3
- package/src/ingress/routes/common.ts +35 -11
- package/src/ingress/routes/echo.ts +60 -7
- package/src/ingress/routes/json-write.ts +32 -8
- package/src/ingress/routes/native.ts +24 -5
- package/src/ingress/routes/options.ts +6 -2
- package/src/ingress/routes/responder.ts +29 -9
- package/src/ingress/routes/safe-record.ts +57 -0
- package/src/ingress/server-error.ts +111 -0
- package/src/ingress/server-node.ts +135 -37
- package/src/ingress/server.ts +143 -36
- package/src/ingress/shared.ts +1 -1
- package/src/ingress/types.ts +15 -0
- package/src/integration/pipeline.ts +6 -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/task.ts +2 -1
- 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/types.ts +6 -1
- package/src/native/ffi.ts +2 -434
- package/src/native/ingress-binding.ts +39 -0
- package/src/native/types.ts +6 -0
- package/src/runtime/codec.ts +24 -0
- package/src/runtime/detect.ts +14 -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/context.ts +46 -3
- package/src/rust-ffi/options.ts +19 -0
- package/src/rust-ffi/scalar/crypto.ts +8 -0
- package/src/rust-ffi/scalar/factories.ts +53 -20
- package/src/rust-ffi/scalar/http.ts +60 -10
- package/src/rust-ffi/scalar/interface.ts +18 -1
- package/src/rust-ffi/scalar/payload.ts +27 -1
- package/src/rust-ffi/text.ts +5 -4
- package/src/shared/buffer-pool.ts +54 -9
- package/src/shared/codec.ts +26 -0
- package/src/shared/memory.ts +68 -0
- package/src/shared/metrics.ts +86 -3
- package/src/shared/packed/parsers.ts +64 -7
- package/src/shared/runtime.ts +7 -0
- package/src/task/runtime.ts +81 -37
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// src/ingress/routes/echo.ts — Pre-baked echo handler.
|
|
2
2
|
|
|
3
|
+
import { abortResponse, isAbortError } from '../abort'
|
|
3
4
|
import { readBodyWithLimit } from '../body'
|
|
4
5
|
import { HV_JSON } from '../constants'
|
|
5
6
|
import { ERROR_BODIES } from '../response/error-bodies'
|
|
@@ -7,9 +8,40 @@ import { DEFAULT_BODY_TIMEOUT_MS, DEFAULT_MAX_BODY_BYTES, secondsFromMs } from '
|
|
|
7
8
|
import type { OptimizedIngressHandler } from '../types'
|
|
8
9
|
import { type BakedHandlerOptions, resolveIp } from './common'
|
|
9
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Content types safe to echo back for an attacker-supplied body. Anything else
|
|
13
|
+
* — notably `text/html`, `application/xhtml+xml`, `image/svg+xml` and XML — is
|
|
14
|
+
* coerced to `application/octet-stream`: reflecting an active type would let a
|
|
15
|
+
* `POST /echo` execute the request body as script in a browser.
|
|
16
|
+
*/
|
|
17
|
+
const SAFE_ECHO_CONTENT_TYPES = new Set([
|
|
18
|
+
'application/json',
|
|
19
|
+
'application/octet-stream',
|
|
20
|
+
'text/plain',
|
|
21
|
+
])
|
|
22
|
+
|
|
23
|
+
/** Lowercased media type with parameters stripped (`text/html; charset=x` → `text/html`). */
|
|
24
|
+
function echoMediaTypeOf(contentType: string): string {
|
|
25
|
+
const semi = contentType.indexOf(';')
|
|
26
|
+
return (semi >= 0 ? contentType.slice(0, semi) : contentType).trim().toLowerCase()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Coerce a client Content-Type into a type that is safe to echo back.
|
|
31
|
+
*
|
|
32
|
+
* @param contentType - The raw request `Content-Type` header value.
|
|
33
|
+
* @returns The value unchanged when it is a safe non-active type, else
|
|
34
|
+
* `application/octet-stream`.
|
|
35
|
+
*/
|
|
36
|
+
export function safeEchoContentType(contentType: string): string {
|
|
37
|
+
return SAFE_ECHO_CONTENT_TYPES.has(echoMediaTypeOf(contentType))
|
|
38
|
+
? contentType
|
|
39
|
+
: 'application/octet-stream'
|
|
40
|
+
}
|
|
41
|
+
|
|
10
42
|
/**
|
|
11
43
|
* Pre-baked echo handler: streams the request body back with the client's
|
|
12
|
-
* Content-Type, bounded by `maxBodyBytes`.
|
|
44
|
+
* Content-Type (coerced to a non-active type), bounded by `maxBodyBytes`.
|
|
13
45
|
*/
|
|
14
46
|
export function echoHandler(
|
|
15
47
|
ingress: OptimizedIngressHandler,
|
|
@@ -22,6 +54,8 @@ export function echoHandler(
|
|
|
22
54
|
return async (req, srv) => {
|
|
23
55
|
const ip = resolveIp(req, srv, opts)
|
|
24
56
|
|
|
57
|
+
if (req.signal?.aborted) return abortResponse()
|
|
58
|
+
|
|
25
59
|
const prep = ingress.run<{
|
|
26
60
|
terminal?: Response
|
|
27
61
|
headers?: ReadonlyArray<[string, string]>
|
|
@@ -48,9 +82,24 @@ export function echoHandler(
|
|
|
48
82
|
|
|
49
83
|
if (prep.terminal) return prep.terminal
|
|
50
84
|
|
|
85
|
+
// The client may have disconnected while the native prep ran.
|
|
86
|
+
if (req.signal?.aborted) return abortResponse()
|
|
87
|
+
|
|
51
88
|
const baseHeaders: ReadonlyArray<[string, string]> = prep.headers ?? []
|
|
52
89
|
|
|
53
|
-
const requestedContentType =
|
|
90
|
+
const requestedContentType = safeEchoContentType(
|
|
91
|
+
req.headers.get('content-type') ?? 'application/octet-stream',
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
// Content-Type + `nosniff` (unless the security config already emitted it),
|
|
95
|
+
// so the echoed body can never be sniffed into an active type.
|
|
96
|
+
const withEchoHeaders = (contentType: string): [string, string][] => {
|
|
97
|
+
const headers = ingress.withContentType(baseHeaders, contentType)
|
|
98
|
+
if (!headers.some(([name]) => name.toLowerCase() === 'x-content-type-options')) {
|
|
99
|
+
headers.push(['x-content-type-options', 'nosniff'])
|
|
100
|
+
}
|
|
101
|
+
return headers
|
|
102
|
+
}
|
|
54
103
|
|
|
55
104
|
const contentLengthHeader = req.headers.get('content-length')
|
|
56
105
|
const contentLength = contentLengthHeader === null ? NaN : Number(contentLengthHeader)
|
|
@@ -59,20 +108,20 @@ export function echoHandler(
|
|
|
59
108
|
if (contentLength > maxBodyBytes) {
|
|
60
109
|
return new Response(ERROR_BODIES.body_too_large, {
|
|
61
110
|
status: 413,
|
|
62
|
-
headers:
|
|
111
|
+
headers: withEchoHeaders('application/json'),
|
|
63
112
|
})
|
|
64
113
|
}
|
|
65
114
|
|
|
66
115
|
if (contentLength <= 0 || req.body === null) {
|
|
67
116
|
return new Response(null, {
|
|
68
117
|
status: 200,
|
|
69
|
-
headers:
|
|
118
|
+
headers: withEchoHeaders(requestedContentType),
|
|
70
119
|
})
|
|
71
120
|
}
|
|
72
121
|
|
|
73
122
|
return new Response(req.body, {
|
|
74
123
|
status: 200,
|
|
75
|
-
headers:
|
|
124
|
+
headers: withEchoHeaders(requestedContentType),
|
|
76
125
|
})
|
|
77
126
|
}
|
|
78
127
|
|
|
@@ -82,11 +131,15 @@ export function echoHandler(
|
|
|
82
131
|
// oversized or trickling body before rejecting it.
|
|
83
132
|
const bodyBytes = await readBodyWithLimit(req, maxBodyBytes, true, bodyTimeoutMs)
|
|
84
133
|
|
|
134
|
+
// Disconnect during the read must not be answered with a body.
|
|
135
|
+
if (req.signal?.aborted) return abortResponse()
|
|
136
|
+
|
|
85
137
|
return new Response(bodyBytes.byteLength > 0 ? bodyBytes : null, {
|
|
86
138
|
status: 200,
|
|
87
|
-
headers:
|
|
139
|
+
headers: withEchoHeaders(requestedContentType),
|
|
88
140
|
})
|
|
89
141
|
} catch (err) {
|
|
142
|
+
if (isAbortError(err)) return abortResponse()
|
|
90
143
|
const code = (err as { code?: string } | null)?.code
|
|
91
144
|
const isTooLarge = code === 'BODY_TOO_LARGE'
|
|
92
145
|
const isTimeout = code === 'REQUEST_TIMEOUT'
|
|
@@ -99,7 +152,7 @@ export function echoHandler(
|
|
|
99
152
|
: ERROR_BODIES.bad_request,
|
|
100
153
|
{
|
|
101
154
|
status: isTooLarge ? 413 : isTimeout ? 408 : 400,
|
|
102
|
-
headers:
|
|
155
|
+
headers: withEchoHeaders('application/json'),
|
|
103
156
|
},
|
|
104
157
|
)
|
|
105
158
|
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// (POST/PUT/PATCH).
|
|
3
3
|
|
|
4
4
|
import { decodeUtf8 } from '../../shared/codec'
|
|
5
|
+
import { abortResponse, isAbortError } from '../abort'
|
|
5
6
|
import { readBodyWithLimit } from '../body'
|
|
6
7
|
import { DEFAULT_BODY_TIMEOUT_MS, DEFAULT_MAX_BODY_BYTES } from '../shared'
|
|
7
8
|
import type { OptimizedIngressHandler } from '../types'
|
|
@@ -23,6 +24,18 @@ function defaultJsonValid(bytes: Uint8Array): boolean {
|
|
|
23
24
|
}
|
|
24
25
|
}
|
|
25
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Whether a `Content-Type` header denotes JSON: the media type (the part before
|
|
29
|
+
* any parameters) is exactly `application/json`, or a `+json` structured suffix
|
|
30
|
+
* (e.g. `application/vnd.api+json`). A plain substring test would wrongly
|
|
31
|
+
* accept `text/plain; application/json`.
|
|
32
|
+
*/
|
|
33
|
+
function isJsonContentType(contentType: string): boolean {
|
|
34
|
+
const semi = contentType.indexOf(';')
|
|
35
|
+
const mediaType = (semi >= 0 ? contentType.slice(0, semi) : contentType).trim().toLowerCase()
|
|
36
|
+
return mediaType === 'application/json' || mediaType.endsWith('+json')
|
|
37
|
+
}
|
|
38
|
+
|
|
26
39
|
/**
|
|
27
40
|
* Pre-baked JSON-write handler (POST/PUT/PATCH): enforces Content-Type,
|
|
28
41
|
* content-length/body-size limits, JSON validity and (optionally) schema
|
|
@@ -46,21 +59,26 @@ export function jsonWriteHandler(
|
|
|
46
59
|
return async (req, srv) => {
|
|
47
60
|
const ip = resolveIp(req, srv, opts)
|
|
48
61
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const terminal = fallback.terminalResponse(req, result, ctx)
|
|
53
|
-
if (terminal) return terminal
|
|
62
|
+
// Client already gone: do no work (no content-type check, no body read,
|
|
63
|
+
// no native run). A disconnect DURING the body read is handled below.
|
|
64
|
+
if (req.signal?.aborted) return abortResponse()
|
|
54
65
|
|
|
55
|
-
|
|
66
|
+
const contentType = req.headers.get('content-type') ?? ''
|
|
67
|
+
if (!isJsonContentType(contentType)) {
|
|
68
|
+
// Always 415 for an unsupported media type. Do NOT short-circuit on the
|
|
69
|
+
// native terminal first: running with no body yields a "body required"
|
|
70
|
+
// 400, which previously masked the 415 (the native pipeline still applied
|
|
71
|
+
// rate limiting / CORS headers to this run).
|
|
72
|
+
return fallback.run(req, ip, null, (result, ctx) =>
|
|
73
|
+
fallback.errorResponse(
|
|
56
74
|
req,
|
|
57
75
|
result,
|
|
58
76
|
415,
|
|
59
77
|
'unsupported_media_type',
|
|
60
78
|
'Content-Type must be application/json',
|
|
61
79
|
ctx,
|
|
62
|
-
)
|
|
63
|
-
|
|
80
|
+
),
|
|
81
|
+
)
|
|
64
82
|
}
|
|
65
83
|
|
|
66
84
|
// Content-Length / body-size enforcement happens INSIDE readBodyWithLimit
|
|
@@ -74,6 +92,9 @@ export function jsonWriteHandler(
|
|
|
74
92
|
// never fully buffered first.
|
|
75
93
|
bodyBytes = await readBodyWithLimit(req, maxBodyBytes, true, bodyTimeoutMs)
|
|
76
94
|
} catch (err) {
|
|
95
|
+
// Client disconnect is NOT a bad request / timeout: return the cancelled
|
|
96
|
+
// response without running the native pipeline.
|
|
97
|
+
if (isAbortError(err)) return abortResponse()
|
|
77
98
|
const code = (err as { code?: string } | null)?.code
|
|
78
99
|
const isTooLarge = code === 'BODY_TOO_LARGE'
|
|
79
100
|
return fallback.run(req, ip, null, (result, ctx) => {
|
|
@@ -91,6 +112,9 @@ export function jsonWriteHandler(
|
|
|
91
112
|
})
|
|
92
113
|
}
|
|
93
114
|
|
|
115
|
+
// The client may have disconnected while the body was streaming in.
|
|
116
|
+
if (req.signal?.aborted) return abortResponse()
|
|
117
|
+
|
|
94
118
|
return ingress.run(req, ip, bodyBytes, (result, ctx) => {
|
|
95
119
|
const terminal = ingress.terminalResponse(req, result, ctx)
|
|
96
120
|
if (terminal) return terminal
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
|
|
22
22
|
import { decoder } from '../../shared/bytes'
|
|
23
23
|
import { generateRequestId } from '../../shared/request-id'
|
|
24
|
+
import { abortResponse, isAbortError } from '../abort'
|
|
24
25
|
import { readBodyWithLimit } from '../body'
|
|
25
26
|
import type { NativeRoute } from '../native-route'
|
|
26
27
|
import type { RouteWireResult } from '../packing/route-wire'
|
|
@@ -29,6 +30,7 @@ import { DEFAULT_BODY_TIMEOUT_MS, DEFAULT_MAX_BODY_BYTES } from '../shared'
|
|
|
29
30
|
import type { NativeRequestContext, NativeResponder } from '../types'
|
|
30
31
|
import type { BakedHandlerOptions } from './common'
|
|
31
32
|
import { resolveIp } from './common'
|
|
33
|
+
import { assignOwn, safeRecord } from './safe-record'
|
|
32
34
|
|
|
33
35
|
/** Options for {@link nativeRouteHandler}. */
|
|
34
36
|
export interface NativeRouteHandlerOptions extends BakedHandlerOptions {
|
|
@@ -36,11 +38,22 @@ export interface NativeRouteHandlerOptions extends BakedHandlerOptions {
|
|
|
36
38
|
readBody?: boolean
|
|
37
39
|
}
|
|
38
40
|
|
|
39
|
-
/**
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
/**
|
|
42
|
+
* Build a prototype-safe record from native packed pairs. The `+`-to-space /
|
|
43
|
+
* `%XX` decoding is done natively; keys are last-wins. Keys are written as OWN
|
|
44
|
+
* data via {@link assignOwn}: `__proto__` is defined (never routed through the
|
|
45
|
+
* inherited setter), and other keys — including `constructor` / `prototype` —
|
|
46
|
+
* are plain own shadowing properties. The record keeps `Object.prototype`.
|
|
47
|
+
*
|
|
48
|
+
* Exported for the prototype-pollution tests.
|
|
49
|
+
*
|
|
50
|
+
* @param pairs - Decoded `[key, value]` pairs from the native route stack.
|
|
51
|
+
* @returns A record with `Object.prototype` preserved (last value wins per key).
|
|
52
|
+
*/
|
|
53
|
+
export function pairsToRecord(pairs: ReadonlyArray<[string, string]>): Record<string, string> {
|
|
54
|
+
const out = safeRecord()
|
|
55
|
+
for (const [k, v] of pairs) assignOwn(out, k, v)
|
|
56
|
+
return out as Record<string, string>
|
|
44
57
|
}
|
|
45
58
|
|
|
46
59
|
/**
|
|
@@ -78,6 +91,8 @@ export function nativeRouteHandler(
|
|
|
78
91
|
|
|
79
92
|
return async (req, srv, _params) => {
|
|
80
93
|
const ip = resolveIp(req, srv, opts)
|
|
94
|
+
// Client already gone: no request-id work, body read, or native run.
|
|
95
|
+
if (req.signal?.aborted) return abortResponse()
|
|
81
96
|
const requestId = decoder.decode(generateRequestId())
|
|
82
97
|
|
|
83
98
|
let body: Uint8Array | null = null
|
|
@@ -85,10 +100,14 @@ export function nativeRouteHandler(
|
|
|
85
100
|
try {
|
|
86
101
|
body = await readBodyWithLimit(req, maxBodyBytes, true, bodyTimeoutMs)
|
|
87
102
|
} catch (err) {
|
|
103
|
+
// Client disconnect: the cancelled response, never a 400/408/413.
|
|
104
|
+
if (isAbortError(err)) return abortResponse()
|
|
88
105
|
const code = (err as Error & { code?: string }).code
|
|
89
106
|
const status = code === 'REQUEST_TIMEOUT' ? 408 : code === 'BODY_TOO_LARGE' ? 413 : 400
|
|
90
107
|
return terminal(status, 'BAD_REQUEST', 'Request body read failed')
|
|
91
108
|
}
|
|
109
|
+
// Disconnect while the body was streaming in.
|
|
110
|
+
if (req.signal?.aborted) return abortResponse()
|
|
92
111
|
}
|
|
93
112
|
|
|
94
113
|
// Extract the query substring + Cookie header (the only request inputs the
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// src/ingress/routes/options.ts — Pre-baked OPTIONS handler (CORS preflight).
|
|
2
2
|
|
|
3
|
+
import { abortResponse } from '../abort'
|
|
3
4
|
import type { OptimizedIngressHandler } from '../types'
|
|
4
5
|
import { type BakedHandlerOptions, buildSuccessInit, resolveIp } from './common'
|
|
5
6
|
|
|
@@ -16,11 +17,14 @@ export function optionsHandler(
|
|
|
16
17
|
ingress: OptimizedIngressHandler,
|
|
17
18
|
opts: BakedHandlerOptions = {},
|
|
18
19
|
): (req: Request, srv?: unknown) => Response {
|
|
19
|
-
return (req, srv) =>
|
|
20
|
-
|
|
20
|
+
return (req, srv) => {
|
|
21
|
+
// Client already gone: no pipeline run, no pooled output buffer.
|
|
22
|
+
if (req.signal?.aborted) return abortResponse()
|
|
23
|
+
return ingress.run<Response>(req, resolveIp(req, srv, opts), null, (result, ctx) => {
|
|
21
24
|
const terminal = ingress.terminalResponse(req, result, ctx)
|
|
22
25
|
if (terminal) return terminal
|
|
23
26
|
|
|
24
27
|
return new Response(null, buildSuccessInit(ingress, result, ctx, 204))
|
|
25
28
|
})
|
|
29
|
+
}
|
|
26
30
|
}
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import { decoder } from '../../shared/bytes'
|
|
18
18
|
import { generateRequestId } from '../../shared/request-id'
|
|
19
|
+
import { abortResponse, isAbortError } from '../abort'
|
|
19
20
|
import { readBodyWithLimit } from '../body'
|
|
20
21
|
import type { BakedIngressResult } from '../decode/baked-result'
|
|
21
22
|
import { errorCodeName, errorMessage } from '../errors'
|
|
@@ -45,7 +46,7 @@ export interface ResponderRouteOptions extends BakedHandlerOptions {
|
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
/** ignex framework security posture for terminal responses. */
|
|
48
|
-
const
|
|
49
|
+
const IGNEX_SECURITY_HEADERS: [string, string][] = [
|
|
49
50
|
['x-frame-options', 'DENY'],
|
|
50
51
|
['x-content-type-options', 'nosniff'],
|
|
51
52
|
['referrer-policy', 'no-referrer'],
|
|
@@ -55,7 +56,7 @@ const IGNGEX_SECURITY_HEADERS: [string, string][] = [
|
|
|
55
56
|
function buildIgngexTerminal(result: BakedIngressResult, _ctx: BakedContext): Response {
|
|
56
57
|
const preflightAllowed = result.isPreflight && result.corsAllowed
|
|
57
58
|
if (preflightAllowed) {
|
|
58
|
-
return new Response(null, { status: 204, headers:
|
|
59
|
+
return new Response(null, { status: 204, headers: IGNEX_SECURITY_HEADERS })
|
|
59
60
|
}
|
|
60
61
|
const status = safeTerminalStatus(result)
|
|
61
62
|
const body = JSON.stringify({
|
|
@@ -63,17 +64,30 @@ function buildIgngexTerminal(result: BakedIngressResult, _ctx: BakedContext): Re
|
|
|
63
64
|
status,
|
|
64
65
|
code: errorCodeName(result.errorCode),
|
|
65
66
|
})
|
|
66
|
-
const headers = new Headers(
|
|
67
|
+
const headers = new Headers(IGNEX_SECURITY_HEADERS)
|
|
67
68
|
headers.set('content-type', 'application/json; charset=utf-8')
|
|
68
69
|
return new Response(body, { status, headers })
|
|
69
70
|
}
|
|
70
71
|
|
|
71
|
-
/**
|
|
72
|
-
|
|
72
|
+
/**
|
|
73
|
+
* Parse a `queryJson()`/`cookiesJson()` section into a record (empty on
|
|
74
|
+
* failure).
|
|
75
|
+
*
|
|
76
|
+
* Prototype-safe by construction: `JSON.parse` creates `__proto__` as an OWN
|
|
77
|
+
* enumerable data property (it never invokes the setter), so the returned
|
|
78
|
+
* object keeps `Object.prototype` and carries `__proto__` as inert own data.
|
|
79
|
+
*
|
|
80
|
+
* Exported for the prototype-pollution tests.
|
|
81
|
+
*
|
|
82
|
+
* @param json - The section JSON emitted by the native pipeline.
|
|
83
|
+
* @returns The decoded object, or an empty record for a non-object / malformed
|
|
84
|
+
* section.
|
|
85
|
+
*/
|
|
86
|
+
export function parseSection(json: string): Record<string, unknown> {
|
|
73
87
|
try {
|
|
74
|
-
const
|
|
75
|
-
return
|
|
76
|
-
? (
|
|
88
|
+
const parsed: unknown = JSON.parse(json)
|
|
89
|
+
return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
90
|
+
? (parsed as Record<string, unknown>)
|
|
77
91
|
: {}
|
|
78
92
|
} catch {
|
|
79
93
|
return {}
|
|
@@ -97,6 +111,8 @@ export function nativeResponderRoute(
|
|
|
97
111
|
|
|
98
112
|
return async (req, srv, _params) => {
|
|
99
113
|
const ip = resolveIp(req, srv, opts)
|
|
114
|
+
// Client already gone: no request-id work, body read, or native run.
|
|
115
|
+
if (req.signal?.aborted) return abortResponse()
|
|
100
116
|
// The request-id the framework surfaces (same source as the pre-baked path's
|
|
101
117
|
// `ctx.requestIdHeader`): the baked result does not carry it, so generate it
|
|
102
118
|
// here — the native pipeline uses the same rid for its internal frame.
|
|
@@ -109,13 +125,17 @@ export function nativeResponderRoute(
|
|
|
109
125
|
try {
|
|
110
126
|
body = await readBodyWithLimit(req, maxBodyBytes, true, bodyTimeoutMs)
|
|
111
127
|
} catch (err) {
|
|
128
|
+
// Client disconnect: the cancelled response, never a 400/408/413.
|
|
129
|
+
if (isAbortError(err)) return abortResponse()
|
|
112
130
|
const code = (err as Error & { code?: string }).code
|
|
113
131
|
const status = code === 'REQUEST_TIMEOUT' ? 408 : code === 'BODY_TOO_LARGE' ? 413 : 400
|
|
114
132
|
const payload = JSON.stringify({ error: 'Bad Request', status, code: 'BAD_REQUEST' })
|
|
115
|
-
const headers = new Headers(
|
|
133
|
+
const headers = new Headers(IGNEX_SECURITY_HEADERS)
|
|
116
134
|
headers.set('content-type', 'application/json; charset=utf-8')
|
|
117
135
|
return new Response(payload, { status, headers })
|
|
118
136
|
}
|
|
137
|
+
// Disconnect while the body was streaming in.
|
|
138
|
+
if (req.signal?.aborted) return abortResponse()
|
|
119
139
|
}
|
|
120
140
|
|
|
121
141
|
let terminal: Response | null = null
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// src/ingress/routes/safe-record.ts — prototype-safe key assignment for
|
|
2
|
+
// request-derived objects.
|
|
3
|
+
//
|
|
4
|
+
// PURE: no addon and no pooled-buffer state. Request keys are attacker
|
|
5
|
+
// controlled, so a key of `__proto__` must not invoke the inherited setter on a
|
|
6
|
+
// plain object. `assignOwn` writes `__proto__` with `Object.defineProperty` (an
|
|
7
|
+
// own enumerable data property) and every other key with plain assignment,
|
|
8
|
+
// which is already safe: `constructor` / `prototype` just become own shadowing
|
|
9
|
+
// properties.
|
|
10
|
+
//
|
|
11
|
+
// Scope: this protects the RECORD itself — it keeps `Object.prototype` (so
|
|
12
|
+
// `hasOwnProperty` / `instanceof Object` still work), gains an own `__proto__`
|
|
13
|
+
// data key, and is never mutated through the setter. It does NOT protect a
|
|
14
|
+
// consumer that blindly merges the record into another object, e.g.
|
|
15
|
+
// `Object.assign({}, rec)`: the own `__proto__` key is still enumerable, so a
|
|
16
|
+
// merge target's inherited setter can fire there. Such consumers must guard
|
|
17
|
+
// their own merge.
|
|
18
|
+
|
|
19
|
+
/** A request-derived key that would invoke the inherited `__proto__` setter. */
|
|
20
|
+
const PROTO_KEY = '__proto__'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Create an empty plain record (`Object.prototype` is preserved, so
|
|
24
|
+
* `hasOwnProperty` / `instanceof Object` keep working). Safety comes from
|
|
25
|
+
* {@link assignOwn}, not from the container.
|
|
26
|
+
*
|
|
27
|
+
* @returns A fresh, empty `Record<string, unknown>`.
|
|
28
|
+
*/
|
|
29
|
+
export function safeRecord(): Record<string, unknown> {
|
|
30
|
+
return {}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Assign a request-derived key as an OWN enumerable data property.
|
|
35
|
+
*
|
|
36
|
+
* Only `__proto__` is written with `Object.defineProperty` — a plain
|
|
37
|
+
* `target[key] = value` would invoke the inherited `__proto__` setter and
|
|
38
|
+
* mutate the prototype. Every other key (including `constructor` /
|
|
39
|
+
* `prototype`) uses plain assignment: faster, and it merely shadows on the
|
|
40
|
+
* object.
|
|
41
|
+
*
|
|
42
|
+
* @param target - Record to write into (normally from {@link safeRecord}).
|
|
43
|
+
* @param key - Request-derived key (may be `__proto__` / `constructor`).
|
|
44
|
+
* @param value - Request-derived value.
|
|
45
|
+
*/
|
|
46
|
+
export function assignOwn(target: Record<string, unknown>, key: string, value: unknown): void {
|
|
47
|
+
if (key === PROTO_KEY) {
|
|
48
|
+
Object.defineProperty(target, key, {
|
|
49
|
+
value,
|
|
50
|
+
enumerable: true,
|
|
51
|
+
writable: true,
|
|
52
|
+
configurable: true,
|
|
53
|
+
})
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
target[key] = value
|
|
57
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// src/ingress/server-error.ts — request-handler fault containment.
|
|
2
|
+
//
|
|
3
|
+
// User-supplied route handlers (a raw `spec.read`, a `responder`, a `native`
|
|
4
|
+
// responder) run OUTSIDE the native pipeline. On Bun a sync throw / async
|
|
5
|
+
// rejection would otherwise escape to Bun's default handler (which is not
|
|
6
|
+
// castrum-controlled and may surface stack/internal detail); on Node it hit a
|
|
7
|
+
// local generic-500 construction. This module provides ONE masked-500 responder
|
|
8
|
+
// shared by both runtimes plus a guard that contains sync throws and async
|
|
9
|
+
// rejections. The response body is the pre-encoded internal error body — the
|
|
10
|
+
// thrown message/stack is NEVER placed on the wire (info-leak rule).
|
|
11
|
+
|
|
12
|
+
import { memoizedHeaders } from './headers/memoized-headers'
|
|
13
|
+
import { ERROR_BODIES } from './response/error-bodies'
|
|
14
|
+
import type { RouteHandler } from './server'
|
|
15
|
+
|
|
16
|
+
/** Options for {@link createServerErrorHandler}. */
|
|
17
|
+
export interface ServerErrorOptions {
|
|
18
|
+
/**
|
|
19
|
+
* Observability hook for an escaped handler error. Receives the real `Error`
|
|
20
|
+
* (unmasked — server-side only). Never throws: a failing hook is swallowed so
|
|
21
|
+
* it cannot break the masked response.
|
|
22
|
+
*/
|
|
23
|
+
onError?: (info: { error: Error; request?: Request }) => void
|
|
24
|
+
/**
|
|
25
|
+
* Structured-log sink: one JSON line per escaped error (message only, no
|
|
26
|
+
* stack, no response leak). Never throws.
|
|
27
|
+
*/
|
|
28
|
+
logger?: (line: string) => void
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Coerce an unknown thrown value to an `Error` for the hook/logger. */
|
|
32
|
+
function toError(value: unknown): Error {
|
|
33
|
+
return value instanceof Error ? value : new Error(String(value))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const INTERNAL_HEADERS: readonly [string, string][] = [
|
|
37
|
+
['content-type', 'application/json; charset=utf-8'],
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Build a masked-500 responder. The returned function never throws, always
|
|
42
|
+
* answers with the pre-encoded internal error body, and runs the optional
|
|
43
|
+
* `onError`/`logger` hooks in a contained way.
|
|
44
|
+
*
|
|
45
|
+
* @param opts - Optional observability hooks.
|
|
46
|
+
* @returns A `(error, req?) => Response` responder for escaped handler errors.
|
|
47
|
+
* @example
|
|
48
|
+
* ```ts
|
|
49
|
+
* const onServerError = createServerErrorHandler({ onError: console.error })
|
|
50
|
+
* onServerError(new Error('boom')) // Response(500)
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
export function createServerErrorHandler(
|
|
54
|
+
opts: ServerErrorOptions,
|
|
55
|
+
): (error: unknown, req?: Request) => Response {
|
|
56
|
+
const headers = memoizedHeaders(INTERNAL_HEADERS)
|
|
57
|
+
return (error, req) => {
|
|
58
|
+
const err = toError(error)
|
|
59
|
+
if (opts.onError) {
|
|
60
|
+
try {
|
|
61
|
+
opts.onError({ error: err, request: req })
|
|
62
|
+
} catch {
|
|
63
|
+
// Observability must never break the response.
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (opts.logger) {
|
|
67
|
+
try {
|
|
68
|
+
opts.logger(
|
|
69
|
+
JSON.stringify({
|
|
70
|
+
level: 'error',
|
|
71
|
+
msg: 'unhandled_handler_error',
|
|
72
|
+
error: err.message,
|
|
73
|
+
}),
|
|
74
|
+
)
|
|
75
|
+
} catch {
|
|
76
|
+
// Logging must never break the response.
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return new Response(ERROR_BODIES.internal, { status: 500, headers })
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Wrap a route handler so a sync throw or an async rejection is contained and
|
|
85
|
+
* turned into the shared masked 500. The success path is passed through
|
|
86
|
+
* unchanged.
|
|
87
|
+
*
|
|
88
|
+
* @param handler - The route handler to guard.
|
|
89
|
+
* @param onServerError - The masked-500 responder (see
|
|
90
|
+
* {@link createServerErrorHandler}).
|
|
91
|
+
* @returns A `RouteHandler` with the same signature and success semantics.
|
|
92
|
+
*/
|
|
93
|
+
export function guardRouteHandler(
|
|
94
|
+
handler: RouteHandler,
|
|
95
|
+
onServerError: (error: unknown, req?: Request) => Response,
|
|
96
|
+
): RouteHandler {
|
|
97
|
+
return (req, srv, params) => {
|
|
98
|
+
try {
|
|
99
|
+
const out = handler(req, srv, params)
|
|
100
|
+
// Duck-type thenables (cross-realm Promises/custom thenables fail
|
|
101
|
+
// `instanceof Promise`). A `Response` never exposes `then`.
|
|
102
|
+
const thenable = out as Promise<Response> | null | undefined
|
|
103
|
+
if (thenable !== null && thenable !== undefined && typeof thenable.then === 'function') {
|
|
104
|
+
return thenable.catch((e) => onServerError(e, req))
|
|
105
|
+
}
|
|
106
|
+
return out
|
|
107
|
+
} catch (e) {
|
|
108
|
+
return onServerError(e, req)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|