Skip to content

Write Host as the first HTTP/1.1 request header field - #1366

Merged
quinnj merged 1 commit into
masterfrom
fix/h1-host-header-first
Sep 17, 2026
Merged

quinnj merged 1 commit into
masterfrom
fix/h1-host-header-first

Conversation

@quinnj

@quinnj quinnj commented Sep 14, 2026

Copy link
Copy Markdown
Member

Problem

The HTTP/1.1 client wrote the Host header after every other header:

GET / HTTP/1.1
User-Agent: example
Accept: */*
Accept-Encoding: gzip, deflate
Host: 127.0.0.1:18798
Content-Length: 0

RFC 9112 §3.2 says a user agent that sends Host SHOULD send it as the first field line after the request line, and curl, Go's net/http and Python's http.client all do. This is not only cosmetic: as reported in #1361, a CDN-fronted HTTPS site answered 403 when Host came last and 200 for the otherwise identical request with Host first.

Root cause: _prepare_request_headers_for_write in src/http1.jl injected Host from request.host with setheader, which appends a key that is not already stored. By the time the request is written, the high-level client has already stored User-Agent, Accept-Encoding and the caller's headers, so Host always landed after them. A caller-supplied Host header simply kept whatever position it had in the caller's list.

Change

  • src/http1.jl: new _hoist_host_header! replaces the setheader injection. It writes exactly one Host line as the first header field: the caller's Host header value when one is stored with a non-empty value, otherwise request.host. A caller-supplied Host is moved to the front (not duplicated) and any further stored Host entries are dropped; an empty-valued caller Host is kept as-is when there is no request.host (RFC 9112 §3.2.2 requires an empty Host for authority-less targets); when there is no Host anywhere none is invented, as before. The hoist happens on the copy that _prepare_request_headers_for_write already makes, so Request.headers is not reordered.
  • Every HTTP/1 request writer shares this preparation, so the fix covers write_request!, the transport's proxy-plan writer (absolute-form forward-proxy requests, with Proxy-Authorization following the caller's headers), the CONNECT tunnel request, and the WebSocket handshake. The HTTP/2 client uses :authority and is unaffected.
  • write_request! docstring and CHANGELOG.md updated.

Tests

  • test/http1_wire_tests.jl: new testset asserting the exact wire bytes for Host derived from request.host (the reporter's request shape), a caller Host stored after other headers moved to the front with the caller's stored order left alone, a lower-case caller key, duplicate stored Host entries collapsing to one, empty caller Host with and without request.host, no Host at all on HTTP/1.0, the forward-proxy absolute-form path via both the write_request! keywords and _write_request_head! with an HTTP_FORWARD plan, and the CONNECT tunnel request as built by the transport (which still round-trips through read_request).
  • test/http_client_tests.jl: end-to-end testset through HTTP.request against a raw TCP listener that captures the request head: Host is the second line (right after the request line) and appears exactly once, both when derived from the URL and when supplied by the caller in headers.
  • Verified the wire testset fails 10/20 (every ordering assertion) with the pre-fix writer body re-installed in-process, and passes 20/20 with the fix, on Julia 1.12.6 and Julia 1.10.11.
  • Full local Pkg.test on Julia 1.12.6: passed.

Fixes #1361

🤖 Generated with Claude Code

RFC 9112 §3.2 recommends that a user agent send Host as the first field
line after the request line, and curl, Go and Python all do. The HTTP/1
writer injected Host from request.host with setheader, which appends a
key that is not already stored, so Host landed after every client-default
and caller-supplied header. Some CDN front ends answer 403 to such a
request and 200 to the otherwise identical request with Host first.

_prepare_request_headers_for_write now hoists Host to the front of the
header copy it writes: exactly one Host line, taking the caller's
non-empty Host header value when present and request.host otherwise. A
caller-supplied Host is moved rather than duplicated, and Request.headers
itself is not reordered. Every HTTP/1 request writer (write_request!, the
transport's proxy-plan writer, the CONNECT tunnel request and the
WebSocket handshake) shares this preparation.

Fixes #1361

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@codecov

codecov Bot commented Sep 14, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 89.60%. Comparing base (dcf6428) to head (cea81c7).
⚠️ Report is 1 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1366      +/-   ##
==========================================
+ Coverage   89.51%   89.60%   +0.08%     
==========================================
  Files          31       31              
  Lines       12594    12635      +41     
==========================================
+ Hits        11274    11321      +47     
+ Misses       1320     1314       -6     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@quinnj
quinnj merged commit 469d421 into master Sep 17, 2026
10 checks passed
@quinnj
quinnj deleted the fix/h1-host-header-first branch September 17, 2026 14:18
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.

HTTP/1.1 client sends the Host header last; RFC 9112 §3.2 says it should be first

1 participant