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.
Files changed (108) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/castrum.darwin-arm64.node +0 -0
  4. package/castrum.darwin-x64.node +0 -0
  5. package/castrum.linux-arm64-gnu.node +0 -0
  6. package/castrum.linux-arm64-musl.node +0 -0
  7. package/castrum.linux-x64-gnu.node +0 -0
  8. package/castrum.linux-x64-musl.node +0 -0
  9. package/castrum.linux-x64-v3-gnu.node +0 -0
  10. package/castrum.win32-x64-msvc.node +0 -0
  11. package/dist/index.d.ts +664 -8
  12. package/dist/index.js +2014 -1327
  13. package/docs/BENCHMARKS.md +13 -0
  14. package/docs/CASE_STUDY.md +1 -1
  15. package/docs/ENVIRONMENT.md +8 -1
  16. package/docs/FFI_BUN_GUIDE.md +1 -1
  17. package/docs/INGRESS.md +4 -4
  18. package/docs/REPO_MAP.md +7 -5
  19. package/docs/RND-CONCURRENCY.md +20 -4
  20. package/docs/superpowers/plans/2026-09-18-castrum-hardening-cleanup.md +617 -0
  21. package/docs/superpowers/plans/2026-09-18-elysia-learnings-adoption.md +634 -0
  22. package/index.ts +6 -0
  23. package/package.json +4 -2
  24. package/rust/crypto/aead.rs +44 -0
  25. package/rust/crypto/argon2.rs +32 -2
  26. package/rust/crypto/jwt/tests.rs +19 -0
  27. package/rust/crypto/jwt/token.rs +43 -28
  28. package/rust/crypto/session.rs +6 -0
  29. package/rust/ffi/crypto.rs +63 -32
  30. package/rust/ffi/hashing.rs +48 -33
  31. package/rust/ffi/http.rs +62 -36
  32. package/rust/ffi/metrics.rs +6 -2
  33. package/rust/ffi/payload.rs +47 -27
  34. package/rust/ffi/task.rs +55 -35
  35. package/rust/ffi/tests.rs +24 -0
  36. package/rust/ffi/util.rs +40 -0
  37. package/rust/http/headers.rs +13 -0
  38. package/rust/http/multipart.rs +139 -0
  39. package/rust/ingress/api.rs +21 -5
  40. package/rust/ingress/mod.rs +7 -0
  41. package/rust/ingress/native_route.rs +25 -5
  42. package/rust/ingress/schema_cache.rs +266 -0
  43. package/rust/task/completion.rs +7 -0
  44. package/rust/task/mod.rs +2 -1
  45. package/rust/task/runtime.rs +55 -7
  46. package/rust/task/tests.rs +128 -1
  47. package/src/ingress/abort.ts +48 -0
  48. package/src/ingress/body.ts +104 -8
  49. package/src/ingress/decode/baked-result.ts +13 -5
  50. package/src/ingress/decode/fast-result.ts +1 -1
  51. package/src/ingress/decode/result-base.ts +16 -2
  52. package/src/ingress/handlers.ts +104 -31
  53. package/src/ingress/headers/memoized-headers.ts +40 -0
  54. package/src/ingress/health.ts +22 -0
  55. package/src/ingress/index.ts +3 -1
  56. package/src/ingress/native-route.ts +25 -6
  57. package/src/ingress/options.ts +26 -2
  58. package/src/ingress/packing/gather-raw-headers.ts +24 -8
  59. package/src/ingress/response/baked-response.ts +118 -28
  60. package/src/ingress/router.ts +50 -3
  61. package/src/ingress/routes/common.ts +35 -11
  62. package/src/ingress/routes/echo.ts +60 -7
  63. package/src/ingress/routes/json-write.ts +32 -8
  64. package/src/ingress/routes/native.ts +24 -5
  65. package/src/ingress/routes/options.ts +6 -2
  66. package/src/ingress/routes/responder.ts +29 -9
  67. package/src/ingress/routes/safe-record.ts +57 -0
  68. package/src/ingress/server-error.ts +111 -0
  69. package/src/ingress/server-node.ts +135 -37
  70. package/src/ingress/server.ts +143 -36
  71. package/src/ingress/shared.ts +1 -1
  72. package/src/ingress/types.ts +15 -0
  73. package/src/integration/pipeline.ts +6 -0
  74. package/src/loader/ops.ts +1 -1
  75. package/src/native/ffi/build/codecs-selftest.ts +305 -0
  76. package/src/native/ffi/build/codecs.ts +0 -296
  77. package/src/native/ffi/build/instances-selftest.ts +264 -0
  78. package/src/native/ffi/build/instances.ts +10 -259
  79. package/src/native/ffi/build/parse-selftest.ts +238 -0
  80. package/src/native/ffi/build/parse.ts +11 -234
  81. package/src/native/ffi/build/task.ts +2 -1
  82. package/src/native/ffi/build.ts +5 -1
  83. package/src/native/ffi/selftest.ts +3 -3
  84. package/src/native/ffi/symbols.ts +460 -0
  85. package/src/native/ffi/types.ts +6 -1
  86. package/src/native/ffi.ts +2 -434
  87. package/src/native/ingress-binding.ts +39 -0
  88. package/src/native/types.ts +6 -0
  89. package/src/runtime/codec.ts +24 -0
  90. package/src/runtime/detect.ts +14 -0
  91. package/src/runtime/types.ts +11 -0
  92. package/src/rust-ffi/batch/build.ts +18 -8
  93. package/src/rust-ffi/batch/types.ts +15 -0
  94. package/src/rust-ffi/context.ts +46 -3
  95. package/src/rust-ffi/options.ts +19 -0
  96. package/src/rust-ffi/scalar/crypto.ts +8 -0
  97. package/src/rust-ffi/scalar/factories.ts +53 -20
  98. package/src/rust-ffi/scalar/http.ts +60 -10
  99. package/src/rust-ffi/scalar/interface.ts +18 -1
  100. package/src/rust-ffi/scalar/payload.ts +27 -1
  101. package/src/rust-ffi/text.ts +5 -4
  102. package/src/shared/buffer-pool.ts +54 -9
  103. package/src/shared/codec.ts +26 -0
  104. package/src/shared/memory.ts +68 -0
  105. package/src/shared/metrics.ts +86 -3
  106. package/src/shared/packed/parsers.ts +64 -7
  107. package/src/shared/runtime.ts +7 -0
  108. 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 = req.headers.get('content-type') ?? 'application/octet-stream'
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: ingress.withContentType(baseHeaders, 'application/json'),
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: ingress.withContentType(baseHeaders, requestedContentType),
118
+ headers: withEchoHeaders(requestedContentType),
70
119
  })
