Skip to content

fix(http): parse Content-Length per header line, strip CR/LF outbound (#715, #716) - #756

Merged
InauguralPhysicist merged 1 commit into
mainfrom
fix-715-716-http-header-parsing
Jul 28, 2026
Merged

fix(http): parse Content-Length per header line, strip CR/LF outbound (#715, #716)#756
InauguralPhysicist merged 1 commit into
mainfrom
fix-715-716-http-header-parsing

Conversation

@InauguralPhysicist

Copy link
Copy Markdown
Collaborator

Closes #715
Closes #716

#715 — a header VALUE could frame the body

Content-Length was located with one strcasestr() over the whole header block, so the first hit anywhere won:

  • inside another header's valueX-Note: Content-Length: 0
  • as a suffix of another header's nameX-Content-Length: 0
  • in the request targetPOST /x?q=Content-Length:0

The server framed the body at the decoy's length, broke out of the read loop, and dispatched the code route with an empty body. It also let a client hide an oversized real Content-Length behind a small fake one and dodge the > max_body 400.

http_find_content_length() now walks the header block line by line — match only at a line start, colon required immediately after the name (RFC 7230 forbids whitespace there), request line skipped outright. Two Content-Length headers that disagree are rejected with 400 instead of the first silently winning.

The part the issue didn't say

It only reproduces when the headers and body arrive in separate writes. Sent as one packet the body is already in reqbuf when the loop breaks early, so the early break costs nothing and the bug is invisible — a single-write version of HS29 passes against the vulnerable build. My first repro attempt passed all four decoy cases for exactly this reason. HS29 splits the write with a 0.3s gap, and HS29a is the control proving the split itself is sound.

#716 — outbound header injection

http_post passed header names and values to curl's -H verbatim, so a \r\n in either injected extra headers into the outbound request — the class the runtime already recognised one screen away in http_cors. Both halves now run through a shared http_strip_crlf(), and http_cors (which had the only copy of this logic) is rewritten onto it so there is one implementation.

Script-controlled, so not a vulnerability on its own under SECURITY.md's threat model — but a code route forwarding a client-supplied header value into an http_post makes it one.

SECURITY.md also now states outright that the destination of an outbound request is out of scope: there is no loopback/link-local blocklist, and guarding the transport (--proto/--proto-redir pinned to http,https) while leaving the destination open invites the assumption that both are guarded. The Supported Versions table was still on 0.26.x.

Tests — red then green

HS29/HS29a/HS30/HS31 (framing) and HS32 (outbound injection) in tests/test_http_server.sh.

Against the pre-fix binary, 5 fail and the control passes:

PASS: HS29a split-write body arrives intact (control)
FAIL: HS29 decoy Content-Length in value framed the body (got '')
FAIL: HS29 decoy Content-Length in name-suffix framed the body (got '')
FAIL: HS29 decoy Content-Length in target framed the body (got '')
FAIL: HS30 conflicting duplicate Content-Length (got 'HTTP/1.1 200 OK')
FAIL: HS32 outbound header CRLF injection (injected header reached the wire as its own line)

Against the fix: HTTP_SERVER: 37 passed, 0 failed.

Validation

  • Release suite: 3262/3262
  • make asan-http + ASAN_OPTIONS=detect_leaks=1: 3357/3357, leak tally still 0
  • gcc -Wall -Wextra clean

Filed separately

Writing HS32 surfaced #755: http_post only reads the VAL_LIST shape, but a JSON object parses to VAL_DICT, so passing headers in the obvious form sends no headers at all — silently, with a successful return. That is why HS32 uses the flat array form.

🤖 Generated with Claude Code

…#715, #716)

#715 — Content-Length was located with a single strcasestr() over the whole
header block, so the first hit anywhere won: inside another header's VALUE
(X-Note: Content-Length: 0), as a suffix of another header's NAME
(X-Content-Length: 0), or in the request target (POST /x?q=Content-Length:0).
The server framed the body at the decoy's length, left the read loop early, and
dispatched the code route with an empty body — and a client could hide an
oversized real length behind a small fake one to dodge the > max_body 400.

http_find_content_length() now walks the block line by line: match only at a
line start, colon required immediately after the name (RFC 7230 forbids
whitespace there), request line skipped outright. Two Content-Length headers
that disagree are now a 400 rather than the first silently winning.

Reachable only when the body arrives in a SEPARATE write from the headers. Sent
as one packet the body is already in reqbuf when the loop breaks, so the early
break costs nothing and the bug is invisible — a single-write version of HS29
passes against the vulnerable build. The test splits the write for that reason,
and HS29a is the control that proves the split itself is sound.

#716 — http_post passed header names and values to curl's -H verbatim, so a
CR/LF in either injected additional headers into the outbound request. Both
halves now go through a shared http_strip_crlf(); http_cors, which had the only
copy of this logic, is rewritten onto the same helper. Script-controlled and so
not a vulnerability on its own under the SECURITY.md threat model, but a code
route forwarding a client-supplied header value into an http_post makes it one.

SECURITY.md: state outright that the DESTINATION of an outbound request is out
of scope — there is no loopback/link-local blocklist, and guarding the
transport (--proto/--proto-redir pinned to http,https) while leaving the
destination open invites the assumption that both are guarded. Supported
Versions was still 0.26.x.

Tests: HS29/HS29a/HS30/HS31 (framing) and HS32 (outbound injection) in
tests/test_http_server.sh. Against the pre-fix binary 5 fail and the control
passes; HS32's failure reads "injected header reached the wire as its own
line". Release 3262/3262, asan-http 3357/3357, leak tally still 0.

Closes #715
Closes #716

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 28, 2026 12:26

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR hardens the HTTP extension against two request/response framing classes: (1) Content-Length spoofing via substring matches inside other header values/names or the request target, and (2) outbound header injection via CR/LF in script-supplied header names/values passed to curl -H. It also updates regression tests and the project’s security documentation/changelog to reflect the fixes and threat-model scope.

Changes:

  • Replace substring search for Content-Length with per-header-line parsing and reject conflicting duplicates.
  • Strip CR/LF from outbound http_post header names/values and deduplicate the stripping logic by sharing it with http_cors.
  • Add HTTP-server regression tests for the framing/injection cases and update SECURITY.md + CHANGELOG.md accordingly.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

File Description
src/ext_http.c Adds shared CR/LF stripping and parses Content-Length line-by-line to avoid decoy framing + reject conflicting duplicates.
tests/test_http_server.sh Adds HS29–HS32 regression coverage for split-write framing and outbound header injection.
SECURITY.md Clarifies SSRF destination scope and updates the Supported Versions table.
CHANGELOG.md Documents the security fixes in the Unreleased section.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/ext_http.c
Comment on lines +987 to +991
const char *v = p + 15;
while (*v == ' ' || *v == '\t') v++;
char *end = NULL;
long n = strtol(v, &end, 10);
if (end == v) return -1; /* no digits at all */
Comment thread tests/test_http_server.sh
Comment on lines +589 to +603
hdrs is "[\"X-Test\", \"probe\\\\r\\\\nX-Injected: yes\"]"
resp is http_post of ["http://127.0.0.1:$PORT/hdrs", hdrs, "b"]
print of resp
EIGSCLI
POSTED=$("$EIGS" "$CLI" 2>/dev/null | tr -d '\r')
rm -f "$CLI"
if [ -z "$POSTED" ]; then
fail "HS32 outbound header CRLF injection" "client got no response from /hdrs"
elif echo "$POSTED" | grep -q '^X-Injected:'; then
fail "HS32 outbound header CRLF injection" "injected header reached the wire as its own line"
elif echo "$POSTED" | grep -q '^X-Test: probeX-Injected: yes$'; then
ok "HS32 http_post strips CR/LF from header values (no injected header line)"
else
fail "HS32 outbound header CRLF injection" "X-Test not delivered as expected: $(echo "$POSTED" | grep -i '^X-' | tr '\n' '|')"
fi
@InauguralPhysicist
InauguralPhysicist merged commit 26589b9 into main Jul 28, 2026
19 checks passed
@InauguralPhysicist
InauguralPhysicist deleted the fix-715-716-http-header-parsing branch July 28, 2026 21:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants