@fuzdev/fuz_app 0.135.0 → 0.137.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/actions/CLAUDE.md +2 -2
- package/dist/actions/action_spec.d.ts +0 -48
- package/dist/actions/action_spec.d.ts.map +1 -1
- package/dist/actions/transports_ws_backend.d.ts +1 -1
- package/dist/actions/transports_ws_backend.js +1 -1
- package/dist/auth/CLAUDE.md +8 -2
- package/dist/auth/account_action_specs.d.ts +7 -0
- package/dist/auth/account_action_specs.d.ts.map +1 -1
- package/dist/auth/account_action_specs.js +12 -3
- package/dist/auth/account_schema.d.ts +1 -6
- package/dist/auth/account_schema.d.ts.map +1 -1
- package/dist/auth/account_schema.js +1 -0
- package/dist/auth/actor_search_action_specs.d.ts +1 -1
- package/dist/auth/actor_search_action_specs.d.ts.map +1 -1
- package/dist/auth/actor_search_action_specs.js +3 -6
- package/dist/auth/audit_emitter.d.ts +4 -0
- package/dist/auth/audit_emitter.d.ts.map +1 -1
- package/dist/auth/audit_emitter.js +18 -2
- package/dist/auth/cell_action_specs.d.ts +11 -1
- package/dist/auth/cell_action_specs.d.ts.map +1 -1
- package/dist/auth/cell_action_specs.js +18 -5
- package/dist/auth/cell_item_action_specs.d.ts.map +1 -1
- package/dist/auth/cell_item_action_specs.js +2 -4
- package/dist/auth/password.d.ts +2 -2
- package/dist/auth/password.d.ts.map +1 -1
- package/dist/auth/password.js +10 -12
- package/dist/auth/role_grant_offer_action_specs.d.ts.map +1 -1
- package/dist/auth/role_grant_offer_action_specs.js +9 -9
- package/dist/auth/role_grant_offer_notifications.d.ts.map +1 -1
- package/dist/auth/role_grant_offer_notifications.js +2 -1
- package/dist/auth/role_grant_offer_schema.d.ts +1 -1
- package/dist/auth/role_grant_offer_schema.d.ts.map +1 -1
- package/dist/auth/role_grant_offer_schema.js +4 -7
- package/dist/auth/role_schema.d.ts.map +1 -1
- package/dist/auth/role_schema.js +5 -4
- package/dist/auth/scope_kind_schema.d.ts +10 -0
- package/dist/auth/scope_kind_schema.d.ts.map +1 -1
- package/dist/auth/scope_kind_schema.js +15 -3
- package/dist/cli/args.d.ts +21 -15
- package/dist/cli/args.d.ts.map +1 -1
- package/dist/cli/args.js +40 -16
- package/dist/cli/daemon.d.ts +0 -5
- package/dist/cli/daemon.d.ts.map +1 -1
- package/dist/cli/help.d.ts +45 -6
- package/dist/cli/help.d.ts.map +1 -1
- package/dist/cli/help.js +81 -4
- package/dist/env/load.d.ts +1 -1
- package/dist/env/load.js +1 -1
- package/dist/http/CLAUDE.md +1 -1
- package/dist/http/auth_shape.d.ts +0 -27
- package/dist/http/auth_shape.d.ts.map +1 -1
- package/dist/http/body_limit.d.ts +27 -14
- package/dist/http/body_limit.d.ts.map +1 -1
- package/dist/http/body_limit.js +29 -16
- package/dist/http/db_routes.d.ts.map +1 -1
- package/dist/http/db_routes.js +5 -3
- package/dist/primitive_schemas.d.ts +38 -4
- package/dist/primitive_schemas.d.ts.map +1 -1
- package/dist/primitive_schemas.js +77 -13
- package/dist/server/x_accel.d.ts +0 -1
- package/dist/server/x_accel.d.ts.map +1 -1
- package/dist/testing/CLAUDE.md +36 -19
- package/dist/testing/audit_drift_guard.d.ts +18 -5
- package/dist/testing/audit_drift_guard.d.ts.map +1 -1
- package/dist/testing/audit_drift_guard.js +24 -8
- package/dist/testing/cross_backend/action_manifest.d.ts +0 -14
- package/dist/testing/cross_backend/action_manifest.d.ts.map +1 -1
- package/dist/testing/cross_backend/body_size.d.ts +18 -5
- package/dist/testing/cross_backend/body_size.d.ts.map +1 -1
- package/dist/testing/cross_backend/body_size.js +233 -34
- package/dist/testing/cross_backend/body_size_smuggling.d.ts +0 -12
- package/dist/testing/cross_backend/body_size_smuggling.d.ts.map +1 -1
- package/dist/testing/cross_backend/body_size_smuggling.js +92 -147
- package/dist/testing/cross_backend/capabilities.d.ts +3 -24
- package/dist/testing/cross_backend/capabilities.d.ts.map +1 -1
- package/dist/testing/cross_backend/capabilities.js +1 -4
- package/dist/testing/cross_backend/cell_crud.d.ts.map +1 -1
- package/dist/testing/cross_backend/cell_crud.js +31 -2
- package/dist/testing/cross_backend/conformance_case.d.ts +3 -3
- package/dist/testing/cross_backend/credential_header_robustness.d.ts.map +1 -1
- package/dist/testing/cross_backend/credential_header_robustness.js +18 -54
- package/dist/testing/cross_backend/default_backend_configs.d.ts.map +1 -1
- package/dist/testing/cross_backend/default_backend_configs.js +2 -8
- package/dist/testing/cross_backend/testing_backdoor.d.ts.map +1 -1
- package/dist/testing/cross_backend/testing_backdoor.js +2 -0
- package/dist/testing/cross_backend/testing_reset_actions.d.ts +5 -9
- package/dist/testing/cross_backend/testing_reset_actions.d.ts.map +1 -1
- package/dist/testing/cross_backend/testing_reset_actions.js +5 -0
- package/dist/testing/cross_backend/token_lifetime.d.ts.map +1 -1
- package/dist/testing/cross_backend/token_lifetime.js +34 -1
- package/dist/testing/cross_backend/ts_spine_backend_config.d.ts.map +1 -1
- package/dist/testing/cross_backend/ts_spine_backend_config.js +7 -14
- package/dist/testing/cross_backend/ws_outbound_overflow.d.ts.map +1 -1
- package/dist/testing/cross_backend/ws_outbound_overflow.js +65 -0
- package/dist/testing/schema_introspect.d.ts +24 -4
- package/dist/testing/schema_introspect.d.ts.map +1 -1
- package/dist/testing/schema_introspect.js +27 -4
- package/dist/testing/transports/raw_http.d.ts +78 -0
- package/dist/testing/transports/raw_http.d.ts.map +1 -0
- package/dist/testing/transports/raw_http.js +141 -0
- package/package.json +7 -5
- package/dist/env/resolve.d.ts +0 -163
- package/dist/env/resolve.d.ts.map +0 -1
- 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'}
|
|
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
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
* **
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
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
|
|
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
|
|
149
|
+
return JSON.parse(text);
|
|
92
150
|
}
|
|
93
151
|
catch {
|
|
94
|
-
return
|
|
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
|
|
101
|
-
const post = (fixture, body) =>
|
|
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
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
const res = await
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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 after an oversized-body `413`
|
|
10
|
-
* (`capabilities.oversized_reject_closes_connection`). `true` (default)
|
|
11
|
-
* requires that the pipelined GET goes unanswered — at most one response.
|
|
12
|
-
* `false` (Bun) requires no desync instead: exactly two well-formed
|
|
13
|
-
* responses, the 413 and one matching what the backend answers that GET
|
|
14
|
-
* sent alone — so `rpc_path` must answer a bare `GET` with a stable status
|
|
15
|
-
* and body, and a backend that closes after the 413 fails. Default `true`
|
|
16
|
-
* so a consumer that leaves the flag out fails loud on a backend that
|
|
17
|
-
* keeps the connection.
|
|
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;
|
|
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,38 +10,42 @@ 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`.
|
|
14
|
-
*
|
|
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
|
-
* - **
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* connection open, and no request behind the body is served either way.
|
|
27
|
-
* - **Keeps the connection (Bun)** — `Bun.serve` sends that same 413, header
|
|
28
|
-
* included, then keeps the socket open, skips the declared body, and serves
|
|
29
|
-
* the GET. This is not a smuggle — the GET is delimited by the POST's
|
|
30
|
-
* `Content-Length`, not carved out of the body — so the property held here
|
|
31
|
-
* is **no desync**: the bytes read back are **exactly two** well-formed
|
|
32
|
-
* responses, the first the 413 and the second the same status and body the
|
|
33
|
-
* backend gives that GET on a connection of its own. Body bytes parsed as a
|
|
34
|
-
* request would show up as a different second response or as extra ones.
|
|
35
|
-
* Fewer than two fails too, with a message naming the capability: a backend
|
|
36
|
-
* on this arm that closes after the 413 belongs on the other one.
|
|
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.
|
|
37
30
|
*
|
|
38
31
|
* The oversized body is rejected *with* a 413 on every backend — pinned
|
|
39
|
-
* reliably
|
|
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
|
|
40
34
|
* only the connection-handling half. A **positive control** (two pipelined
|
|
41
35
|
* requests → `>= 2` responses) proves a second response *would* be seen if a
|
|
42
|
-
* trailing request were processed — without it
|
|
43
|
-
*
|
|
44
|
-
*
|
|
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
|
|
38
|
+
* undercounting.
|
|
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.
|
|
45
49
|
*
|
|
46
50
|
* Raw-socket by necessity (the `FetchTransport` can't pipeline two requests on
|
|
47
51
|
* one connection), so — unlike `body_size.ts` — this is **cross-process only**
|
|
@@ -54,107 +58,32 @@ import '../assert_dev_env.js';
|
|
|
54
58
|
*
|
|
55
59
|
* @module
|
|
56
60
|
*/
|
|
57
|
-
import { connect } from 'node:net';
|
|
58
61
|
import { describe, test, assert } from 'vitest';
|
|
62
|
+
import { raw_http_count_responses, raw_http_exchange } from '../transports/raw_http.js';
|
|
59
63
|
import { SPINE_RPC_PATH } from './spine_surface_constants.js';
|
|
60
64
|
/** The shared 1 MiB cap (see `body_size.ts` for why it's a local constant). */
|
|
61
65
|
const BODY_LIMIT_DEFAULT_BYTES = 1024 * 1024;
|
|
62
66
|
/**
|
|
63
|
-
* How long each raw exchange waits for the server to finish answering.
|
|
64
|
-
*
|
|
65
|
-
* whole window.
|
|
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.
|
|
66
69
|
*/
|
|
67
70
|
const RAW_READ_TIMEOUT_MS = 2000;
|
|
68
|
-
/**
|
|
69
|
-
* Open a raw TCP socket to `base_url`, write `request_bytes` once, and collect
|
|
70
|
-
* everything the server sends back until it closes the connection or
|
|
71
|
-
* `read_timeout_ms` elapses. Write errors are swallowed: the server closing
|
|
72
|
-
* mid-upload (the correct response to an oversized body) surfaces as
|
|
73
|
-
* `EPIPE`/`ECONNRESET` on our unfinished write, which is exactly the behavior
|
|
74
|
-
* under test — what matters is what we *read back*.
|
|
75
|
-
*/
|
|
76
|
-
const send_raw = (base_url, request_bytes, read_timeout_ms) => new Promise((resolve) => {
|
|
77
|
-
const url = new URL(base_url);
|
|
78
|
-
const port = Number(url.port) || (url.protocol === 'https:' ? 443 : 80);
|
|
79
|
-
const socket = connect({ host: url.hostname, port });
|
|
80
|
-
let received = '';
|
|
81
|
-
let settled = false;
|
|
82
|
-
const finish = () => {
|
|
83
|
-
if (settled)
|
|
84
|
-
return;
|
|
85
|
-
settled = true;
|
|
86
|
-
clearTimeout(timer);
|
|
87
|
-
socket.destroy();
|
|
88
|
-
resolve(received);
|
|
89
|
-
};
|
|
90
|
-
const timer = setTimeout(finish, read_timeout_ms);
|
|
91
|
-
socket.setEncoding('latin1');
|
|
92
|
-
socket.on('connect', () => socket.write(request_bytes));
|
|
93
|
-
socket.on('data', (chunk) => {
|
|
94
|
-
received += chunk;
|
|
95
|
-
});
|
|
96
|
-
socket.on('error', () => { }); // EPIPE/ECONNRESET on mid-write close is expected
|
|
97
|
-
socket.on('close', finish);
|
|
98
|
-
});
|
|
99
|
-
/**
|
|
100
|
-
* Count HTTP response status lines in a raw byte stream. Deliberately
|
|
101
|
-
* **unanchored** (no `^`/`m`): a second pipelined response is concatenated
|
|
102
|
-
* straight after the first response's body, which carries no trailing newline,
|
|
103
|
-
* so a line-anchored match would miss it — and missing a smuggled second
|
|
104
|
-
* response is a silent false negative. No response header or JSON error body
|
|
105
|
-
* contains the literal `HTTP/1.x NNN`, so an unanchored match counts exactly
|
|
106
|
-
* the status lines. The positive control below proves this counts 2 when the
|
|
107
|
-
* server genuinely returns 2.
|
|
108
|
-
*/
|
|
109
|
-
const count_responses = (raw) => (raw.match(/HTTP\/1\.[01] \d{3}/g) ?? []).length;
|
|
110
|
-
/**
|
|
111
|
-
* Split a raw byte stream into the HTTP responses it holds, each framed on
|
|
112
|
-
* its own `Content-Length` (a response without one runs to the end of the
|
|
113
|
-
* stream). The stream is `latin1`-decoded, so a character is a byte.
|
|
114
|
-
*
|
|
115
|
-
* @returns the responses in order, or `null` when the bytes are not a
|
|
116
|
-
* sequence of whole responses — text where a status line belongs, a head
|
|
117
|
-
* with no end, a body shorter than its declared length
|
|
118
|
-
*/
|
|
119
|
-
const parse_responses = (raw) => {
|
|
120
|
-
const responses = [];
|
|
121
|
-
let offset = 0;
|
|
122
|
-
while (offset < raw.length) {
|
|
123
|
-
const head_end = raw.indexOf('\r\n\r\n', offset);
|
|
124
|
-
if (head_end === -1)
|
|
125
|
-
return null;
|
|
126
|
-
const [status_line, ...header_lines] = raw.slice(offset, head_end).split('\r\n');
|
|
127
|
-
const status_match = /^HTTP\/1\.[01] (\d{3})(?: |$)/.exec(status_line);
|
|
128
|
-
if (!status_match)
|
|
129
|
-
return null;
|
|
130
|
-
const body_start = head_end + 4;
|
|
131
|
-
const length_line = header_lines.find((line) => /^content-length:/i.test(line));
|
|
132
|
-
const body_end = length_line === undefined
|
|
133
|
-
? raw.length
|
|
134
|
-
: body_start + Number.parseInt(length_line.slice(length_line.indexOf(':') + 1), 10);
|
|
135
|
-
// also false for a `NaN` length
|
|
136
|
-
if (!(body_end >= body_start && body_end <= raw.length))
|
|
137
|
-
return null;
|
|
138
|
-
responses.push({ status: Number(status_match[1]), body: raw.slice(body_start, body_end) });
|
|
139
|
-
offset = body_end;
|
|
140
|
-
}
|
|
141
|
-
return responses;
|
|
142
|
-
};
|
|
143
71
|
export const describe_body_size_smuggling_cross_tests = (options) => {
|
|
144
72
|
const { base_url } = options;
|
|
145
73
|
const rpc_path = options.rpc_path ?? SPINE_RPC_PATH;
|
|
146
|
-
const closes_connection = options.closes_connection ?? true;
|
|
147
74
|
const host = new URL(base_url).host;
|
|
148
75
|
describe('body-size limit — request-smuggling resistance', () => {
|
|
149
76
|
// Positive control: prove the server returns >1 response on a single
|
|
150
77
|
// connection. Without this the smuggling assertion below would be
|
|
151
78
|
// vacuously green on a server that simply never reuses connections — and
|
|
152
|
-
// it also validates `
|
|
79
|
+
// it also validates `raw_http_count_responses` actually counts a second response.
|
|
153
80
|
test('control: two pipelined requests → ≥2 responses (connection reuse is real)', async () => {
|
|
154
81
|
const two_requests = `GET ${rpc_path} HTTP/1.1\r\nHost: ${host}\r\n\r\n` +
|
|
155
82
|
`GET ${rpc_path} HTTP/1.1\r\nHost: ${host}\r\nConnection: close\r\n\r\n`;
|
|
156
|
-
const response = await
|
|
157
|
-
|
|
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);
|
|
158
87
|
assert.ok(n >= 2, `expected ≥2 responses on one keep-alive connection (got ${n}); without ` +
|
|
159
88
|
`connection reuse — or with an undercounting matcher — the smuggling ` +
|
|
160
89
|
`assertion below is not a real signal. Raw head: ${response.slice(0, 120)}`);
|
|
@@ -171,43 +100,59 @@ export const describe_body_size_smuggling_cross_tests = (options) => {
|
|
|
171
100
|
`\r\n` +
|
|
172
101
|
'x'.repeat(oversized_len) +
|
|
173
102
|
pipelined_get;
|
|
174
|
-
const response = await
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
}
|
|
186
|
-
// The backend keeps the connection (Bun): the 413, then its answer to
|
|
187
|
-
// the GET, and nothing else. Hold it to **no desync** — the body bytes
|
|
188
|
-
// were skipped as a body, never parsed as a request.
|
|
189
|
-
assert.ok(n >= 2, `expected two responses (the 413 + the reply to the pipelined GET) from a ` +
|
|
190
|
-
`backend declared to keep the connection after an oversized-body 413. Saw ` +
|
|
191
|
-
`${n} within ${RAW_READ_TIMEOUT_MS}ms. If this backend closes the connection ` +
|
|
192
|
-
`after the 413, declare \`oversized_reject_closes_connection: true\` for it ` +
|
|
193
|
-
`(this suite's \`closes_connection\`). Raw head: ${response.slice(0, 120)}`);
|
|
194
|
-
assert.ok(n <= 2, `expected exactly two responses (the 413 + one reply to the pipelined GET); ` +
|
|
195
|
-
`more means the oversized body was parsed as requests (a desync). ` +
|
|
196
|
-
`Saw ${n}. Raw head: ${response.slice(0, 120)}`);
|
|
197
|
-
const responses = parse_responses(response);
|
|
198
|
-
assert.ok(responses !== null, `expected the bytes read back to be whole HTTP responses, each framed on its ` +
|
|
199
|
-
`Content-Length. Raw head: ${response.slice(0, 240)}`);
|
|
200
|
-
assert.strictEqual(responses.length, n, `expected one status line per framed response; a difference means a status ` +
|
|
201
|
-
`line sits inside another response's body. Raw head: ${response.slice(0, 240)}`);
|
|
202
|
-
const [first, second] = responses;
|
|
203
|
-
assert.strictEqual(first.status, 413, 'the first response is the oversized reject');
|
|
204
|
-
// A request carved out of the body would not be this GET, so the reply
|
|
205
|
-
// must be the one the same GET gets on its own connection.
|
|
206
|
-
const alone = parse_responses(await send_raw(base_url, `GET ${rpc_path} HTTP/1.1\r\nHost: ${host}\r\nConnection: close\r\n\r\n`, RAW_READ_TIMEOUT_MS));
|
|
207
|
-
assert.ok(alone?.length === 1, 'expected one response to the GET sent alone');
|
|
208
|
-
assert.deepStrictEqual(second, alone[0], `expected the second response to be the backend's reply to the pipelined ` +
|
|
209
|
-
`GET (same status and body as that GET sent alone); anything else means ` +
|
|
210
|
-
`the bytes after the 413 were not framed as that request`);
|
|
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)}`);
|
|
211
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
|
+
}
|
|
212
157
|
});
|
|
213
158
|
};
|