fix(http): harden request trailer parsing

- Safely discard trailers after early responses while preserving parser validation.
- Bound aggregate head and trailer parsing by bytes and field count.
- Add regressions for early response trailers and overflow rejection.
This commit is contained in:
Chris Tate
2026-09-23 08:51:26 -05:00
parent d97f6fd04a
commit bf68ea215f
5 changed files with 127 additions and 6 deletions
+59 -6
View File
@@ -55,6 +55,12 @@
#include <string.h>
#include <time.h>
/* The HTTP/1 parser has no configurable maxHeaderSize surface yet. Keep its
* head and trailer block within the existing 64 KiB parser bound, and cap
* fields too: each stored field owns three strings and three growing arrays. */
#define SCR_HTTP_MAX_HEADER_BYTES 65536u
#define SCR_HTTP_MAX_HEADER_FIELDS 1000u
static void scr_http_oom(void) {
fputs("scriptc: out of memory\n", stderr);
abort();
@@ -1595,6 +1601,8 @@ typedef struct ScrHttpConn {
size_t len, cap;
ScrHttpParseState state;
size_t body_remaining;
size_t head_bytes, head_fields;
size_t trailer_bytes, trailer_fields;
ScrHttpReq *req; /* the in-flight request (server) or response (client),
* +1; NULL between requests / before the head */
ScrHttpRes *res; /* +1; server mode only */
@@ -1611,6 +1619,21 @@ typedef struct ScrHttpConn {
static bool scr_http_client_parse_head(ScrHttpConn *conn, size_t head_len);
static void scr_http_client_head_overflow(ScrHttpConn *conn);
/* The trailer budget includes the request/response head and every trailer
* line on the wire, including the terminating blank line. Counters live on
* the connection because an early response can release conn->req while the
* parser must continue consuming and validating its incoming chunk stream. */
static bool scr_http_conn_trailer_fits(const ScrHttpConn *conn, size_t bytes, bool field) {
if (conn->head_bytes > SCR_HTTP_MAX_HEADER_BYTES ||
conn->trailer_bytes > SCR_HTTP_MAX_HEADER_BYTES - conn->head_bytes) return false;
size_t remaining = SCR_HTTP_MAX_HEADER_BYTES - conn->head_bytes - conn->trailer_bytes;
if (bytes > remaining) return false;
if (!field) return true;
if (conn->head_fields > SCR_HTTP_MAX_HEADER_FIELDS ||
conn->trailer_fields >= SCR_HTTP_MAX_HEADER_FIELDS - conn->head_fields) return false;
return true;
}
/* The server-side ctx: the 'request' listener list, shared by every
* connection. REFCOUNTED: the server's native-conn chain holds one ref
* (released through scr_http_srv_ctx_free) and each live connection
@@ -1920,10 +1943,14 @@ static bool scr_http_conn_parse_head(ScrHttpConn *conn, size_t head_len) {
scr_http_req_add_header(req, p, (size_t)(colon - p), v, (size_t)(ve - v));
p = eol + 2;
}
if (!ok) {
if (!ok || req->nheaders > SCR_HTTP_MAX_HEADER_FIELDS) {
scr_http_req_release(req);
return false;
}
conn->head_bytes = head_len;
conn->head_fields = req->nheaders;
conn->trailer_bytes = 0;
conn->trailer_fields = 0;
if (!http10 && conn->srv->require_host_header) {
bool has_host = false;
@@ -2115,13 +2142,18 @@ static void scr_http_conn_pump(ScrHttpConn *conn) {
}
}
if (!hit) {
if (conn->len > 65536) {
if (conn->len > SCR_HTTP_MAX_HEADER_BYTES) {
if (conn->client_mode) scr_http_client_head_overflow(conn);
else scr_http_conn_bad_request(conn); /* header cap */
}
return;
}
size_t head_len = (size_t)(hit - conn->buf) + 4;
if (head_len > SCR_HTTP_MAX_HEADER_BYTES) {
if (conn->client_mode) scr_http_client_head_overflow(conn);
else scr_http_conn_bad_request(conn);
return;
}
if (conn->client_mode) {
if (!scr_http_client_parse_head(conn, head_len)) {
scr_http_client_head_overflow(conn); /* malformed: hang up */
@@ -2225,8 +2257,14 @@ static void scr_http_conn_pump(ScrHttpConn *conn) {
* trailers separate from the head until 'end' fires. */
if (conn->len < 2) return;
if (conn->buf[0] == '\r' && conn->buf[1] == '\n') {
if (!scr_http_conn_trailer_fits(conn, 2, false)) {
if (conn->client_mode) scr_http_client_head_overflow(conn);
else scr_http_conn_bad_request(conn);
return;
}
memmove(conn->buf, conn->buf + 2, conn->len - 2);
conn->len -= 2;
conn->trailer_bytes += 2;
scr_http_conn_body_done(conn);
continue;
}
@@ -2238,7 +2276,7 @@ static void scr_http_conn_pump(ScrHttpConn *conn) {
}
}
if (!eol) {
if (conn->len > 65536) {
if (!scr_http_conn_trailer_fits(conn, conn->len, false)) {
if (conn->client_mode) scr_http_client_head_overflow(conn);
else scr_http_conn_bad_request(conn);
}
@@ -2265,11 +2303,22 @@ static void scr_http_conn_pump(ScrHttpConn *conn) {
else scr_http_conn_bad_request(conn);
return;
}
scr_http_req_add_trailer(conn->req, conn->buf, (size_t)(colon - conn->buf),
value, (size_t)(end - value));
size_t consumed = (size_t)(eol - conn->buf) + 2;
if (!scr_http_conn_trailer_fits(conn, consumed, true)) {
if (conn->client_mode) scr_http_client_head_overflow(conn);
else scr_http_conn_bad_request(conn);
return;
}
/* An early server response releases the request but deliberately leaves
* this parser in its chunk states so the wire remains validated. */
if (conn->req) {
scr_http_req_add_trailer(conn->req, conn->buf, (size_t)(colon - conn->buf),
value, (size_t)(end - value));
}
memmove(conn->buf, conn->buf + consumed, conn->len - consumed);
conn->len -= consumed;
conn->trailer_bytes += consumed;
conn->trailer_fields++;
continue;
}
return;
@@ -3066,10 +3115,14 @@ static bool scr_http_client_parse_head(ScrHttpConn *conn, size_t head_len) {
scr_http_req_add_header(res, p, (size_t)(colon - p), v, (size_t)(ve - v));
p = eol + 2;
}
if (!ok) {
if (!ok || res->nheaders > SCR_HTTP_MAX_HEADER_FIELDS) {
scr_http_req_release(res);
return false;
}
conn->head_bytes = head_len;
conn->head_fields = res->nheaders;
conn->trailer_bytes = 0;
conn->trailer_fields = 0;
/*
* Informational responses do not settle the request. Node emits an
+28
View File
@@ -23,6 +23,33 @@ function exchange(path, body = "") {
});
}
function earlyExchange() {
return new Promise((resolve, reject) => {
let wire = "";
let responseDone = false;
const socket = connect(port, "127.0.0.1", () => {
socket.write(
"POST /early HTTP/1.1\r\n" +
"Host: test\r\n" +
"Connection: keep-alive\r\n" +
"Transfer-Encoding: chunked\r\n" +
"Trailer: X-Probe\r\n\r\n" +
"0\r\nX-Probe: yes\r\n\r\n",
);
});
socket.on("data", (chunk) => {
wire += chunk.toString("latin1");
if (!responseDone && wire.includes("\r\n\r\nearly")) {
responseDone = true;
socket.end();
}
});
socket.on("end", () => resolve(wire));
socket.on("error", reject);
socket.setTimeout(5000, () => reject(new Error("timeout on /early")));
});
}
function summarize(path, wire) {
const split = wire.indexOf("\r\n\r\n");
const head = wire.slice(0, split);
@@ -32,6 +59,7 @@ function summarize(path, wire) {
}
const chunked = "4\r\ndata\r\n0\r\nX-Note: first\r\nx-NoTe: second\r\nCookie: a=1\r\ncookie: b=2\r\nAuthorization: first\r\nAuthorization: second\r\n\r\n";
summarize("/early", await earlyExchange());
for (const [path, body] of [["/incoming", chunked], ["/fixed", ""], ["/replace", ""], ["/explicit", ""], ["/declared", ""], ["/invalid-framing", ""], ["/invalid", ""], ["/quit", ""]]) {
summarize(path, await exchange(path, body));
}
@@ -0,0 +1,27 @@
import { connect } from "node:net";
const port = Number(process.argv[2]);
function exchange(wire) {
return new Promise((resolve, reject) => {
let response = "";
const socket = connect(port, "127.0.0.1", () => socket.end(wire));
socket.on("data", (chunk) => { response += chunk.toString("latin1"); });
socket.on("end", () => resolve(response));
socket.on("error", reject);
socket.setTimeout(5000, () => reject(new Error("timeout")));
});
}
const overflow = await exchange(
"POST /limit HTTP/1.1\r\n" +
"Host: test\r\n" +
"Connection: close\r\n" +
"Transfer-Encoding: chunked\r\n\r\n" +
"0\r\n" +
"X: y\r\n".repeat(1001) +
"\r\n",
);
console.log(`limit ${overflow.split("\r\n")[0]}`);
await exchange("GET /quit HTTP/1.1\r\nHost: test\r\nConnection: close\r\n\r\n");
console.log("limit driver done");
+4
View File
@@ -7,6 +7,10 @@ const server = createServer((req, res) => {
return;
}
console.log(`start ${req.url} raw=${req.rawTrailers.length} note=${req.trailers["x-note"] === undefined ? "absent" : "present"}`);
if (req.url === "/early") {
res.end("early");
return;
}
let body = "";
req.on("data", (chunk: Buffer) => { body += chunk.toString("utf8"); });
req.on("end", () => {
+9
View File
@@ -144,6 +144,15 @@ describe(`server differential (${cases.length} programs${sanitize ? ", sanitized
expect(nativeRes.driverStdout).toBe(nodeRes.driverStdout);
}, 120_000);
test("http-trailers rejects an aggregate trailer overflow", async () => {
const entry = join(fixturesRoot, "cases/http-trailers/main.ts");
const driver = join(fixturesRoot, "cases/http-trailers/limit-driver.mjs");
const binary = await build(entry);
const nativeRes = await runLane(binary, [], driver);
expect(nativeRes.exitCode).toBe(0);
expect(nativeRes.driverStdout).toBe("limit HTTP/1.1 400 Bad Request\nlimit driver done\n");
}, 120_000);
test("net-reuse-port emits the additive LLVM ABI", async () => {
const entry = join(fixturesRoot, "cases/net-reuse-port/main.ts");
const outDir = join(cacheDir, `server-llvm-reuse-port${sanitize ? "-san" : ""}`);