Skip to content

Reject CR/LF in the request target and fail the request cleanly#2501

Merged
yhirose merged 3 commits into
masterfrom
request-line-crlf-guard-v2
Jul 18, 2026
Merged

Reject CR/LF in the request target and fail the request cleanly#2501
yhirose merged 3 commits into
masterfrom
request-line-crlf-guard-v2

Conversation

@yhirose

@yhirose yhirose commented Jul 18, 2026

Copy link
Copy Markdown
Owner

Supersedes #2495 (thanks @metsw24-max — the first two commits are theirs, unchanged).

What the original PR does

The client builds each request line in write_request_line by concatenating the method, the target, and " HTTP/1.1\r\n", with no check on the target. A target carrying CR/LF splits the request line and injects a header or a whole request. The neighbouring writers already guard this (check_and_write_headers over header values, perform_websocket_handshake over the path), but the ordinary request path was left out.

It becomes reachable when following a redirect with path encoding disabled: ClientImpl::redirect decodes the Location path through decode_path_component, so a wire-safe %0D%0A turns into a raw CR/LF, and under set_path_encode(false) that decoded target reaches write_request_line verbatim. The guard rejects a target that fails is_field_value, matching the other two writers.

What this PR adds on top

write_request_line now returns -1 on a rejected target, but ClientImpl::write_request ignored that return value. A rejected target was silently dropped, producing a request-line-less (headers-only) request — and the client reported it as a successful send (res.error() == Success). This is the primary path (all normal requests and the CONNECT target flow through write_request), i.e. exactly the path the original PR is about.

  • Check the return value in write_request and fail with Error::Write, matching ClientImpl::open_stream, which already checks it.
  • Add RequestLineInjectionTest.ClientRejectsCRLFTargetEndToEnd: with set_path_encode(false), cli.Get("/a\r\nInjected: pwned") must return false with Error::Write rather than reaching the wire. Verified to fail without the call-site fix.

Note: the guard blocks CR/LF (the injection vector). is_field_value still permits interior SP/HTAB, consistent with the other two writers; normal encoding prevents raw spaces in targets.

Tested: full suite green (734 tests) in both header-only and split builds.

metsw24-max and others added 3 commits July 18, 2026 17:27
split.py strips `inline` and moves the definition into httplib.cc, so
detail::write_request_line is not visible from the split httplib.h and
test_split failed to compile. Re-declare it in test.cc, matching what the
base64_encode and getaddrinfo_with_timeout tests already do.
The CR/LF guard in write_request_line returns -1, but ClientImpl::write_request
ignored that return value. A rejected target therefore produced a request-line-
less request (headers only) that the client silently reported as a successful
send. This is the primary path reachable via a decoded redirect Location under
set_path_encode(false), and it also carries the CONNECT target.

Check the return value like ClientImpl::open_stream already does and fail with
Error::Write. Add an end-to-end test asserting the client refuses a CR/LF target
instead of putting it on the wire.
@yhirose

yhirose commented Jul 18, 2026

Copy link
Copy Markdown
Owner Author

@metsw24-max this is another PR that revised your PR #2495. I fixed an issue that the return code from write_request_line is ignored. Also it adds an end-to-end test.

@yhirose
yhirose merged commit 255c075 into master Jul 18, 2026
43 checks passed
@yhirose
yhirose deleted the request-line-crlf-guard-v2 branch July 21, 2026 16:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants