mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-02 08:35:07 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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");
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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" : ""}`);
|
||||
|
||||
Reference in New Issue
Block a user