@fuzdev/fuz_app 0.134.0 → 0.136.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/dist/actions/CLAUDE.md +93 -11
  2. package/dist/actions/register_action_ws.d.ts +23 -3
  3. package/dist/actions/register_action_ws.d.ts.map +1 -1
  4. package/dist/actions/register_action_ws.js +57 -37
  5. package/dist/actions/transports.d.ts +23 -0
  6. package/dist/actions/transports.d.ts.map +1 -1
  7. package/dist/actions/transports.js +23 -0
  8. package/dist/actions/transports_ws_backend.d.ts +133 -10
  9. package/dist/actions/transports_ws_backend.d.ts.map +1 -1
  10. package/dist/actions/transports_ws_backend.js +237 -34
  11. package/dist/actions/ws_endpoint_spec.d.ts +9 -0
  12. package/dist/actions/ws_endpoint_spec.d.ts.map +1 -1
  13. package/dist/actions/ws_outbound_writer.d.ts +206 -0
  14. package/dist/actions/ws_outbound_writer.d.ts.map +1 -0
  15. package/dist/actions/ws_outbound_writer.js +431 -0
  16. package/dist/auth/CLAUDE.md +10 -4
  17. package/dist/auth/account_action_specs.d.ts +7 -0
  18. package/dist/auth/account_action_specs.d.ts.map +1 -1
  19. package/dist/auth/account_action_specs.js +11 -1
  20. package/dist/auth/audit_emitter.d.ts +4 -0
  21. package/dist/auth/audit_emitter.d.ts.map +1 -1
  22. package/dist/auth/audit_emitter.js +18 -2
  23. package/dist/auth/audit_log_routes.d.ts.map +1 -1
  24. package/dist/auth/audit_log_routes.js +3 -1
  25. package/dist/auth/cell_action_specs.d.ts +10 -0
  26. package/dist/auth/cell_action_specs.d.ts.map +1 -1
  27. package/dist/auth/cell_action_specs.js +17 -3
  28. package/dist/auth/cell_actions.js +1 -1
  29. package/dist/auth/role_grant_offer_action_specs.d.ts.map +1 -1
  30. package/dist/auth/role_grant_offer_action_specs.js +7 -2
  31. package/dist/auth/role_schema.d.ts +14 -3
  32. package/dist/auth/role_schema.d.ts.map +1 -1
  33. package/dist/auth/role_schema.js +14 -2
  34. package/dist/auth/scope_kind_schema.d.ts +10 -0
  35. package/dist/auth/scope_kind_schema.d.ts.map +1 -1
  36. package/dist/auth/scope_kind_schema.js +11 -0
  37. package/dist/db/migrate.d.ts +1 -1
  38. package/dist/db/migrate.js +2 -2
  39. package/dist/env/load.d.ts +1 -1
  40. package/dist/env/load.js +1 -1
  41. package/dist/http/CLAUDE.md +2 -1
  42. package/dist/http/body_limit.d.ts +74 -0
  43. package/dist/http/body_limit.d.ts.map +1 -0
  44. package/dist/http/body_limit.js +100 -0
  45. package/dist/http/db_routes.d.ts.map +1 -1
  46. package/dist/http/db_routes.js +5 -3
  47. package/dist/http/error_schemas.d.ts +2 -0
  48. package/dist/http/error_schemas.d.ts.map +1 -1
  49. package/dist/http/error_schemas.js +2 -0
  50. package/dist/realtime/outbound_budget.d.ts +91 -0
  51. package/dist/realtime/outbound_budget.d.ts.map +1 -0
  52. package/dist/realtime/outbound_budget.js +108 -0
  53. package/dist/realtime/sse.d.ts +36 -3
  54. package/dist/realtime/sse.d.ts.map +1 -1
  55. package/dist/realtime/sse.js +58 -6
  56. package/dist/realtime/sse_constants.d.ts +12 -0
  57. package/dist/realtime/sse_constants.d.ts.map +1 -1
  58. package/dist/realtime/sse_constants.js +12 -0
  59. package/dist/server/app_server.d.ts.map +1 -1
  60. package/dist/server/app_server.js +3 -6
  61. package/dist/testing/CLAUDE.md +96 -19
  62. package/dist/testing/admin_integration.js +1 -1
  63. package/dist/testing/audit_completeness.d.ts +1 -1
  64. package/dist/testing/audit_completeness.js +1 -1
  65. package/dist/testing/audit_drift_guard.d.ts +19 -6
  66. package/dist/testing/audit_drift_guard.d.ts.map +1 -1
  67. package/dist/testing/audit_drift_guard.js +25 -9
  68. package/dist/testing/cross_backend/body_size.d.ts +18 -5
  69. package/dist/testing/cross_backend/body_size.d.ts.map +1 -1
  70. package/dist/testing/cross_backend/body_size.js +233 -34
  71. package/dist/testing/cross_backend/body_size_smuggling.d.ts +0 -12
  72. package/dist/testing/cross_backend/body_size_smuggling.d.ts.map +1 -1
  73. package/dist/testing/cross_backend/body_size_smuggling.js +103 -108
  74. package/dist/testing/cross_backend/capabilities.d.ts +3 -21
  75. package/dist/testing/cross_backend/capabilities.d.ts.map +1 -1
  76. package/dist/testing/cross_backend/capabilities.js +1 -3
  77. package/dist/testing/cross_backend/cell_crud.d.ts.map +1 -1
  78. package/dist/testing/cross_backend/cell_crud.js +31 -2
  79. package/dist/testing/cross_backend/conformance_case.d.ts +3 -3
  80. package/dist/testing/cross_backend/credential_header_robustness.d.ts.map +1 -1
  81. package/dist/testing/cross_backend/credential_header_robustness.js +18 -54
  82. package/dist/testing/cross_backend/default_backend_configs.d.ts.map +1 -1
  83. package/dist/testing/cross_backend/default_backend_configs.js +2 -8
  84. package/dist/testing/cross_backend/role_grant_role_grammar.d.ts +6 -0
  85. package/dist/testing/cross_backend/role_grant_role_grammar.d.ts.map +1 -0
  86. package/dist/testing/cross_backend/role_grant_role_grammar.js +117 -0
  87. package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts +2 -3
  88. package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts.map +1 -1
  89. package/dist/testing/cross_backend/rust_spine_stub_backend_config.js +2 -3
  90. package/dist/testing/cross_backend/testing_backdoor.d.ts.map +1 -1
  91. package/dist/testing/cross_backend/testing_backdoor.js +10 -4
  92. package/dist/testing/cross_backend/testing_reset_actions.d.ts +87 -2
  93. package/dist/testing/cross_backend/testing_reset_actions.d.ts.map +1 -1
  94. package/dist/testing/cross_backend/testing_reset_actions.js +106 -2
  95. package/dist/testing/cross_backend/token_lifetime.d.ts.map +1 -1
  96. package/dist/testing/cross_backend/token_lifetime.js +34 -1
  97. package/dist/testing/cross_backend/ts_spine_backend_config.d.ts.map +1 -1
  98. package/dist/testing/cross_backend/ts_spine_backend_config.js +8 -13
  99. package/dist/testing/cross_backend/ws_outbound_overflow.d.ts +29 -0
  100. package/dist/testing/cross_backend/ws_outbound_overflow.d.ts.map +1 -0
  101. package/dist/testing/cross_backend/ws_outbound_overflow.js +276 -0
  102. package/dist/testing/integration.js +1 -1
  103. package/dist/testing/schema_introspect.d.ts +30 -2
  104. package/dist/testing/schema_introspect.d.ts.map +1 -1
  105. package/dist/testing/schema_introspect.js +38 -7
  106. package/dist/testing/transports/raw_http.d.ts +78 -0
  107. package/dist/testing/transports/raw_http.d.ts.map +1 -0
  108. package/dist/testing/transports/raw_http.js +141 -0
  109. package/dist/testing/transports/ws_transport.d.ts +4 -2
  110. package/dist/testing/transports/ws_transport.d.ts.map +1 -1
  111. package/dist/testing/transports/ws_transport.js +2 -1
  112. package/dist/testing/ws_round_trip.d.ts +32 -2
  113. package/dist/testing/ws_round_trip.d.ts.map +1 -1
  114. package/dist/testing/ws_round_trip.js +26 -4
  115. package/package.json +1 -1
  116. package/dist/env/resolve.d.ts +0 -163
  117. package/dist/env/resolve.d.ts.map +0 -1
  118. package/dist/env/resolve.js +0 -267
