Skip to content

http/2: reject a header field carrying CR, LF or NUL, and answer a malformed field with a 400 - #1297

Merged
pjfanning merged 1 commit into
apache:mainfrom
pjfanning:http2-reject-crlf-in-header-values
Sep 11, 2026
Merged

pjfanning merged 1 commit into
apache:mainfrom
pjfanning:http2-reject-crlf-in-header-values

Conversation

@pjfanning

Copy link
Copy Markdown
Member

Motivation

Two things go wrong when an HTTP/2 peer sends a malformed header field. Probed against main:

:path      "/a\r\nb"           → 400  Illegal http2-path-pseudo-header
:authority "a\r\nb"            → 400  Illegal http2-authority-pseudo-header
header value "foo\r\nx-b: bar" → 200  headers = [x-a=foo]          ← accepted, truncated
header value "foo\u0000bar"    → stage failure, connection gone   ← not a 400

Silent truncation. RequestParsing.parseHeaderPair reuses the HTTP/1.1 line parser by building name + ": " + value + "\r\nx" (RequestParsing.scala:220). The parser stops at the first CRLF it meets — which is now the peer's — and the header that comes back is whatever preceded it. RFC 9113 §8.2.1 says a field name or value carrying NUL, CR or LF MUST be treated as malformed. Not an injection (the tail is dropped, not added), but silent acceptance where the spec wants rejection.

Connection teardown. A field the HTTP/1.1 parser does reject — NUL in the value, an illegal character in the name, a value over max-header-value-length — is reported with that parser's own internal pekko.http.impl.engine.parsing.ParsingException. That is not the model pekko.http.scaladsl.model.ParsingException that HeaderDecompression catches (HeaderDecompression.scala:24), so it escapes the decompression stage and fails it, and with it the whole connection and every stream on it. The HTTP/2 engine answers other malformed fields with a 400 on the one stream — that was the point of #59. On a connection a proxy multiplexes for many users, one bad header from one of them takes the connection down for all of them.

Modification

  • Check the name and the value for CR, LF and NUL at the top of the HPACK listener, before the field is dispatched on its name, reusing HeaderCompression.hasIllegalChar from the rendering side (fix: drop HTTP/2 header fields containing CR, LF or NUL #1258). Neither is echoed in the error, since either may be what is malformed.
  • Widen the internal ParsingException from private[parsing] to private[http] and rethrow it from parseHeaderPair as the model exception, so that every failure the HTTP/1.1 parser reports for an HTTP/2 field takes the existing 400 path.

Result

A header field carrying CR, LF or NUL is answered with a 400 instead of being accepted truncated, and a field the HTTP/1.1 parser rejects is answered with a 400 on its own stream instead of failing the connection.

Tests

  • RequestParsingSpec: CR LF, bare LF and NUL in a value, CR LF in a name, and a value over max-header-value-length each produce a BadRequest with the expected summary.
  • Http2ServerSpec: the CR LF, NUL and over-long cases each get a 400 on their stream, and the next stream on the same connection is served — which is the part that fails today for the NUL and over-long cases.
  • Directional, in two steps: with both source changes reverted all eight fail; with only the CR/LF/NUL check in place the two over-long cases still fail, so the exception translation is covered on its own.
  • sbt "http2-tests/test" — 374 pass.
  • sbt "http-core/mimaReportBinaryIssues" — pass.
  • Native scalafmt --list --mode diff-ref=upstream/main — clean.

References

Refs #59

…lformed field with a 400

Motivation:
Two things go wrong when an HTTP/2 peer sends a malformed header field.

A regular field whose value contains CR LF is silently accepted with the
value truncated. `RequestParsing.parseHeaderPair` reuses the HTTP/1.1
line parser by building `name + ": " + value + "\r\nx"`, so the parser
stops at the first CRLF it meets, which is now the peer's, and the header
that comes back is whatever preceded it. RFC 9113 section 8.2.1 says a
field name or value carrying NUL, CR or LF must be treated as malformed.

A field the HTTP/1.1 parser does reject -- a NUL in the value, an illegal
character in the name, a value over `max-header-value-length` -- is
reported with that parser's own, internal `ParsingException`, which is
not the model `ParsingException` that `HeaderDecompression` catches. It
escapes the decompression stage and fails it, and with it the whole
connection and every stream on it, where the HTTP/2 engine answers other
malformed fields with a 400 on the one stream (apache#59). On a connection a
proxy multiplexes for many users, one bad header from one of them takes
the connection down for all of them.

Modification:
Check the name and the value for CR, LF and NUL at the top of the HPACK
listener, before the field is dispatched on its name, reusing the
predicate the rendering side uses. Neither is echoed in the error, since
either may be what is malformed. Widen the internal `ParsingException`
from `private[parsing]` to `private[http]` and rethrow it from
`parseHeaderPair` as the model exception, so that every failure the
HTTP/1.1 parser reports for an HTTP/2 field takes the 400 path.

Result:
A header field carrying CR, LF or NUL is answered with a 400 instead of
being accepted truncated, and a field the HTTP/1.1 parser rejects is
answered with a 400 on its own stream instead of failing the connection.

Tests:
- `RequestParsingSpec`: CR LF, bare LF and NUL in a value, CR LF in a
  name, and a value over `max-header-value-length` each produce a
  `BadRequest` with the expected summary.
- `Http2ServerSpec`: the CR LF, NUL and over-long cases each get a 400 on
  their stream and the next stream on the same connection is served.
- With both source changes reverted all eight fail; with only the CR/LF/
  NUL check in place the two over-long cases still fail, so the exception
  translation is covered on its own.
- sbt "http2-tests/test" - 374 pass.
- sbt "http-core/mimaReportBinaryIssues" - pass.
- native scalafmt clean.

References:
Refs apache#59
@pjfanning pjfanning added this to the 2.0.0-M2 milestone Sep 11, 2026
@pjfanning
pjfanning merged commit 40b07a2 into apache:main Sep 11, 2026
6 checks passed
pjfanning added a commit to pjfanning/incubator-pekko-http that referenced this pull request Sep 11, 2026
Motivation:
Nineteen commits landed on main after the model was pinned to `6740cbd`,
ten of which touch a claim it makes. One changes a triage line: apache#1194
documents `idle-timeout` as a bidirectional inactivity timeout that a
client sending bytes inside every window keeps alive by design, where §9
called such a connection an evasion and therefore in scope. Two others
defend a property the model never stated: that a malformed request on
one HTTP/2 stream is answered on that stream and leaves the connection,
its HPACK state and its other streams alone (apache#1252, apache#1297).

Checking the error path for apache#1246 also surfaced a shipped default the
model never mentioned: `error-logging-verbosity = full` writes the
failing request target into the log at warning level, independent of the
client-facing `verbose-error-messages = off` that P3 rests on.

Modification:
Re-pin to `40b07a2`. Add P9, HTTP/2 stream isolation, cited to
`RequestErrorFlow`, with a note recording the two paths around it that
review found and closed: a header that failed to parse unwound out of
the HPACK decoder before the dynamic table was updated, desynchronising
every later HEADERS frame on the connection (apache#1252, merged through
apache#1251); and a field the HTTP/1.1 header parser rejects was reported with
an exception type nothing on the HTTP/2 side caught, failing the
connection (apache#1297, which also closed the RFC 9113 8.2.1 gap of a CR LF
value being accepted silently truncated). Restate P5 and the §9
slow-loris bullet around inactivity rather than evasion, citing the new
`reference.conf` and `timeouts.md` wording, and record apache#1284's leak of
one scheduled task per open request as the in-scope shape. Add apache#1257 to
the §9 smuggling bullet beside apache#1267, and apache#1281 to the P1 note beside
apache#1259 as the third discard path. Add `max-part-count` (apache#1266) to §5a and
a multipart row to §6, which had no multipart input at all, noting
apache#1279's header-state bleed between parts. Add `error-logging-verbosity`
to §5a, a §9 false-friend entry explaining what `verbose-error-messages`
does not govern, and §10.9 recommending `simple` where logs are shipped
or alerted on. Add two §11 misuse patterns: building `Raw-Request-URI`
from request input, and echoing `IllegalRequestContext.rawRequestTarget`
unescaped. Turn §5b.4's two concrete examples into a running tally of
the defects review has found of that shape. Extend §15 to match.

Result:
Every claim is verified against `40b07a2`. Provenance is 23 documented /
33 maintainer / 0 inferred. The P1 frame-size gap stays open; apache#1264 has
not merged.

Tests:
Not run - docs only

References:
Refs apache#1194, apache#1246, apache#1251, apache#1252, apache#1257, apache#1266, apache#1279, apache#1280, apache#1281, apache#1284, apache#1297
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