Skip to content

Reject malformed HTTP versions with 400, unsupported ones with 505 - #79

Merged
MDA2AV merged 1 commit into
mainfrom
fix/71-version-status
Nov 3, 2026
Merged

MDA2AV merged 1 commit into
mainfrom
fix/71-version-status

Conversation

@MDA2AV

@MDA2AV MDA2AV commented Nov 2, 2026 •

Copy link
Copy Markdown
Member

Fixes #71

Why

Since #55 every version the parser does not accept throws 505, whether it is HTTP/2.0, http/1.1 or HTTP/1.x. 505 (RFC 9110 §15.6.6) means "I understood the version and do not implement it". For bytes that are not an HTTP-version at all, that is the wrong answer: the request line is malformed, which is a 400.

The issue proposes one rule for both implementations. The C core and the bindings are no longer supported, so its C half is moot. This PR applies the rule to the managed parser.

What changes

  • Request line and status line: a version that matches the grammar ("HTTP/" DIGIT "." DIGIT, exactly 8 case-sensitive bytes, RFC 9112 §2.3) but is not 1.0/1.1 throws 505 Unsupported HTTP version.. Anything else in that position throws 400 Invalid HTTP version..
  • The grammar check is a new internal helper, ParserConstants.IsWellFormedHttpVersion, next to IsValidHttpVersion. The public API does not grow.
  • HttpParseException.IsLimitViolation was StatusCode != 400, so since Yield error 505 on unsupported HTTP version #55 it reported every version error as a limit breach. It now means exactly StatusCode == 431. The StatusCode doc lists the three codes in use.
  • SECURITY.md "HTTP Version Validation" now describes what is accepted and which code each kind of rejection gets. The RFC reference moves from §2.6, an RFC 7230 leftover, to RFC 9112 §2.3, here and in the IsValidHttpVersion doc.

Decisions worth reviewing

  • Higher minor versions (HTTP/1.2) stay rejected, now as 505. RFC 9110 §2.5 says a recipient SHOULD process a higher minor version as the highest one it implements. The parser has always refused anything but 1.0/1.1, and this PR keeps that policy and only changes the code it reports. Accepting 1.x as 1.1 would be a separate change.

  • The status line gets the same rule. For a client, the code is a classification rather than something to send back. The response parser already reported 505, so this keeps both directions consistent.

  • IsLimitViolation now means 431 and nothing else. A caller that builds its own HttpParseException with some other non-400 code would see it flip to false. The library itself throws only 400, 431 and 505.

  • http-probe agrees with the split. COMP-VERSION-MISSING-MINOR (GET / HTTP/1) expects exactly 400, which the parser fails today with 505. RFC9112-2.3-INVALID-VERSION (HTTP/9.9) accepts 400 or 505, and COMP-HTTP12-VERSION (HTTP/1.2) accepts 505. Both are unchanged.

  • Downstream: GenHTTP pins the current behaviour in two tests.

    • ComplianceTests.TestNoMinorHttpVersion expects 505 for HTTP/1. With this PR that request gets 400.
    • WireTests.TestUnsupportedProtocolVersion matches the message Invalid HTTP version for HTTP/2.0. With this PR the message for that case is Unsupported HTTP version..
    • chore(engines): Update Glyph11 to 0.4.0 and drop Glyph11.Pico Kaliumhexacyanoferrat/GenHTTP#973, the GenHTTP update to 0.4.0, adapts both tests. With its test helper changed to actually forward the engine, HTTP/1 gets 400 on Kestrel and Ioxide too.

Overlap with sibling PRs

Tests

  • New tests/Tests/UltraHardenedParser.VersionStatus.cs, 55 cases. Requests and responses, single- and multi-segment:
    • HTTP/2.0, HTTP/1.2, HTTP/0.9, HTTP/3.0 throw 505.
    • http/1.1, HTTP/1.x, HTTP1.1, HTTP/11, HTTP/1.1x, HTTP/1., HTTP/ throw 400.
    • HTTP/1.0 and HTTP/1.1 still parse.
    • IsLimitViolation is false for 400 and 505 and true for 431.
  • No existing test needed changing. The only one asserting 505 uses HTTP/9.9, which is well-formed and stays 505.
  • dotnet test -f net10.0: 458 passed (403 before), 0 failed. dotnet build src/Glyph11.sln builds clean, with no new warnings.
  • core/diff (not maintained, run for information): divergences drop from 865 to 45 per 200k inputs. All 45 are well-formed unsupported versions, which the C core still reports as 400.

