fix(http): parse Content-Length per header line, strip CR/LF outbound (#715, #716) - #756
Merged
Merged
Conversation
…#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>
There was a problem hiding this comment.
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-Lengthwith per-header-line parsing and reject conflicting duplicates. - Strip CR/LF from outbound
http_postheader names/values and deduplicate the stripping logic by sharing it withhttp_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 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 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #715
Closes #716
#715 — a header VALUE could frame the body
Content-Lengthwas located with onestrcasestr()over the whole header block, so the first hit anywhere won:X-Note: Content-Length: 0X-Content-Length: 0POST /x?q=Content-Length:0The server framed the body at the decoy's length, broke out of the read loop, and dispatched the
coderoute with an empty body. It also let a client hide an oversized realContent-Lengthbehind a small fake one and dodge the> max_body400.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. TwoContent-Lengthheaders 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
reqbufwhen 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_postpassed header names and values to curl's-Hverbatim, so a\r\nin either injected extra headers into the outbound request — the class the runtime already recognised one screen away inhttp_cors. Both halves now run through a sharedhttp_strip_crlf(), andhttp_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
coderoute forwarding a client-supplied header value into anhttp_postmakes 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-redirpinned tohttp,https) while leaving the destination open invites the assumption that both are guarded. The Supported Versions table was still on0.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:
Against the fix:
HTTP_SERVER: 37 passed, 0 failed.Validation
make asan-http+ASAN_OPTIONS=detect_leaks=1: 3357/3357, leak tally still 0gcc -Wall -WextracleanFiled separately
Writing HS32 surfaced #755:
http_postonly reads theVAL_LISTshape, but a JSON object parses toVAL_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