@@ -5,11 +5,12 @@ import '../assert_dev_env.js';
5
5
  * `create_app_server` (TS) and the Rust spine both cap the request body at a
6
6
  * 1 MiB default (`DEFAULT_MAX_BODY_SIZE` / `fuz_http`'s
7
7
  * `DEFAULT_BODY_LIMIT_BYTES`) and reject oversized payloads with `413` and the
8
- * canonical flat REST body `{error: 'payload_too_large'}` — *before* auth,
8
+ * canonical flat REST body `{error: 'payload_too_large'}`. A declared
9
+ * `Content-Length` over the cap is refused on the header, *before* auth,
9
10
  * origin, or dispatch run (middleware step 4). Each impl unit-tests this in
10
- * isolation, but nothing fires an oversized POST over the wire, so the
11
- * cross-impl agreement on the status + body shape (and on the exact `>` cap
12
- * boundary, not an off-by-one divergence) was unpinned. Three cases:
11
+ * isolation; this suite pins the cross-impl agreement on the status + body
12
+ * shape, on the exact `>` cap boundary (not an off-by-one divergence), and on
13
+ * the cap answering ahead of the origin check and of auth. The cases:
13
14
  *
14
15
  * - **over-limit POST (cap + 1 byte) → 413** `payload_too_large`, refused
15
16
  * before any handler runs (the limit fires ahead of origin verification + the
@@ -24,17 +25,48 @@ import '../assert_dev_env.js';
24
25
  * - **under-limit POST (small) → 200** — a small, well-formed authenticated
25
26
  * `account_verify` envelope sails through to a successful handler response,
26
27
  * the positive control that the route works for normal traffic.
28
+ * - **over-limit POST with a disallowed `Origin` → 413**, not the origin
29
+ * check's 403, and **over-limit POST with no credentials → 413**, not 401:
30
+ * the cap answers a declared length ahead of both. The control — the same
31
+ * disallowed `Origin` on an under-limit body → 403 `forbidden_origin` —
32
+ * shows the origin check still runs.
33
+ * - **over-limit chunked POST (no `Content-Length`), authenticated → 413** —
34
+ * a body with no declared length is refused by the bytes that arrive.
27
35
  *
28
- * **Real-socket connection hazard (cross-process only).** When the server caps
29
- * the body it answers 413 and closes the connection *before* the client
30
- * finishes uploading — correct HTTP, since an unread request body can't share a
31
- * keep-alive socket. The client's pool can then hand that now-dead socket to
32
- * the very next request (observed as `other side closed`). So every request
33
- * here goes through `fetch_retrying_once`: a request that inherits the poisoned
34
- * socket retries onto a fresh connection, which both keeps the suite
35
- * deterministic *and* evicts the dead socket so it can't strand a later cross
36
- * suite in the same process. In-process (`app.request`) has no socket, so the
37
- * hazard is cross-process-only and the retry never fires there.
36
+ * **Implementation-defined, not pinned here.** Without a declared length the
37
+ * two spines measure at different points: the TS spine reads the body up to
38
+ * the cap before any later middleware runs, so its 413 comes ahead of the
39
+ * origin check and auth; the Rust spine counts as the handler reads, so a
40
+ * refusal made on the headers (a disallowed `Origin`) answers first and a
41
+ * handler that never reads the body answers its own status. Only the
42
+ * authenticated, allowed-origin chunked case — where nothing else refuses —
43
+ * has one answer on every backend. A repeated `Content-Length` with equal
44
+ * values is also the implementation's: the TS spine answers 400
45
+ * `invalid_content_length` where its runtime's parser hands the repeat over,
46
+ * and hyper folds it into one value and accepts.
47
+ *
48
+ * **Over a real socket the over-cap cases send no byte the server won't
49
+ * read.** A server that caps the body answers 413 and may close the
50
+ * connection with the rest of the upload unread — correct HTTP, since an
51
+ * unread request body can't share a keep-alive socket. A client still
52
+ * uploading then has its write fail (`EPIPE`) and can lose the 413 the server
53
+ * did send, so a whole over-cap body sent through `fetch` reads its answer
54
+ * only some of the time. Given a `base_url`, the suite speaks to the socket
55
+ * directly (`raw_http_exchange`) and holds back what the refusal doesn't need:
56
+ *
57
+ * - a **declared** over-cap length is sent as the request head alone —
58
+ * `Content-Length` one over the cap and not one body byte. The 413 must
59
+ * arrive within `RAW_READ_TIMEOUT_MS`, which also pins that the refusal is
60
+ * made on the header: a backend that reads the body before refusing, or has
61
+ * no cap, waits for bytes that never come and fails the case.
62
+ * - the **chunked** body is sent up to one byte past the cap and never
63
+ * terminated. The server has read every byte sent by the time it has counted
64
+ * past the cap, so nothing is left unread, and the 413 must arrive without
65
+ * the body's end.
66
+ *
67
+ * With `base_url: null` there is no socket and no such race (the in-process
68
+ * `app.request`), so the same cases send whole bodies through the fixture's
69
+ * transport. The under-cap cases go through the transport either way.
38
70
  *
39
71
  * Like origin/payload rejection, this is middleware-level flat REST — not the
40
72
  * JSON-RPC envelope the conformance-table runner expects — so it's an
@@ -51,7 +83,8 @@ import '../assert_dev_env.js';
51
83
  */
52
84
  import { describe, test, assert } from 'vitest';
53
85
  import { account_verify_action_spec } from '../../auth/account_action_specs.js';
54
- import { ERROR_PAYLOAD_TOO_LARGE } from '../../http/error_schemas.js';
86
+ import { ERROR_FORBIDDEN_ORIGIN, ERROR_PAYLOAD_TOO_LARGE } from '../../http/error_schemas.js';
87
+ import { raw_http_exchange, raw_http_parse_response } from '../transports/raw_http.js';
55
88
  import { SPINE_RPC_PATH } from './spine_surface_constants.js';
56
89
  /**
57
90
  * The shared 1 MiB default both impls ship (`DEFAULT_MAX_BODY_SIZE` in TS,
@@ -78,41 +111,159 @@ const sized_envelope = (id, target_bytes) => {
78
111
  };
79
112
  /** A small, well-formed nullary `account_verify` body — well under the cap. */
80
113
  const small_envelope = (id) => JSON.stringify({ jsonrpc: '2.0', method: account_verify_action_spec.method, id });
114
+ /** An `Origin` no backend's allowlist admits. */
115
+ const DISALLOWED_ORIGIN = 'http://evil.com';
116
+ /** Chunk size of the streamed over-cap body. */
117
+ const STREAM_CHUNK_BYTES = 64 * 1024;
118
+ /**
119
+ * A request body of `total_bytes` delivered as a stream, so the request
120
+ * carries no `Content-Length` (`Transfer-Encoding: chunked` over a socket).
121
+ * Single-use.
122
+ */
123
+ const streamed_body = (total_bytes) => {
124
+ let sent = 0;
125
+ return new ReadableStream({
126
+ pull: (controller) => {
127
+ if (sent >= total_bytes) {
128
+ controller.close();
129
+ return;
130
+ }
131
+ const size = Math.min(STREAM_CHUNK_BYTES, total_bytes - sent);
132
+ sent += size;
133
+ controller.enqueue(new Uint8Array(size).fill(0x78));
134
+ }
135
+ });
136
+ };
81
137
  /**
82
- * Issue a request, retrying once if the call *throws* (vs. returning a
83
- * response). A request handed the keep-alive socket the server closed on an
84
- * oversized-body rejection fails at the transport layer before any response;
85
- * one retry opens a fresh connection (and drops the dead socket from the
86
- * pool). Keyed on "threw at all" rather than a specific error code so it holds
87
- * across the node / deno / bun cross runtimes, which surface different errors.
138
+ * How long a raw over-cap exchange waits for the refusal. The server has every
139
+ * byte it will get when the wait starts, so a backend that refuses on what it
140
+ * has answers at once and one that waits for more runs the whole window.
88
141
  */
89
- const fetch_retrying_once = async (transport, path, init) => {
142
+ const RAW_READ_TIMEOUT_MS = 2000;
143
+ const to_sized_response = async (res) => ({
144
+ status: res.status,
145
+ body: await res.json().catch(() => undefined)
146
+ });
147
+ const parse_json = (text) => {
90
148
  try {
91
- return await transport(path, init);
149
+ return JSON.parse(text);
92
150
  }
93
151
  catch {
94
- return await transport(path, init);
152
+ return undefined;
95
153
  }
96
154
  };
155
+ /** Render request headers as raw header lines. */
156
+ const to_header_lines = (headers) => Object.entries(headers)
157
+ .map(([name, value]) => `${name}: ${value}\r\n`)
158
+ .join('');
159
+ /**
160
+ * Write `request_bytes` to the backend's socket and read the one response it
161
+ * must answer with — without being sent anything more.
162
+ *
163
+ * @param withheld - what the request leaves unsent, for the failure message
164
+ */
165
+ const exchange_withholding = async (base_url, request_bytes, withheld) => {
166
+ const { received, closed_by_server } = await raw_http_exchange(base_url, request_bytes, {
167
+ read_timeout_ms: RAW_READ_TIMEOUT_MS,
168
+ until: 'response'
169
+ });
170
+ const response = raw_http_parse_response(received);
171
+ assert.ok(response, `expected a whole response with ${withheld} withheld; ` +
172
+ (closed_by_server
173
+ ? `the server closed the connection first`
174
+ : `none arrived within ${RAW_READ_TIMEOUT_MS}ms — the backend is waiting for bytes ` +
175
+ `it should refuse without`) +
176
+ `. Received: ${received ? received.slice(0, 120) : 'nothing'}`);
177
+ return { status: response.status, body: parse_json(response.body) };
178
+ };
179
+ /** Assert a response is the canonical over-cap refusal. */
180
+ const assert_payload_too_large = (res, message) => {
181
+ assert.strictEqual(res.status, 413, message);
182
+ assert.strictEqual(res.body?.error, ERROR_PAYLOAD_TOO_LARGE);
183
+ };
97
184
  export const describe_body_size_cross_tests = (options) => {
98
- const { setup_test } = options;
185
+ const { setup_test, base_url } = options;
99
186
  const rpc_path = options.rpc_path ?? SPINE_RPC_PATH;
100
- /** POST `body` to the RPC path as the keeper, retry-once on a dead socket. */
101
- const post = (fixture, body) => fetch_retrying_once(fixture.transport, rpc_path, {
187
+ /** POST `body` to the RPC path as the keeper. */
188
+ const post = (fixture, body) => fixture.transport(rpc_path, {
102
189
  method: 'POST',
103
190
  headers: { ...fixture.create_session_headers(), 'content-type': 'application/json' },
104
191
  body
105
192
  });
193
+ /**
194
+ * POST with a declared `Content-Length` one byte over the cap. Over a
195
+ * socket only the head is sent; in-process, the whole body.
196
+ *
197
+ * `headers` carries the credential and any `Origin`, which defaults to the
198
+ * backend's own. `anonymous` picks the cookie-jar-free transport in-process;
199
+ * a raw head carries only what `headers` names either way.
200
+ */
201
+ const post_over_limit_declared = async (fixture, request) => {
202
+ const { id, headers, anonymous } = request;
203
+ const content_length = BODY_LIMIT_DEFAULT_BYTES + 1;
204
+ if (base_url === null) {
205
+ const transport = anonymous ? fixture.fresh_transport() : fixture.transport;
206
+ return to_sized_response(await transport(rpc_path, {
207
+ method: 'POST',
208
+ headers: { ...headers, 'content-type': 'application/json' },
209
+ body: sized_envelope(id, content_length)
210
+ }));
211
+ }
212
+ const url = new URL(base_url);
213
+ return exchange_withholding(base_url, `POST ${rpc_path} HTTP/1.1\r\n` +
214
+ to_header_lines({
215
+ host: url.host,
216
+ origin: url.origin,
217
+ ...headers,
218
+ 'content-type': 'application/json',
219
+ 'content-length': String(content_length)
220
+ }) +
221
+ `\r\n`, 'the declared body');
222
+ };
223
+ /**
224
+ * POST a chunked body past the cap as the keeper. Over a socket the body
225
+ * stops one byte past the cap, unterminated; in-process, a whole streamed
226
+ * body runs past it.
227
+ */
228
+ const post_over_limit_chunked = async (fixture) => {
229
+ if (base_url === null) {
230
+ return to_sized_response(await fixture.transport(rpc_path, {
231
+ method: 'POST',
232
+ headers: { ...fixture.create_session_headers(), 'content-type': 'application/json' },
233
+ body: streamed_body(BODY_LIMIT_DEFAULT_BYTES + 1024),
234
+ // a streamed request body needs half-duplex `fetch`
235
+ duplex: 'half'
236
+ }));
237
+ }
238
+ const url = new URL(base_url);
239
+ // whole chunks up to exactly the cap, then a one-byte chunk's size line
240
+ // and its byte — and neither that chunk's closing CRLF nor the
241
+ // terminating chunk, so the last byte sent is the first one past the cap
242
+ const whole_chunk = `${STREAM_CHUNK_BYTES.toString(16)}\r\n${'x'.repeat(STREAM_CHUNK_BYTES)}\r\n`;
243
+ return exchange_withholding(base_url, `POST ${rpc_path} HTTP/1.1\r\n` +
244
+ to_header_lines({
245
+ host: url.host,
246
+ origin: url.origin,
247
+ ...fixture.create_session_headers(),
248
+ 'content-type': 'application/json',
249
+ 'transfer-encoding': 'chunked'
250
+ }) +
251
+ `\r\n` +
252
+ whole_chunk.repeat(BODY_LIMIT_DEFAULT_BYTES / STREAM_CHUNK_BYTES) +
253
+ `1\r\nx`, `the chunked body's end`);
254
+ };
106
255
  describe('body-size limit parity', () => {
107
256
  test('over-limit POST (cap + 1) → 413 payload_too_large (refused before dispatch)', async () => {
108
257
  const fixture = await setup_test();
109
- // Keeper session cookie + the transport's default allowed Origin, so
110
- // neither auth nor origin is the cause — only the body size. One byte
111
- // over the cap: both impls reject on a strict `>`.
112
- const res = await post(fixture, sized_envelope('over-limit', BODY_LIMIT_DEFAULT_BYTES + 1));
113
- assert.strictEqual(res.status, 413, 'a body one byte over the cap must be rejected with 413');
114
- const body = (await res.json().catch(() => undefined));
115
- assert.strictEqual(body?.error, ERROR_PAYLOAD_TOO_LARGE);
258
+ // Keeper session cookie + the default allowed Origin, so neither auth
259
+ // nor origin is the cause — only the size. One byte over the cap: both
260
+ // impls reject on a strict `>`.
261
+ const res = await post_over_limit_declared(fixture, {
262
+ id: 'over-limit',
263
+ headers: fixture.create_session_headers(),
264
+ anonymous: false
265
+ });
266
+ assert_payload_too_large(res, 'a body one byte over the cap must be rejected with 413');
116
267
  });
117
268
  test('at-limit POST (exactly the cap) → not size-rejected', async () => {
118
269
  const fixture = await setup_test();
@@ -130,5 +281,53 @@ export const describe_body_size_cross_tests = (options) => {
130
281
  const res = await post(fixture, small_envelope('under-limit'));
131
282
  assert.strictEqual(res.status, 200, 'an under-limit authenticated request must pass');
132
283
  });
284
+ test('over-limit POST with a disallowed Origin → 413, not 403', async () => {
285
+ const fixture = await setup_test();
286
+ // Keeper session + a rogue Origin: the origin check would answer 403,
287
+ // and the cap, which reads only the declared length, answers first.
288
+ const res = await post_over_limit_declared(fixture, {
289
+ id: 'over-limit-evil-origin',
290
+ headers: { ...fixture.create_session_headers(), origin: DISALLOWED_ORIGIN },
291
+ anonymous: false
292
+ });
293
+ assert_payload_too_large(res, 'a declared length over the cap must answer 413 ahead of the origin check');
294
+ });
295
+ test('under-limit POST with a disallowed Origin → 403 forbidden_origin', async () => {
296
+ const fixture = await setup_test();
297
+ // The control for the case above: the same request minus the size is
298
+ // refused by the origin check, so the 413 there is the cap answering
299
+ // first, not the origin check being absent.
300
+ const res = await fixture.transport(rpc_path, {
301
+ method: 'POST',
302
+ headers: {
303
+ ...fixture.create_session_headers(),
304
+ origin: DISALLOWED_ORIGIN,
305
+ 'content-type': 'application/json'
306
+ },
307
+ body: small_envelope('under-limit-evil-origin')
308
+ });
309
+ assert.strictEqual(res.status, 403, 'a disallowed Origin on an under-limit body is refused');
310
+ const body = (await res.json().catch(() => undefined));
311
+ assert.strictEqual(body?.error, ERROR_FORBIDDEN_ORIGIN);
312
+ });
313
+ test('over-limit POST with no credentials → 413, not 401', async () => {
314
+ const fixture = await setup_test();
315
+ // No cookie, no token: the dispatcher would answer 401, and the cap
316
+ // answers first.
317
+ const res = await post_over_limit_declared(fixture, {
318
+ id: 'over-limit-anonymous',
319
+ headers: {},
320
+ anonymous: true
321
+ });
322
+ assert_payload_too_large(res, 'a declared length over the cap must answer 413 ahead of auth');
323
+ });
324
+ test('over-limit chunked POST (no Content-Length), authenticated → 413', async () => {
325
+ const fixture = await setup_test();
326
+ // Keeper session + the default allowed Origin, so nothing but the size
327
+ // refuses. No declared length for the header check to read, so the
328
+ // refusal is by the bytes that arrive.
329
+ const res = await post_over_limit_chunked(fixture);
330
+ assert_payload_too_large(res, 'a chunked body past the cap must be rejected with 413 when nothing else refuses');
331
+ });
133
332
  });
134
333
  };
@@ -5,18 +5,6 @@ export interface BodySizeSmugglingCrossTestOptions {
5
5
  readonly base_url: string;
6
6
  /** RPC endpoint path to target. Default `/api/rpc`. */
7
7
  readonly rpc_path?: string;
8
- /**
9
- * Whether the backend closes the connection on an oversized-body reject
10
- * without reading the body (`capabilities.oversized_reject_closes_connection`).
11
- * `true` (default) demands the strong posture — the pipelined GET is never
12
- * reached, so **at most one** response comes back. `false` (Bun) relaxes to
13
- * the no-desync property: the body is drained on `Content-Length` and the
14
- * pipelined GET is framed correctly, so **at most two** responses come back
15
- * and the body bytes are never reparsed as a request. Default `true` so a
16
- * consumer that forgets to declare the flag fails loud rather than silently
17
- * accepting a drain.
18
- */
19
- readonly closes_connection?: boolean;
20
8
  }
21
9
  export declare const describe_body_size_smuggling_cross_tests: (options: BodySizeSmugglingCrossTestOptions) => void;
22
10
  //# sourceMappingURL=body_size_smuggling.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"body_size_smuggling.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/body_size_smuggling.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAwD9B,4EAA4E;AAC5E,MAAM,WAAW,iCAAiC;IACjD,mFAAmF;IACnF,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,uDAAuD;IACvD,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B;;;;;;;;;;OAUG;IACH,QAAQ,CAAC,iBAAiB,CAAC,EAAE,OAAO,CAAC;CACrC;AAqDD,eAAO,MAAM,wCAAwC,GACpD,SAAS,iCAAiC,KACxC,IAiFF,CAAC"}
1
+ {"version":3,"file":"body_size_smuggling.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/body_size_smuggling.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAmE9B,4EAA4E;AAC5E,MAAM,WAAW,iCAAiC;IACjD,mFAAmF;IACnF,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,uDAAuD;IACvD,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;CAC3B;AAWD,eAAO,MAAM,wCAAwC,GACpD,SAAS,iCAAiC,KACxC,IA4GF,CAAC"}
@@ -10,154 +10,149 @@ import '../assert_dev_env.js';
10
10
  * body bytes would be parsed as the start of the next request — a classic
11
11
  * request-smuggling vector. This suite probes the boundary by **pipelining**:
12
12
  * it opens a raw TCP socket and sends, in one write, an oversized `POST`
13
- * immediately followed by a second `GET`. The assertion forks on the backend's
14
- * declared `oversized_reject_closes_connection` capability:
13
+ * immediately followed by a second `GET`. Every backend must close after the
14
+ * reject, so the pipelined GET goes unanswered and **at most one** response
15
+ * comes back. `<= 1` rather than "exactly the 413" because the impls close
16
+ * differently at the TCP level (a graceful close delivers the 413 first;
17
+ * hyper's RST can drop the in-flight 413 before the client reads it), so
18
+ * demanding a cleanly-read 413 would be flaky.
15
19
  *
16
- * - **Closes (Node / Deno / hyper)** — the reject closes the socket with the
17
- * GET bytes unconsumed, so **at most one** response comes back. `<= 1` rather
18
- * than "exactly the 413" because the impls close differently at the TCP level
19
- * (node-server graceful close delivers the 413 first; hyper's RST can drop the
20
- * in-flight 413 before the client reads it), so demanding a cleanly-read 413
21
- * would be flaky.
22
- * - **Drains + keepalives (Bun)** — `Bun.serve` reads the full declared
23
- * `Content-Length` body and answers the *correctly-framed* pipelined GET, so
24
- * **two** responses come back. This is **not** a smuggle: the GET is delimited
25
- * by the body's `Content-Length`, not the unread body reinterpreted as a
26
- * request — Bun answers it with a clean `400` (`missing method`), not the `x`
27
- * body bytes reparsed. The security property asserted here is **no desync**
28
- * (`<= 2`): a real desync would reframe the 1 MiB of `x` into bogus request
29
- * lines and push the count past two.
20
+ * - **Node** closes at once with the body unread — `create_app_server`'s body
21
+ * limit (`http/body_limit.ts`) refuses on the header and answers
22
+ * `Connection: close`, which is what makes `@hono/node-server` close.
23
+ * - **Bun** closes at once too, honouring that header. Bun 1.4.3 is the tested
24
+ * floor: an older Bun kept the connection after the 413 and served the next
25
+ * request (correctly framed), which fails this suite.
26
+ * - **Deno** closes once the whole declared body has arrived, which this
27
+ * probe's single write delivers; a client that withholds the body holds a
28
+ * Deno connection open, and no request behind the body is served either way.
29
+ * - **hyper** (the Rust spine) closes at once.
30
30
  *
31
- * Either way the oversized body is rejected *with* a 413 — pinned reliably over
32
- * `fetch` by `describe_body_size_cross_tests`; this test owns only the
33
- * connection-handling half. A **positive control** (two pipelined requests →
34
- * `>= 2` responses) proves a second response *would* be seen if a trailing
35
- * request were processed — without it the close-posture `<= 1` would be vacuous
36
- * on a server that never reuses connections — and that the counter isn't
31
+ * The oversized body is rejected *with* a 413 on every backend — pinned
32
+ * reliably by `describe_body_size_cross_tests`, which sends the over-cap
33
+ * request's head and no body, so no upload races the close; this test owns
34
+ * only the connection-handling half. A **positive control** (two pipelined
35
+ * requests → `>= 2` responses) proves a second response *would* be seen if a
36
+ * trailing request were processed — without it `<= 1` would be vacuous on a
37
+ * server that never reuses connections — and that the counter isn't
37
38
  * undercounting.
38
39
  *
40
+ * **`Content-Length` beside `Transfer-Encoding`.** The second probe sends a
41
+ * request carrying both headers — the shape a request-smuggling attack hands a
42
+ * server and a proxy that disagree on which one frames the body — with a
43
+ * `GET` pipelined behind it. What the backends share is the outcome, not the
44
+ * status: the request is refused with `400` (the Node, Deno and Bun parsers)
45
+ * or answered as its chunked body reads (hyper, which drops the
46
+ * `Content-Length`), and either way the server **closes the connection** and
47
+ * the pipelined request is **never served**. The status of the one response
48
+ * is implementation-defined and not asserted.
49
+ *
39
50
  * Raw-socket by necessity (the `FetchTransport` can't pipeline two requests on
40
51
  * one connection), so — unlike `body_size.ts` — this is **cross-process only**
41
- * (no in-process leg; there is no socket in-process). The connection-close half
42
- * is capability-gated; the no-desync half holds on every spine.
52
+ * (no in-process leg; there is no socket in-process — the in-process pin is
53
+ * that the reject never reads the request body, in
54
+ * `server/create_app_server.db.test.ts`).
43
55
  *
44
56
  * Cited property: `docs/security.md` §"Body Size Limiting" (connection handling
45
57
  * on oversized reject).
46
58
  *
47
59
  * @module
48
60
  */
49
- import { connect } from 'node:net';
50
61
  import { describe, test, assert } from 'vitest';
62
+ import { raw_http_count_responses, raw_http_exchange } from '../transports/raw_http.js';
51
63
  import { SPINE_RPC_PATH } from './spine_surface_constants.js';
52
64
  /** The shared 1 MiB cap (see `body_size.ts` for why it's a local constant). */
53
65
  const BODY_LIMIT_DEFAULT_BYTES = 1024 * 1024;
54
66
  /**
55
- * Open a raw TCP socket to `base_url`, write `request_bytes` once, and collect
56
- * everything the server sends back until it closes the connection or
57
- * `read_timeout_ms` elapses. Write errors are swallowed: the server closing
58
- * mid-upload (the correct response to an oversized body) surfaces as
59
- * `EPIPE`/`ECONNRESET` on our unfinished write, which is exactly the behavior
60
- * under test — what matters is what we *read back*.
61
- */
62
- const send_raw = (base_url, request_bytes, read_timeout_ms) => new Promise((resolve) => {
63
- const url = new URL(base_url);
64
- const port = Number(url.port) || (url.protocol === 'https:' ? 443 : 80);
65
- const socket = connect({ host: url.hostname, port });
66
- let received = '';
67
- let settled = false;
68
- const finish = () => {
69
- if (settled)
70
- return;
71
- settled = true;
72
- clearTimeout(timer);
73
- socket.destroy();
74
- resolve(received);
75
- };
76
- const timer = setTimeout(finish, read_timeout_ms);
77
- socket.setEncoding('latin1');
78
- socket.on('connect', () => socket.write(request_bytes));
79
- socket.on('data', (chunk) => {
80
- received += chunk;
81
- });
82
- socket.on('error', () => { }); // EPIPE/ECONNRESET on mid-write close is expected
83
- socket.on('close', finish);
84
- });
85
- /**
86
- * Count HTTP response status lines in a raw byte stream. Deliberately
87
- * **unanchored** (no `^`/`m`): a second pipelined response is concatenated
88
- * straight after the first response's body, which carries no trailing newline,
89
- * so a line-anchored match would miss it — and missing a smuggled second
90
- * response is a silent false negative. No response header or JSON error body
91
- * contains the literal `HTTP/1.x NNN`, so an unanchored match counts exactly
92
- * the status lines. The positive control below proves this counts 2 when the
93
- * server genuinely returns 2.
67
+ * How long each raw exchange waits for the server to finish answering. An
68
+ * exchange the backend does not end by closing runs the whole window.
94
69
  */
95
- const count_responses = (raw) => (raw.match(/HTTP\/1\.[01] \d{3}/g) ?? []).length;
70
+ const RAW_READ_TIMEOUT_MS = 2000;
96
71
  export const describe_body_size_smuggling_cross_tests = (options) => {
97
72
  const { base_url } = options;
98
73
  const rpc_path = options.rpc_path ?? SPINE_RPC_PATH;
99
- const closes_connection = options.closes_connection ?? true;
100
74
  const host = new URL(base_url).host;
101
75
  describe('body-size limit — request-smuggling resistance', () => {
102
76
  // Positive control: prove the server returns >1 response on a single
103
77
  // connection. Without this the smuggling assertion below would be
104
78
  // vacuously green on a server that simply never reuses connections — and
105
- // it also validates `count_responses` actually counts a second response.
79
+ // it also validates `raw_http_count_responses` actually counts a second response.
106
80
  test('control: two pipelined requests → ≥2 responses (connection reuse is real)', async () => {
107
81
  const two_requests = `GET ${rpc_path} HTTP/1.1\r\nHost: ${host}\r\n\r\n` +
108
82
  `GET ${rpc_path} HTTP/1.1\r\nHost: ${host}\r\nConnection: close\r\n\r\n`;
109
- const response = await send_raw(base_url, two_requests, 2000);
110
- const n = count_responses(response);
83
+ const { received: response } = await raw_http_exchange(base_url, two_requests, {
84
+ read_timeout_ms: RAW_READ_TIMEOUT_MS
85
+ });
86
+ const n = raw_http_count_responses(response);
111
87
  assert.ok(n >= 2, `expected ≥2 responses on one keep-alive connection (got ${n}); without ` +
112
88
  `connection reuse — or with an undercounting matcher — the smuggling ` +
113
89
  `assertion below is not a real signal. Raw head: ${response.slice(0, 120)}`);
114
90
  });
115
- test('oversized POST + pipelined GET → smuggled request not processed', async () => {
91
+ test('oversized POST + pipelined GET → no request smuggled', async () => {
116
92
  const oversized_len = BODY_LIMIT_DEFAULT_BYTES + 1024;
117
- // One write: an over-cap POST (rejected on Content-Length, body never
118
- // read) immediately followed by a GET. If the server wrongly drained
119
- // the unread body it would reach + answer this GET — a smuggle.
93
+ const pipelined_get = `GET ${rpc_path} HTTP/1.1\r\nHost: ${host}\r\n\r\n`;
94
+ // One write: an over-cap POST (rejected on Content-Length) immediately
95
+ // followed by a GET.
120
96
  const payload = `POST ${rpc_path} HTTP/1.1\r\n` +
121
97
  `Host: ${host}\r\n` +
122
98
  `Content-Type: application/json\r\n` +
123
99
  `Content-Length: ${oversized_len}\r\n` +
124
100
  `\r\n` +
125
101
  'x'.repeat(oversized_len) +
126
- `GET ${rpc_path} HTTP/1.1\r\nHost: ${host}\r\n\r\n`;
127
- const response = await send_raw(base_url, payload, 2000);
128
- const n = count_responses(response);
129
- if (closes_connection) {
130
- // Strong posture (Node / Deno / hyper): the reject closes the
131
- // connection *without reading the body*, so the pipelined GET is
132
- // never reached — **at most one** response comes back (the 413, or
133
- // none if the close raced the read). A *second* response would mean
134
- // the body was drained and the GET processed. We assert `<= 1` rather
135
- // than "exactly the 413" because the impls close differently at the
136
- // TCP level — node-server closes gracefully (413 delivered first),
137
- // hyper sends an RST that can drop the in-flight 413 before the client
138
- // reads it — and demanding a cleanly-read 413 would be flaky. The
139
- // 413-ness itself is pinned reliably (over `fetch`) by
140
- // `describe_body_size_cross_tests`. The control above proves a second
141
- // response *would* be seen if the GET were processed, so `<= 1` is a
142
- // real signal, not vacuous.
143
- assert.ok(n <= 1, `expected at most one response (oversized reject closes the connection; the ` +
144
- `pipelined GET must not be reached). Saw ${n}. Raw head: ${response.slice(0, 120)}`);
145
- }
146
- else {
147
- // Drain-and-keepalive posture (Bun): `Bun.serve` reads the full
148
- // declared `Content-Length` body and keeps the socket alive, so it
149
- // answers the *correctly-framed* pipelined GET — **two** responses
150
- // (the 413 + a well-formed reply to the GET). This is not a smuggle:
151
- // the GET is delimited by the body's `Content-Length`, not the unread
152
- // body reinterpreted as request bytes. The security property here is
153
- // **no desync** — the body bytes are never reparsed as request(s). A
154
- // real desync would reframe the 1 MiB of `x` into bogus request
155
- // lines, pushing the count past two; `<= 2` is the no-desync bound,
156
- // and the control above proves the counter isn't undercounting.
157
- assert.ok(n <= 2, `expected at most two responses (the 413 + one framed reply to the ` +
158
- `pipelined GET); more means the oversized body was reparsed as requests ` +
159
- `(a desync). Saw ${n}. Raw head: ${response.slice(0, 120)}`);
160
- }
102
+ pipelined_get;
103
+ const { received: response } = await raw_http_exchange(base_url, payload, {
104
+ read_timeout_ms: RAW_READ_TIMEOUT_MS
105
+ });
106
+ const n = raw_http_count_responses(response);
107
+ // The reject closes the connection, so the pipelined GET is never
108
+ // reached — **at most one** response comes back (the 413, or none if
109
+ // the close raced the read). A *second* response means the connection
110
+ // was kept and the GET served. The control above proves a second
111
+ // response *would* be seen, so `<= 1` is a real signal.
112
+ assert.ok(n <= 1, `expected at most one response (oversized reject closes the connection; the ` +
113
+ `pipelined GET must not be reached). Saw ${n}. Raw head: ${response.slice(0, 120)}`);
161
114
  });
115
+ // A request carrying both `Content-Length` and `Transfer-Encoding`, with
116
+ // a GET pipelined behind it. The body is a small JSON-RPC envelope,
117
+ // chunked and terminated, so a backend that frames on
118
+ // `Transfer-Encoding` reads a whole request. `Content-Length` disagrees
119
+ // with the chunked length in both cases: framing on it instead would
120
+ // leave body bytes to be parsed as the next request.
121
+ const envelope = JSON.stringify({ jsonrpc: '2.0', id: 'cl-te', method: 'heartbeat' });
122
+ const chunked_envelope = `${envelope.length.toString(16)}\r\n${envelope}\r\n0\r\n\r\n`;
123
+ const framing_cases = [
124
+ [
125
+ 'Content-Length ahead of Transfer-Encoding',
126
+ `Content-Length: 5\r\nTransfer-Encoding: chunked\r\n`
127
+ ],
128
+ [
129
+ 'Transfer-Encoding ahead of Content-Length',
130
+ `Transfer-Encoding: chunked\r\nContent-Length: 5\r\n`
131
+ ]
132
+ ];
133
+ for (const [name, framing_headers] of framing_cases) {
134
+ test(`${name} + pipelined GET → connection closed, GET never served`, async () => {
135
+ const payload = `POST ${rpc_path} HTTP/1.1\r\n` +
136
+ `Host: ${host}\r\n` +
137
+ `Content-Type: application/json\r\n` +
138
+ framing_headers +
139
+ `\r\n` +
140
+ chunked_envelope +
141
+ `GET ${rpc_path} HTTP/1.1\r\nHost: ${host}\r\n\r\n`;
142
+ const { received, closed_by_server } = await raw_http_exchange(base_url, payload, {
143
+ read_timeout_ms: RAW_READ_TIMEOUT_MS
144
+ });
145
+ const n = raw_http_count_responses(received);
146
+ // Refused with 400 or answered — the status is the implementation's.
147
+ // A second response is the pipelined GET served on a connection the
148
+ // ambiguous request should have ended.
149
+ assert.ok(n <= 1, `expected at most one response to a request carrying both Content-Length ` +
150
+ `and Transfer-Encoding (the pipelined GET must not be served). Saw ${n}. ` +
151
+ `Raw head: ${received.slice(0, 120)}`);
152
+ assert.ok(closed_by_server, `expected the server to close the connection after a request carrying both ` +
153
+ `Content-Length and Transfer-Encoding; it was still open after ` +
154
+ `${RAW_READ_TIMEOUT_MS}ms. Raw head: ${received.slice(0, 120)}`);
155
+ });
156
+ }
162
157
  });
163
158
  };