@fuzdev/fuz_app 0.135.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.
- package/dist/actions/CLAUDE.md +2 -2
- 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 +11 -1
- 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 +10 -0
- package/dist/auth/cell_action_specs.d.ts.map +1 -1
- package/dist/auth/cell_action_specs.js +17 -3
- package/dist/auth/role_grant_offer_action_specs.d.ts.map +1 -1
- package/dist/auth/role_grant_offer_action_specs.js +7 -2
- 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 +11 -0
- 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/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/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/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 -0
- 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 +1 -1
- package/dist/env/resolve.d.ts +0 -163
- package/dist/env/resolve.d.ts.map +0 -1
- package/dist/env/resolve.js +0 -267
|
@@ -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
|
};
|
|
@@ -122,28 +122,6 @@ export interface BackendCapabilities {
|
|
|
122
122
|
* explicitly (a backend that deliberately omits the route).
|
|
123
123
|
*/
|
|
124
124
|
readonly account_status: boolean;
|
|
125
|
-
/**
|
|
126
|
-
* After an oversized-body `413` the backend **closes the connection**, so a
|
|
127
|
-
* request that follows on the same connection is never served. Read by
|
|
128
|
-
* `describe_body_size_smuggling_cross_tests`, which pipelines a `GET` behind
|
|
129
|
-
* an oversized `POST`: with it `true` the `GET` must go unanswered (at most
|
|
130
|
-
* one response); with it `false` the suite requires **exactly two**
|
|
131
|
-
* responses — the 413, then the backend's own answer to that `GET`, which
|
|
132
|
-
* shows the next request was framed on `Content-Length` and the body bytes
|
|
133
|
-
* were not parsed as a request. Fewer than two fails as well: a backend
|
|
134
|
-
* declared `false` that closes after the 413 fails the suite until the flag
|
|
135
|
-
* is flipped to `true`.
|
|
136
|
-
*
|
|
137
|
-
* `true` for Node (closes at once, the body unread), Deno (closes once the
|
|
138
|
-
* declared body has arrived — observed on Deno 2.8.3) and the Rust spine
|
|
139
|
-
* (hyper closes at once); `false` for Bun. `Bun.serve` sends the 413
|
|
140
|
-
* with its `Connection: close` header and then keeps the connection open,
|
|
141
|
-
* skips the declared body, and serves the request after it — observed on
|
|
142
|
-
* Bun 1.3.11, whatever the handler does with the request body. Requests
|
|
143
|
-
* stay correctly framed there; what is missing is the close. See
|
|
144
|
-
* `docs/security.md` §"Body Size Limiting".
|
|
145
|
-
*/
|
|
146
|
-
readonly oversized_reject_closes_connection: boolean;
|
|
147
125
|
/**
|
|
148
126
|
* The backend can **initiate** a JSON-RPC request to a connected client
|
|
149
127
|
* and await its typed reply (the server→client request/response direction
|
|
@@ -174,8 +152,9 @@ export interface BackendCapabilities {
|
|
|
174
152
|
* anything.
|
|
175
153
|
*
|
|
176
154
|
* `true` for Node (`ws`), Deno, and the Rust spine (hyper hands the
|
|
177
|
-
* leftover bytes to the upgraded stream); `false` for Bun
|
|
178
|
-
*
|
|
155
|
+
* leftover bytes to the upgraded stream); `false` for a Bun backend that
|
|
156
|
+
* upgrades after an asynchronous check, as one behind auth does — `Bun.serve`
|
|
157
|
+
* answers such a request `400` (observed on Bun 1.4.3). A backend
|
|
179
158
|
* without it still runs the suite's client-library case (requests sent the
|
|
180
159
|
* moment the socket opens), which pins the same contract without forcing
|
|
181
160
|
* the ordering.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"capabilities.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/capabilities.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;
|
|
1
|
+
{"version":3,"file":"capabilities.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/capabilities.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AA2C9B;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,iBAAiB;IACjC;;;;;OAKG;IACH,QAAQ,CAAC,WAAW,EAAE,OAAO,CAAC;IAC9B;;;;;OAKG;IACH,QAAQ,CAAC,aAAa,EAAE,OAAO,CAAC;IAChC;;;;OAIG;IACH,QAAQ,CAAC,gBAAgB,EAAE,OAAO,CAAC;CACnC;AAED;;;;;GAKG;AACH,MAAM,WAAW,mBAAmB;IACnC;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,EAAE,OAAO,CAAC;IACrB;;;;;OAKG;IACH,QAAQ,CAAC,GAAG,EAAE,OAAO,CAAC;IACtB;;;;;;;OAOG;IACH,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;IAC5B;;;;;;;;;;OAUG;IACH,QAAQ,CAAC,cAAc,EAAE,OAAO,CAAC;IACjC;;;;;;;;;;;;;;OAcG;IACH,QAAQ,CAAC,iBAAiB,EAAE,OAAO,CAAC;IACpC;;;;;;;;OAQG;IACH,QAAQ,CAAC,YAAY,EAAE,OAAO,CAAC;IAC/B;;;;;;;;OAQG;IACH,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;IACxB;;;;;;;;OAQG;IACH,QAAQ,CAAC,cAAc,EAAE,OAAO,CAAC;IACjC;;;;;;;;;;;;;;;;;;OAkBG;IACH,QAAQ,CAAC,YAAY,EAAE,OAAO,CAAC;IAC/B;;;;;;;;;;;;;;;;OAgBG;IACH,QAAQ,CAAC,uBAAuB,EAAE,OAAO,CAAC;IAC1C;;;;;;;;;;;;;;OAcG;IACH,QAAQ,CAAC,kBAAkB,EAAE,OAAO,CAAC;IACrC;;;;;;;;;;;;;;;;OAgBG;IACH,QAAQ,CAAC,wBAAwB,EAAE,OAAO,CAAC;IAC3C;;;;;;;;;;;;OAYG;IACH,QAAQ,CAAC,iBAAiB,EAAE,OAAO,CAAC;CACpC;AAED;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,uBAAuB,EAAE,mBAqBpC,CAAC;AAEH;;;;GAIG;AACH,eAAO,MAAM,sBAAsB,EAAE,iBAInC,CAAC;AAEH;;;;;;;;GAQG;AACH,eAAO,MAAM,OAAO,GAAI,MAAM,OAAO,EAAE,MAAM,MAAM,EAAE,IAAI,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,KAAG,IAMrF,CAAC"}
|
|
@@ -22,8 +22,7 @@ import '../assert_dev_env.js';
|
|
|
22
22
|
* a capability is a compile error until each family declares it).
|
|
23
23
|
* - `ts_spine_capabilities` / `ts_spine_bun_capabilities` — fuz_app's own TS
|
|
24
24
|
* spine presets, in `ts_spine_backend_config.ts` (deltas off the family
|
|
25
|
-
* default; Bun flips `
|
|
26
|
-
* `ws_handshake_pipelining`).
|
|
25
|
+
* default; Bun flips `ws_handshake_pipelining`).
|
|
27
26
|
* - `rust_spine_stub_capabilities` — fuz_app's Rust spine-stub preset, in
|
|
28
27
|
* `rust_spine_stub_backend_config.ts` (delta off the rust family default).
|
|
29
28
|
*
|
|
@@ -61,8 +60,6 @@ export const in_process_capabilities = Object.freeze({
|
|
|
61
60
|
fact_serving: true,
|
|
62
61
|
ready: true,
|
|
63
62
|
account_status: true,
|
|
64
|
-
// Nothing reads it in-process: the smuggling probe needs a real socket.
|
|
65
|
-
oversized_reject_closes_connection: true,
|
|
66
63
|
peer_request: false,
|
|
67
64
|
// Cross-process-only, like `peer_request`: the case needs a real bound socket
|
|
68
65
|
// to write raw bytes to.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cell_crud.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/cell_crud.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;
|
|
1
|
+
{"version":3,"file":"cell_crud.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/cell_crud.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AA6D9B,OAAO,KAAK,EAAE,uCAAuC,EAAE,MAAM,YAAY,CAAC;AAG1E,eAAO,MAAM,8BAA8B,GAC1C,SAAS,uCAAuC,KAC9C,IA6fF,CAAC"}
|
|
@@ -32,7 +32,10 @@ import '../assert_dev_env.js';
|
|
|
32
32
|
* `kind` inside `data` still gets the authz error (404 / 403), never the
|
|
33
33
|
* kind error — authz runs before the input-shape checks, on both spines;
|
|
34
34
|
* - an empty-string `kind` is rejected on create (`cell_kind_empty`) and is
|
|
35
|
-
* accepted as a list filter that matches nothing
|
|
35
|
+
* accepted as a list filter that matches nothing;
|
|
36
|
+
* - a `kind` of exactly `CELL_KIND_LENGTH_MAX` UTF-16 code units is stored
|
|
37
|
+
* whole, and one unit past it is `invalid_params` on create and as a list
|
|
38
|
+
* filter.
|
|
36
39
|
*
|
|
37
40
|
* The visibility-manage-tier 403 (`cell_visibility_manage_only`) needs a
|
|
38
41
|
* non-owner editor, which only a `cell_grant` can produce, so it lives in
|
|
@@ -42,7 +45,7 @@ import '../assert_dev_env.js';
|
|
|
42
45
|
* @module
|
|
43
46
|
*/
|
|
44
47
|
import { describe, assert } from 'vitest';
|
|
45
|
-
import { CellCreateOutput, CellDeleteOutput, CellGetOutput, CellListOutput, CellUpdateOutput } from '../../auth/cell_action_specs.js';
|
|
48
|
+
import { CELL_KIND_LENGTH_MAX, CellCreateOutput, CellDeleteOutput, CellGetOutput, CellListOutput, CellUpdateOutput } from '../../auth/cell_action_specs.js';
|
|
46
49
|
import { test_if } from './capabilities.js';
|
|
47
50
|
import { cross_rpc_call, error_reason, expect_output } from './cell_cross_helpers.js';
|
|
48
51
|
import { assert_iso8601_seconds, assert_iso8601_seconds_nullable } from './wire_shapes.js';
|
|
@@ -239,5 +242,31 @@ export const describe_cell_crud_cross_tests = (options) => {
|
|
|
239
242
|
const listed = expect_output(await cross_rpc_call(t, rpc_path, 'cell_list', { kind: '' }, owner_headers), CellListOutput);
|
|
240
243
|
assert.ok(!listed.cells.some((c) => c.id === note.id), 'empty-kind filter matched a non-empty-kind cell');
|
|
241
244
|
});
|
|
245
|
+
test_if(capabilities.cell_crud, 'kind length cap: stored whole at the cap; one unit past it is invalid_params on create and list', async () => {
|
|
246
|
+
const fixture = await setup_test();
|
|
247
|
+
const owner = await fixture.create_account({ username: 'cell_kind_cap' });
|
|
248
|
+
const t = fixture.fresh_transport();
|
|
249
|
+
const owner_headers = owner.create_session_headers();
|
|
250
|
+
// an astral character costs two UTF-16 code units on both spines
|
|
251
|
+
const astral_at_cap = '\u{1F600}'.repeat(CELL_KIND_LENGTH_MAX / 2);
|
|
252
|
+
for (const kind of ['k'.repeat(CELL_KIND_LENGTH_MAX), astral_at_cap]) {
|
|
253
|
+
assert.strictEqual(kind.length, CELL_KIND_LENGTH_MAX);
|
|
254
|
+
const created = expect_output(await cross_rpc_call(t, rpc_path, 'cell_create', { kind, data: {} }, owner_headers), CellCreateOutput).cell;
|
|
255
|
+
assert.strictEqual(created.kind, kind, 'a kind at the cap must be stored whole');
|
|
256
|
+
const listed = expect_output(await cross_rpc_call(t, rpc_path, 'cell_list', { kind }, owner_headers), CellListOutput);
|
|
257
|
+
assert.ok(listed.cells.some((c) => c.id === created.id), 'a kind filter at the cap must match the cell that carries it');
|
|
258
|
+
}
|
|
259
|
+
for (const kind of ['k'.repeat(CELL_KIND_LENGTH_MAX + 1), astral_at_cap + 'k']) {
|
|
260
|
+
for (const [method, params] of [
|
|
261
|
+
['cell_create', { kind, data: {} }],
|
|
262
|
+
['cell_list', { kind }]
|
|
263
|
+
]) {
|
|
264
|
+
const refused = await cross_rpc_call(t, rpc_path, method, params, owner_headers);
|
|
265
|
+
assert.ok(!refused.ok, `${method} admitted a ${kind.length}-unit kind`);
|
|
266
|
+
assert.strictEqual(refused.error?.code, -32602);
|
|
267
|
+
assert.strictEqual(error_reason(refused), undefined, 'a schema refusal carries no reason');
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
});
|
|
242
271
|
});
|
|
243
272
|
};
|
|
@@ -92,12 +92,12 @@ import { z } from 'zod';
|
|
|
92
92
|
export declare const ConformancePrincipal: z.ZodEnum<{
|
|
93
93
|
keeper: "keeper";
|
|
94
94
|
token: "token";
|
|
95
|
+
anonymous: "anonymous";
|
|
95
96
|
daemon: "daemon";
|
|
96
97
|
invalid_daemon: "invalid_daemon";
|
|
97
98
|
daemon_browser: "daemon_browser";
|
|
98
99
|
bearer_browser: "bearer_browser";
|
|
99
100
|
scoped_token: "scoped_token";
|
|
100
|
-
anonymous: "anonymous";
|
|
101
101
|
fresh_non_admin: "fresh_non_admin";
|
|
102
102
|
role_holder: "role_holder";
|
|
103
103
|
wrong_role: "wrong_role";
|
|
@@ -122,12 +122,12 @@ export declare const ConformanceCaseRequest: z.ZodObject<{
|
|
|
122
122
|
as: z.ZodEnum<{
|
|
123
123
|
keeper: "keeper";
|
|
124
124
|
token: "token";
|
|
125
|
+
anonymous: "anonymous";
|
|
125
126
|
daemon: "daemon";
|
|
126
127
|
invalid_daemon: "invalid_daemon";
|
|
127
128
|
daemon_browser: "daemon_browser";
|
|
128
129
|
bearer_browser: "bearer_browser";
|
|
129
130
|
scoped_token: "scoped_token";
|
|
130
|
-
anonymous: "anonymous";
|
|
131
131
|
fresh_non_admin: "fresh_non_admin";
|
|
132
132
|
role_holder: "role_holder";
|
|
133
133
|
wrong_role: "wrong_role";
|
|
@@ -179,12 +179,12 @@ export declare const ConformanceCase: z.ZodObject<{
|
|
|
179
179
|
as: z.ZodEnum<{
|
|
180
180
|
keeper: "keeper";
|
|
181
181
|
token: "token";
|
|
182
|
+
anonymous: "anonymous";
|
|
182
183
|
daemon: "daemon";
|
|
183
184
|
invalid_daemon: "invalid_daemon";
|
|
184
185
|
daemon_browser: "daemon_browser";
|
|
185
186
|
bearer_browser: "bearer_browser";
|
|
186
187
|
scoped_token: "scoped_token";
|
|
187
|
-
anonymous: "anonymous";
|
|
188
188
|
fresh_non_admin: "fresh_non_admin";
|
|
189
189
|
role_holder: "role_holder";
|
|
190
190
|
wrong_role: "wrong_role";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"credential_header_robustness.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/credential_header_robustness.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;
|
|
1
|
+
{"version":3,"file":"credential_header_robustness.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/credential_header_robustness.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AA4D9B,qGAAqG;AACrG,MAAM,WAAW,0CAA0C;IAC1D,mFAAmF;IACnF,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,uDAAuD;IACvD,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,6DAA6D;IAC7D,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;CAC9B;AAgDD,eAAO,MAAM,iDAAiD,GAC7D,SAAS,0CAA0C,KACjD,IAkHF,CAAC"}
|
|
@@ -46,55 +46,19 @@ import '../assert_dev_env.js';
|
|
|
46
46
|
*
|
|
47
47
|
* @module
|
|
48
48
|
*/
|
|
49
|
-
import { connect } from 'node:net';
|
|
50
49
|
import { describe, test, assert } from 'vitest';
|
|
51
50
|
import { DAEMON_TOKEN_HEADER } from '../../auth/daemon_token.js';
|
|
51
|
+
import { raw_http_count_responses, raw_http_exchange, raw_http_first_status } from '../transports/raw_http.js';
|
|
52
52
|
import { SPINE_RPC_PATH } from './spine_surface_constants.js';
|
|
53
53
|
/** A well-formed UUID that never names a real row. */
|
|
54
54
|
const NIL_UUID = '00000000-0000-0000-0000-000000000000';
|
|
55
55
|
/** A value that is never a real credential — used for the conflicting / garbage copies. */
|
|
56
56
|
const GARBAGE_TOKEN = 'not-a-real-credential-value';
|
|
57
|
-
/**
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
* `body_size_smuggling.ts`'s `send_raw`.
|
|
63
|
-
*/
|
|
64
|
-
const send_raw = (base_url, request_bytes, read_timeout_ms) => new Promise((resolve) => {
|
|
65
|
-
const url = new URL(base_url);
|
|
66
|
-
const port = Number(url.port) || (url.protocol === 'https:' ? 443 : 80);
|
|
67
|
-
const socket = connect({ host: url.hostname, port });
|
|
68
|
-
let received = '';
|
|
69
|
-
let settled = false;
|
|
70
|
-
const finish = () => {
|
|
71
|
-
if (settled)
|
|
72
|
-
return;
|
|
73
|
-
settled = true;
|
|
74
|
-
clearTimeout(timer);
|
|
75
|
-
socket.destroy();
|
|
76
|
-
resolve(received);
|
|
77
|
-
};
|
|
78
|
-
const timer = setTimeout(finish, read_timeout_ms);
|
|
79
|
-
socket.setEncoding('latin1');
|
|
80
|
-
socket.on('connect', () => socket.write(request_bytes));
|
|
81
|
-
socket.on('data', (chunk) => {
|
|
82
|
-
received += chunk;
|
|
83
|
-
});
|
|
84
|
-
socket.on('error', () => { }); // EPIPE/ECONNRESET on mid-write close is expected
|
|
85
|
-
socket.on('close', finish);
|
|
86
|
-
});
|
|
87
|
-
/**
|
|
88
|
-
* Count HTTP response status lines in a raw byte stream. Unanchored so a
|
|
89
|
-
* second (smuggled) response concatenated after the first body — which carries
|
|
90
|
-
* no trailing newline — is still counted; see `body_size_smuggling.ts`.
|
|
91
|
-
*/
|
|
92
|
-
const count_responses = (raw) => (raw.match(/HTTP\/1\.[01] \d{3}/g) ?? []).length;
|
|
93
|
-
/** The status code of the first HTTP response in a raw stream, or `null` if none. */
|
|
94
|
-
const first_status = (raw) => {
|
|
95
|
-
const match = /HTTP\/1\.[01] (\d{3})/.exec(raw);
|
|
96
|
-
return match ? Number(match[1]) : null;
|
|
97
|
-
};
|
|
57
|
+
/** How long each raw exchange waits for the server to finish answering. */
|
|
58
|
+
const RAW_READ_TIMEOUT_MS = 2000;
|
|
59
|
+
/** Write `request_bytes` to a fresh raw socket and read until the server closes. */
|
|
60
|
+
const send_raw = async (base_url, request_bytes) => (await raw_http_exchange(base_url, request_bytes, { read_timeout_ms: RAW_READ_TIMEOUT_MS }))
|
|
61
|
+
.received;
|
|
98
62
|
const is_success_status = (status) => status >= 200 && status < 300;
|
|
99
63
|
/**
|
|
100
64
|
* Frame a raw HTTP/1.1 POST with the given extra header lines + JSON body.
|
|
@@ -124,18 +88,18 @@ export const describe_credential_header_robustness_cross_tests = (options) => {
|
|
|
124
88
|
describe('credential-header robustness', () => {
|
|
125
89
|
test('duplicate X-Daemon-Token (valid + conflicting) never escalates → one non-2xx response', async () => {
|
|
126
90
|
const req = rpc_post(host, rpc_path, [`${DAEMON_TOKEN_HEADER}: ${daemon_token}`, `${DAEMON_TOKEN_HEADER}: ${GARBAGE_TOKEN}`], purge_body('dup-daemon'));
|
|
127
|
-
const raw = await send_raw(base_url, req
|
|
128
|
-
assert.strictEqual(
|
|
129
|
-
const status =
|
|
91
|
+
const raw = await send_raw(base_url, req);
|
|
92
|
+
assert.strictEqual(raw_http_count_responses(raw), 1, `a duplicated X-Daemon-Token must yield exactly one response (no desync). Raw head: ${raw.slice(0, 120)}`);
|
|
93
|
+
const status = raw_http_first_status(raw);
|
|
130
94
|
assert.ok(status !== null, `expected an HTTP response. Raw head: ${raw.slice(0, 120)}`);
|
|
131
95
|
assert.ok(!is_success_status(status), `a duplicated daemon token must not perform a keeper operation; whichever copy wins, ` +
|
|
132
96
|
`the result must be non-2xx (got ${status})`);
|
|
133
97
|
});
|
|
134
98
|
test('duplicate Authorization bearer headers (both garbage) do not authenticate → one non-2xx response', async () => {
|
|
135
99
|
const req = rpc_post(host, rpc_path, [`Authorization: Bearer ${GARBAGE_TOKEN}`, `Authorization: Bearer ${GARBAGE_TOKEN}-2`], purge_body('dup-bearer'));
|
|
136
|
-
const raw = await send_raw(base_url, req
|
|
137
|
-
assert.strictEqual(
|
|
138
|
-
const status =
|
|
100
|
+
const raw = await send_raw(base_url, req);
|
|
101
|
+
assert.strictEqual(raw_http_count_responses(raw), 1, `duplicate Authorization headers must yield exactly one response (no desync). Raw head: ${raw.slice(0, 120)}`);
|
|
102
|
+
const status = raw_http_first_status(raw);
|
|
139
103
|
assert.ok(status !== null, `expected an HTTP response. Raw head: ${raw.slice(0, 120)}`);
|
|
140
104
|
assert.ok(!is_success_status(status), `garbage bearer tokens (even duplicated) must not authenticate (got ${status})`);
|
|
141
105
|
});
|
|
@@ -154,10 +118,10 @@ export const describe_credential_header_robustness_cross_tests = (options) => {
|
|
|
154
118
|
];
|
|
155
119
|
for (const { label, value } of malformed) {
|
|
156
120
|
const req = rpc_post(host, rpc_path, [`${DAEMON_TOKEN_HEADER}: ${value}`], purge_body('ctrl-char'));
|
|
157
|
-
const raw = await send_raw(base_url, req
|
|
158
|
-
assert.ok(
|
|
121
|
+
const raw = await send_raw(base_url, req);
|
|
122
|
+
assert.ok(raw_http_count_responses(raw) <= 1, `${label}: a control-char-injected credential header must not frame a second ` +
|
|
159
123
|
`request (no smuggle). Raw head: ${raw.slice(0, 120)}`);
|
|
160
|
-
const status =
|
|
124
|
+
const status = raw_http_first_status(raw);
|
|
161
125
|
assert.ok(status === null || !is_success_status(status), `${label}: a malformed credential header must never authenticate (got ${status})`);
|
|
162
126
|
}
|
|
163
127
|
});
|
|
@@ -166,12 +130,12 @@ export const describe_credential_header_robustness_cross_tests = (options) => {
|
|
|
166
130
|
// rejected (4xx / 431 / connection close). Its own outcome is don't-care.
|
|
167
131
|
const oversized = 'a'.repeat(64 * 1024);
|
|
168
132
|
const req = rpc_post(host, rpc_path, [`${DAEMON_TOKEN_HEADER}: ${oversized}`], purge_body('oversized'));
|
|
169
|
-
await send_raw(base_url, req
|
|
133
|
+
await send_raw(base_url, req);
|
|
170
134
|
// The property: the server survives it. A normal request on a fresh
|
|
171
135
|
// connection must still get a response (no crash, no wedged accept loop).
|
|
172
136
|
const probe = `GET ${rpc_path} HTTP/1.1\r\nHost: ${host}\r\nConnection: close\r\n\r\n`;
|
|
173
|
-
const raw = await send_raw(base_url, probe
|
|
174
|
-
assert.ok(
|
|
137
|
+
const raw = await send_raw(base_url, probe);
|
|
138
|
+
assert.ok(raw_http_first_status(raw) !== null, `server must still respond after an oversized-header request (no crash / wedge). Raw head: ${raw.slice(0, 120)}`);
|
|
175
139
|
});
|
|
176
140
|
});
|
|
177
141
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"default_backend_configs.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/default_backend_configs.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAE9B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAEH,OAAO,KAAK,EAAE,sBAAsB,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACjF,OAAO,KAAK,EAAE,mBAAmB,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AAChF,OAAO,EAA4B,KAAK,gBAAgB,EAAE,MAAM,+BAA+B,CAAC;AAYhG,2CAA2C;AAC3C,MAAM,WAAW,yBAAyB;IACzC,QAAQ,CAAC,wBAAwB,CAAC,EAAE,OAAO,CAAC;IAC5C,QAAQ,CAAC,8BAA8B,CAAC,EAAE,MAAM,CAAC;CACjD;AAED;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,GACjC,SAAS,yBAAyB,KAChC,MAAM,CAAC,MAAM,EAAE,MAAM,CAKtB,CAAC;AAEH;;;;GAIG;AACH,eAAO,MAAM,uBAAuB,EAAE,
|
|
1
|
+
{"version":3,"file":"default_backend_configs.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/default_backend_configs.ts"],"names":[],"mappings":"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"}
|
|
@@ -32,10 +32,6 @@ export const ts_default_capabilities = Object.freeze({
|
|
|
32
32
|
// `GET /api/account/status` is bundled into `create_account_route_specs`, so
|
|
33
33
|
// every TS backend mounting account routes serves it.
|
|
34
34
|
account_status: true,
|
|
35
|
-
// Node and Deno close the connection after an oversized-body `413`. A
|
|
36
|
-
// Bun-served consumer overrides to `false` (see the bun spine config) —
|
|
37
|
-
// `Bun.serve` keeps the connection and serves the next request.
|
|
38
|
-
oversized_reject_closes_connection: true,
|
|
39
35
|
// Off by default like `sse`/`ready`: the WS backend transport *can* initiate
|
|
40
36
|
// server→client requests (`BackendWebsocketTransport.request_connection`),
|
|
41
37
|
// but driving the full `peer/ping` suite also needs `peer/ping` mounted on
|
|
@@ -44,7 +40,8 @@ export const ts_default_capabilities = Object.freeze({
|
|
|
44
40
|
peer_request: false,
|
|
45
41
|
// Node (`ws`) and Deno hand frames pipelined with the upgrade request to the
|
|
46
42
|
// socket. A Bun-served consumer overrides to `false` (see the bun spine
|
|
47
|
-
// config) — `Bun.serve` answers such a request `400
|
|
43
|
+
// config) — `Bun.serve` answers such a request `400` when the upgrade
|
|
44
|
+
// follows an asynchronous check.
|
|
48
45
|
ws_handshake_pipelining: true,
|
|
49
46
|
// Off by default: a consumer's WS endpoint mounts its own domain's actions.
|
|
50
47
|
// fuz_app's spine configs opt in (the spine binary mounts the account
|
|
@@ -76,9 +73,6 @@ export const rust_default_capabilities = Object.freeze({
|
|
|
76
73
|
// The Rust `account_router` bundles `/status` into the account routes, so
|
|
77
74
|
// every Rust spine serving the account surface serves it.
|
|
78
75
|
account_status: true,
|
|
79
|
-
// hyper closes the connection on the oversized-body reject, so a pipelined
|
|
80
|
-
// request is never reached.
|
|
81
|
-
oversized_reject_closes_connection: true,
|
|
82
76
|
// Server-initiated requests landed Rust-first canonical — the Rust spine
|
|
83
77
|
// drives the `peer/ping` round-trip, so the family opts into the suite.
|
|
84
78
|
peer_request: true,
|