From 7d35fee6883edee81db080acbd6310086c1a890f Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 20 Oct 2026 10:21:03 -0500 Subject: [PATCH] Validate flat request header arrays and drop lookup listeners when a socket closes - http.request, https.request and get validate each name and value of a flat [name, value, ...] headers array, as Node's _storeHeader does: a name that is not an HTTP token throws ERR_INVALID_HTTP_TOKEN, a value with an invalid character throws ERR_INVALID_CHAR, and an odd length throws ERR_INVALID_ARG_VALUE with the inspected array. The [[name, value], ...] entry form throws the options fence. - A closing socket drops its 'lookup' listeners with its other listeners, so a listener that holds the socket no longer keeps it alive. - The options-record diagnostics snapshot no longer expects the socketPath refusal. --- packages/runtime/src/scr_http.c | 113 +++++++++++++++++- packages/runtime/src/scr_net.c | 2 + .../6441-http-request-options-record.ts | 15 ++- .../corpus/6443-net-lookup-http-close-text.ts | 4 +- .../__snapshots__/options-record.ts.txt | 9 -- 5 files changed, 130 insertions(+), 13 deletions(-) diff --git a/packages/runtime/src/scr_http.c b/packages/runtime/src/scr_http.c index 9a2541d24..d9876c30b 100644 --- a/packages/runtime/src/scr_http.c +++ b/packages/runtime/src/scr_http.c @@ -806,6 +806,8 @@ static bool scr_http_token_char(unsigned char ch) { (ch >= 'a' && ch <= 'z') || (ch != 0 && strchr("!#$%&'*+-.^_`|~", ch) != NULL); } +static void scr_http_throw_invalid_header_name(ScrStr *name /*borrowed*/, ScrStr *label /*borrowed*/); + /* node:http's public validators use the same token and field-vchar rules * as OutgoingMessage. Strings are UTF-8 here, so the Latin-1 obs-text * range accepted by Node's JS regex is C2/C3 plus one continuation byte. */ @@ -814,7 +816,12 @@ void scr_http_validate_header_name(ScrStr *name /*borrowed*/, ScrStr *label /*bo for (size_t i = 0; i < name->len && valid; i++) valid = scr_http_token_char((unsigned char)name->data[i]); if (valid) return; + scr_http_throw_invalid_header_name(name, label); +} +/* Node's ERR_INVALID_HTTP_TOKEN for a header name; `name` is the name's + * string form. */ +static void scr_http_throw_invalid_header_name(ScrStr *name /*borrowed*/, ScrStr *label /*borrowed*/) { ScrJsonBuf b; scr_jb_init(&b); ScrStr *shown = label->len ? label : NULL; @@ -7009,6 +7016,96 @@ static const char SCR_HTTP_REQ_SUPPORTED[] = "hostname/host, port, path, method, headers, timeout, agent, signal, socketPath, " "defaultPort, protocol, and maxHeaderSize are the supported options"; +/* validateHeaderName and validateHeaderValue over one pair of the flat + * headers array: a name that is not a string throws + * ERR_INVALID_HTTP_TOKEN with its string form, and an array value + * validates each element. */ +static void scr_http_req_validate_header_pair(const ScrDyn *name, const ScrDyn *value) { + ScrStr *label = scr_str_new("", 0); + if (name->kind == SCR_DYN_STR) { + scr_http_validate_header_name(name->v.str, label); + } else { + ScrStr *text = scr_dyn_string_coerce_js(name); + if (text != NULL) { + scr_http_throw_invalid_header_name(text, label); + scr_str_release(text); + } + } + scr_str_release(label); + if (scr_exc_pending()) return; + if (value->kind == SCR_DYN_ARR) { + for (size_t k = 0; k < value->v.arr.len && !scr_exc_pending(); k++) + scr_http_validate_header_value(name->v.str, value->v.arr.items[k]); + } else { + scr_http_validate_header_value(name->v.str, value); + } +} + +/* util.inspect's one-line rendering of a short array ("[ 'x-a', 5 ]"): up + * to six primitive entries whose rendering stays within inspect's + * 80-column break length (a start of n + 11, plus the separators and each + * entry). NULL when inspect would group or break the entries, or an entry + * is not a primitive or renders outside ASCII (inspect counts UTF-16 + * units). */ +static ScrStr *scr_http_inspect_short_array(const ScrDyn *list) { + size_t n = list->v.arr.len; + if (n == 0 || n > 6) return NULL; + ScrJsonBuf b; + scr_jb_init(&b); + scr_jb_puts(&b, "[ "); + size_t total = 2 * n + 11; + bool fits = true; + for (size_t i = 0; i < n && fits; i++) { + const ScrDyn *v = list->v.arr.items[i]; + ScrStr *part = NULL; + switch (v->kind) { + case SCR_DYN_STR: part = scr_str_inspect_quoted(v->v.str, false); break; + case SCR_DYN_NUM: + part = v->v.num == 0 && signbit(v->v.num) ? scr_str_new("-0", 2) : scr_f64_to_scrstr(v->v.num); + break; + case SCR_DYN_BOOL: part = v->v.b ? scr_str_new("true", 4) : scr_str_new("false", 5); break; + case SCR_DYN_NULL: part = scr_str_new("null", 4); break; + case SCR_DYN_UNDEF: part = scr_str_new("undefined", 9); break; + default: break; + } + if (part == NULL) { + fits = false; + break; + } + for (size_t k = 0; k < part->len && fits; k++) fits = (unsigned char)part->data[k] < 0x80; + total += part->len; + if (total > 80) fits = false; + if (i > 0) scr_jb_puts(&b, ", "); + scr_jb_write(&b, part->data, part->len); + scr_str_release(part); + } + scr_jb_puts(&b, " ]"); + ScrStr *out = scr_jb_finish(&b); + if (fits) return out; + scr_str_release(out); + return NULL; +} + +/* Node's ERR_INVALID_ARG_VALUE for a flat headers array of odd length. + * Arrays whose inspected form scr_http_inspect_short_array cannot + * reproduce fence instead. */ +static void scr_http_req_odd_headers_fail(const char *api, const ScrDyn *list) { + ScrStr *shown = scr_http_inspect_short_array(list); + if (shown == NULL) { + scr_http_req_opts_fence(api, "headers", + "the odd-length headers array error prints up to six short primitive entries here"); + return; + } + ScrJsonBuf b; + scr_jb_init(&b); + scr_jb_puts(&b, "The argument 'headers' is invalid. Received "); + scr_jb_write(&b, shown->data, shown->len); + scr_str_release(shown); + ScrStr *msg = scr_jb_finish(&b); + scr_throw_error_msg_code(SCR_ERR_TYPE, msg->data, msg->len, "ERR_INVALID_ARG_VALUE"); + scr_str_release(msg); +} + void scr_http_req_opts_fence(const char *api, const char *key, const char *hint) { ScrJsonBuf b; scr_jb_init(&b); @@ -7270,7 +7367,21 @@ bool scr_http_req_opts_read(const ScrDyn *opts, bool secure, const char *api, Sc } if (!scr_exc_pending()) o->headers = scr_http_dynh_pairs(headers); } else if (headers != NULL && headers->kind == SCR_DYN_ARR) { - o->headers = scr_http_dynh_flat_pairs(headers); + /* Node's _storeHeader checks that the flat [name, value, ...] form has + * an even length, then validates each name and value. The + * [[name, value], ...] entry form is not modeled. */ + size_t n = headers->v.arr.len; + if (n > 0 && headers->v.arr.items[0]->kind == SCR_DYN_ARR) { + scr_http_req_opts_fence( + api, "headers", + "headers is an object of string or number values or a flat [name, value, ...] array here"); + } else if (n % 2 != 0) { + scr_http_req_odd_headers_fail(api, headers); + } else { + for (size_t i = 0; i < n && !scr_exc_pending(); i += 2) + scr_http_req_validate_header_pair(headers->v.arr.items[i], headers->v.arr.items[i + 1]); + } + if (!scr_exc_pending()) o->headers = scr_http_dynh_flat_pairs(headers); } else if (headers != NULL && headers->kind != SCR_DYN_NULL) { scr_http_req_opts_fence(api, "headers", "headers is an object of string or number values here"); } diff --git a/packages/runtime/src/scr_net.c b/packages/runtime/src/scr_net.c index 978f42e3d..9902033f2 100644 --- a/packages/runtime/src/scr_net.c +++ b/packages/runtime/src/scr_net.c @@ -5172,6 +5172,7 @@ static void scr_net_sweep(void) { scr_net_ls_drop(&sock->finish_ls); scr_net_ls_drop(&sock->wcb_ls); scr_net_ls_drop(&sock->ready_ls); + scr_net_ls_drop(&sock->lookup_ls); if (sock->pipe_dst) { scr_net_sock_release(sock->pipe_dst); sock->pipe_dst = NULL; @@ -5373,6 +5374,7 @@ static void scr_net_cleanup_atexit(void) { scr_net_ls_drop(&s->conn_ls); scr_net_ls_drop(&s->timeout_ls); scr_net_ls_drop(&s->readable_ls); + scr_net_ls_drop(&s->lookup_ls); /* Tear down the protocol layer's parser NOW: an in-flight http * exchange (a handler that threw before res.end()) holds req/res, * and the res holds this socket back — a cycle the lean untraced diff --git a/tests/corpus/6441-http-request-options-record.ts b/tests/corpus/6441-http-request-options-record.ts index f2eba1a02..89a9abc29 100644 --- a/tests/corpus/6441-http-request-options-record.ts +++ b/tests/corpus/6441-http-request-options-record.ts @@ -1,8 +1,10 @@ // http.request options read at run time: a RequestOptions value chosen by // a runtime condition (a TCP endpoint with a string port, or a socket // path), spread into an options object with a timeout and an optional -// signal; agent: false; an aborted signal; and Node's validation errors -// for the method, port, host, timeout, path, protocol and agent options. +// signal; agent: false; an aborted signal; a flat [name, value, ...] +// headers array; and Node's validation errors for the method, port, host, +// timeout, path, protocol and agent options and for flat header names and +// values. import * as http from "node:http"; const server = http.createServer((req, res) => { @@ -52,11 +54,15 @@ server.listen(0, "127.0.0.1", async () => { const headers: Record = { "x-n": 1 }; const controller: AbortController | undefined = undefined; const options = { ...location, path: "/rpc", method: "post", headers, timeout: 2000, signal: controller }; + // The flat [name, value, ...] headers form; @types/node types headers as an object. + const flat = (path: string, list: unknown[]): http.RequestOptions => + ({ ...location, path, headers: list }) as unknown as http.RequestOptions; await send("spread", options); await send("agent false", { ...location, path: "/close", agent: false }); const aborted = new AbortController(); aborted.abort(); await send("aborted", { ...location, path: "/never", signal: aborted.signal }); + await send("flat headers", flat("/flat", ["host", "flat.test", "x-n", 2])); invalid("method", { ...location, method: "g t" }); invalid("port", { host: "127.0.0.1", port: "http" }); invalid("host", { hostname: 5 } as unknown as http.RequestOptions); @@ -64,5 +70,10 @@ server.listen(0, "127.0.0.1", async () => { invalid("path", { ...location, path: "/a b" }); invalid("protocol", { ...location, protocol: "https:" }); invalid("agent", { ...location, agent: 5 } as unknown as http.RequestOptions); + invalid("flat name", flat("/", ["x n", "1"])); + invalid("flat number name", flat("/", [5, "1"])); + invalid("flat crlf value", flat("/", ["x-a", "ok\r\nx-b: injected"])); + invalid("flat crlf array value", flat("/", ["x-a", ["ok", "b\r\nx-c: injected"]])); + invalid("flat odd length", flat("/", ["x-a"])); server.close(); }); diff --git a/tests/corpus/6443-net-lookup-http-close-text.ts b/tests/corpus/6443-net-lookup-http-close-text.ts index 1d50d3580..ba445b117 100644 --- a/tests/corpus/6443-net-lookup-http-close-text.ts +++ b/tests/corpus/6443-net-lookup-http-close-text.ts @@ -23,7 +23,9 @@ function lookups(): Promise { named.on("data", () => {}); named.on("close", () => { const direct = net.connect({ host: "127.0.0.1", port }); - direct.once("lookup", () => console.log("unexpected lookup")); + // A 'lookup' listener that never runs and holds its socket: closing + // the socket drops it. + direct.once("lookup", () => console.log("unexpected lookup", direct.remotePort)); direct.once("connect", () => console.log("connect without lookup")); direct.on("data", () => {}); direct.on("close", () => tcp.close(() => resolve())); diff --git a/tests/harness/__snapshots__/options-record.ts.txt b/tests/harness/__snapshots__/options-record.ts.txt index ab89613c0..26ca22db3 100644 --- a/tests/harness/__snapshots__/options-record.ts.txt +++ b/tests/harness/__snapshots__/options-record.ts.txt @@ -25,15 +25,6 @@ options-record.ts:20:22 - error SC2020: 'get with agent: false and a non-literal hint: agent: false lowers by injecting Connection: close into a LITERAL headers object — add connection: "close" to the record instead, or write the headers inline -options-record.ts:23:22 - error SC2020: 'get option 'socketPath'' is part of the standard library types but has no scriptc lowering yet - - 22 | // Documented client options with no lowering fence by name. - 23 | http.get({ port: 80, socketPath: "/tmp/sock.s" }, () => {}); - | ^~~~~~~~~~~~~~~~~~~~~~~~~ - 24 | http.get({ port: 80, localAddress: "127.0.0.1" }, () => {}); - - hint: hostname/host, port, path, method, timeout, headers, agent, signal, and maxHeaderSize are the supported options - options-record.ts:24:22 - error SC2020: 'get option 'localAddress'' is part of the standard library types but has no scriptc lowering yet 23 | http.get({ port: 80, socketPath: "/tmp/sock.s" }, () => {});