Skip to content

Validate and normalize HTTP/1 status lines and header fields - #513

Open
ericmj wants to merge 13 commits into
mainfrom
http1-header-parsing
Open

ericmj wants to merge 13 commits into
mainfrom
http1-header-parsing

Conversation

@ericmj

@ericmj ericmj commented Sep 24, 2026 •

Copy link
Copy Markdown
Member

Fixes to how Mint.HTTP1 parses the status line and header fields. Each commit is one fix with its own tests, so this is meant to be rebase-merged rather than squashed.

  • Trim only SP and HTAB from Content-Length values. String.trim_trailing/1 also stripped Unicode whitespace such as VT, FF or NBSP (RFC 9110 搂8.6).
  • Replace obsolete line folding in header and trailer values with a space instead of delivering the CRLF and leading whitespace (RFC 9112 搂5.2).
  • Validate the status line. :erlang.decode_packet/3 accepts any major version, two- or four-digit status codes and control bytes in the reason phrase; only HTTP/1.x with a single-digit minor version, three-digit codes and reason phrases of HTAB, SP, VCHAR and obs-text are accepted now.
  • Parse the status line directly against the RFC 9112 grammar in a single pass instead of with :erlang.decode_packet/3, which also accepted leading zeros (HTTP/01.1, HTTP/1.01, 0200) that it returns as plain integers. Whitespace after the separating space now stays in the reason phrase, as the grammar requires.
  • Reject header and trailer values containing control characters with :invalid_header / :invalid_trailer_header, the same check the request encoder applies to outgoing values (RFC 9110 搂5.5).
  • Reject header and trailer lines with an empty field name, which used to be delivered as {"", value} (RFC 9110 搂5.1).
  • Trim trailing whitespace from every header and trailer value, not only Content-Length (RFC 9112 搂5.1).
  • Drop the leading whitespace an obs-fold at the start of a value leaves behind, and replace folds with :stream_headers too when the continuation line arrives together with its header. A continuation line that arrives after its header was emitted is still rejected. Whitespace before the line break is replaced along with it, since obs-fold is OWS CRLF RWS. The :stream_headers docs now describe this.
  • Parse header and trailer lines in one pass instead of with :erlang.decode_packet/3 followed by separate scans for obs-folds, whitespace and invalid bytes. The parser reads 7 bytes at a time as an integer and uses bit tricks (SWAR) to find the colon and the line end while validating the name and value. It handles bare LF line endings and obs-folds, and removes the sentinel byte :stream_headers needed to make decode_packet/3 emit a header at the end of the data.

Benchmarks, compared with the same validation done after :erlang.decode_packet/3. Each figure is the median of five shuffled rounds, each timing many calls in a fresh process after a warm-up (Elixir 1.20.4, OTP 29, Apple M5 Max).

Status line parsing, 2M calls per round after 200k warm-up calls:

Status line decode_packet + checks This PR
HTTP/1.1 200 OK (17 B) 54 ns 28 ns
HTTP/1.1 404 Not Found (24 B) 65 ns 47 ns
55-byte reason phrase (69 B) 151 ns 205 ns

The reason phrase is validated one byte at a time, so the native version is faster on unusually long reason phrases.

Header block parsing, one header line at a time until the end of the section:

Header block decode_packet + checks This PR
2 headers (41 B) 672 ns 311 ns
10 headers (347 B) 3.71 碌s 1.53 碌s
40 headers (3.7 KB) 23.3 碌s 9.37 碌s
101 headers (28.6 KB) 91.5 碌s 30.4 碌s

The SWAR header parser is by @wojtekmach.

Every commit's tests were run without its fix applied and fail there.