Since #55 every version the parser does not accept throws 505, whether
it is HTTP/2.0 or "http/1.1" or "HTTP/1.x". 505 (RFC 9110 15.6.6) says
"I understood the version you asked for and do not implement it"; for
bytes that are not an HTTP-version at all that is the wrong answer - the
request line is simply malformed, which is a 400.

Split the two on the request line and, symmetrically, on the status
line: a version that matches the grammar ("HTTP/" DIGIT "." DIGIT,
exactly 8 case-sensitive bytes, RFC 9112 2.3) but is not 1.0/1.1 is 505
"Unsupported HTTP version."; anything else in that position is 400
"Invalid HTTP version.". The grammar check is an internal helper next
to IsValidHttpVersion, so the public surface does not grow.

HttpParseException.IsLimitViolation was `StatusCode != 400`, so since
#55 it has reported every version error as a limit breach. It now means
exactly 431, and the StatusCode doc lists the three codes in use.

Fixes #71.
@MDA2AV
MDA2AV merged commit 9135726 into main Nov 3, 2026
1 of 2 checks passed
Kaliumhexacyanoferrat pushed a commit to Kaliumhexacyanoferrat/GenHTTP that referenced this pull request Nov 4, 2026
* Update Glyph11 to 0.4.0 and parse with TryParseRequestHeader

Glyph11 0.4.0 marks TryExtractFullHeaderValidated and TryExtractFullHeaderROM
[Obsolete]: they report one less than the length of the header block, which
is why every call site here sliced at bytesRead + 1. TryParseRequestHeader
reports the length itself, so the body starts at consumed.

With TreatWarningsAsErrors the update does not build without the move, and the
rename and the dropped + 1 have to land together - renaming alone would skip
the first byte of every body or pipelined request. Both engines, the body
lifecycle tests and the benchmark context move. Glyph11.Pico stays at 0.0.1:
PicoParser.TryParse keeps its convention, so the Pico path keeps its + 1.

Closes #972.

* Expect 400 for a malformed HTTP version, 505 for an unsupported one

Glyph11 0.4.0 (dotnet-web-stack/Glyph11#79) splits what it used to report
as one 505: a version that matches HTTP-version ("HTTP/" DIGIT "." DIGIT)
but is not 1.0/1.1 stays a 505, "Unsupported HTTP version.", while bytes
that are not an HTTP-version at all make the request line invalid, a 400
(RFC 9112 2.3 and 3).

"GET / HTTP/1" has no minor version, so it is malformed rather than
unsupported, which is also what http-probe's COMP-VERSION-MISSING-MINOR
expects. HTTP/2.0 keeps its 505, under the new message.

* Expect a truncated chunked body to hand out its data before failing

Glyph11 0.4.0 (dotnet-web-stack/Glyph11#83) hands chunk data out as it
arrives instead of waiting for the whole chunk, so a peer that declares a
huge chunk can no longer make the connection buffer it. For "5\r\nHel" the
first read now returns the three bytes that did arrive, and the truncation
surfaces as the 400 ProviderException on the next read - the point where
the body actually ends early.

* Drop the Glyph11.Pico dependency

Glyph11 discontinues Glyph11.Pico and Glyph11.Native
(dotnet-web-stack/Glyph11#84): they get no further releases. The Ioxide
engine's GENHTTP_IOXIDE_PARSER=pico switch goes with the package, so
requests are always parsed by UltraHardenedParser, and the body
lifecycle tests that need unvalidated framing headers get them from
Glyph11's FlexibleParser instead of picohttpparser.
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.

Bad HTTP-version maps to 505 in C# but 400 in the C core

1 participant