71
120
  }
72
121
 
73
122
  return new Response(req.body, {
74
123
  status: 200,
75
- headers: ingress.withContentType(baseHeaders, requestedContentType),
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: ingress.withContentType(baseHeaders, requestedContentType),
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: ingress.withContentType(baseHeaders, 'application/json'),
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
- const contentType = req.headers.get('content-type') ?? ''
50
- if (!contentType.includes('application/json')) {
51
- return fallback.run(req, ip, null, (result, ctx) => {
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
- return fallback.errorResponse(
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
- /** The `+`-to-space / `%XX` decoding is done natively; keys are last-wins. */
40
- function pairsToRecord(pairs: ReadonlyArray<[string, string]>): Record<string, string> {
41
- const out: Record<string, string> = {}
42
- for (const [k, v] of pairs) out[k] = v
43
- return out
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
- ingress.run<Response>(req, resolveIp(req, srv, opts), null, (result, ctx) => {
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 IGNGEX_SECURITY_HEADERS: [string, string][] = [
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: IGNGEX_SECURITY_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(IGNGEX_SECURITY_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
- /** Parse a `queryJson()`/`cookiesJson()` section into an object (empty on failure). */
72
- function parseSection(json: string): Record<string, unknown> {
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 v: unknown = JSON.parse(json)
75
- return v !== null && typeof v === 'object' && !Array.isArray(v)
76
- ? (v as Record<string, unknown>)
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(IGNGEX_SECURITY_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
+ }