String.trim_trailing/1 also strips Unicode whitespace, so values such as
"5" followed by VT, FF, NBSP, NEL or U+3000 were accepted. RFC 9110
section 8.6 allows only digits, with optional whitespace around the field
value.
Folded header and trailer values were delivered with the CRLF and leading
whitespace intact. RFC 9112 section 5.2 requires user agents to replace
each obs-fold with a space before interpreting the value. With
:stream_headers a header is emitted before its continuation line can be
seen, so folds are rejected with :invalid_header in that mode.
:erlang.decode_packet/3 accepts any major version, status codes with two or
four digits, and reason phrases containing control bytes. Only HTTP/1.x
with a single-digit minor version, three-digit status codes, and reason
phrases made of HTAB, SP, VCHAR and obs-text are accepted.
Values with CR, NUL, DEL or other control bytes were delivered verbatim
in headers and trailers. RFC 9110 5.5 allows only HTAB, SP, VCHAR and
obs-text in a field value and requires recipients to reject or replace
CR, LF and NUL. The response now fails with :invalid_header or
:invalid_trailer_header, the same check the request encoder applies to
outgoing values.
:erlang.decode_packet/3 accepts a line such as ": bar" and returns an
empty name, which was delivered to callers as {"", "bar"}. RFC 9110 5.1
defines field-name as a token of at least one character.
RFC 9112 5.1 excludes optional whitespace after a field value from the
value itself. :erlang.decode_packet/3 strips the leading whitespace but
keeps the trailing one, so values such as "bar   " reached callers with
the whitespace and only Content-Length trimmed it. Trim every header and
trailer value in one place and make the Content-Length parser accept
digits only.
An obs-fold at the start of a header value ("Foo:" followed by a
continuation line) was replaced by a space that stayed in the delivered
value. RFC 9112 5.1 excludes the optional whitespace around a value, so
leading whitespace is now trimmed like trailing whitespace already was.

With the :stream_headers option a folded value was rejected even when
the whole header section arrived in one message, while the default mode
replaced the fold. The replacement now applies in both modes; a
continuation line that arrives after its header was emitted is still
rejected.
@coveralls

coveralls commented Sep 24, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 3

Coverage increased (+0.5%) to 89.226%

Details

  • Coverage increased (+0.5%) from the base build.
  • Patch coverage: 3 uncovered changes across 1 file (83 of 86 lines covered, 96.51%).
  • No coverage regressions found.

Uncovered Changes

File Changed Covered %
lib/mint/http1/response.ex 77 74 96.1%
Total (3 files) 86 83 96.51%

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 1847
Covered Lines: 1648
Line Coverage: 89.23%
Coverage Strength: 661.21 hits per line

馃挍 - Coveralls

:erlang.decode_packet/3 returns the version and status code as integers,
so "HTTP/01.1", "HTTP/1.01" and a status code of "0200" were accepted as
HTTP/1.1 and 200. RFC 9112 defines the version as "HTTP/" DIGIT "." DIGIT
and the status code as three digits, so the raw status line is checked
as well.
RFC 9112 5.2 defines obs-fold as OWS CRLF RWS, but only the line break
and the whitespace after it were replaced, so "one\t\r\n two" was
delivered as "one\t two" instead of "one two".
decode_packet/3 accepts status lines that RFC 9112 doesn't, and returns
the version and status code as integers, so the raw line had to be
checked again after it. The status line is now matched directly against
the RFC 9112 grammar in one pass. The only difference in accepted lines
is that the reason phrase keeps whitespace after the separating space,
as the grammar requires, instead of having it stripped.
@ericmj
ericmj marked this pull request as ready for review September 24, 2026 11:57
ericmj and others added 2 commits September 24, 2026 14:02
Splitting the line on LF with :binary.split/2 and validating the reason
phrase afterwards was slower than :erlang.decode_packet/3 with the same
validation. The reason phrase is now validated while looking for the
end of the line.
Header values were decoded with decode_packet/3 and then scanned again to
replace obs-folds, trim whitespace and validate the name and value bytes.
Header lines are now parsed in one pass that reads 7 bytes at a time as an
integer and uses bit tricks to find the colon and the line end while
validating the name and value. Bare LF line endings and obs-folds are
handled by the parser, so the sentinel byte :stream_headers appended to
make decode_packet/3 emit a header at the end of the data isn't needed.

Co-authored-by: Wojtek Mach <wojtekmach@users.noreply.github.com>
@ericmj
ericmj marked this pull request as draft September 24, 2026 12:09
@ericmj
ericmj marked this pull request as ready for review September 24, 2026 12:17
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