You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
— contains no CRLFCRLF, returns false, and is reported as incomplete. The caller waits for a terminator that is never coming: until its own timeout, or until the receive buffer hits its ceiling and the request fails for an unrelated-sounding reason.
I hit this writing tests for ioxide.httpclient, which now parses responses with Glyph11. The test asserted "malformed response is refused" and instead the client hung until the harness timed it out.
The part that makes it a bug rather than a policy choice
The parser already rejects bare LF:
// FullHeader.ROM.cs:53 and :190, FullResponse.ROM.cs:55 and :142thrownewHttpParseException("Bare LF detected; only CRLF line endings are allowed.");
But those checks run after a CRLFCRLF block has been located. So:
message
outcome
CRLF throughout
parsed
CRLF block containing one bare LF
rejected
bare LF throughout
incomplete, forever
The strictness is unreachable for exactly the messages it is aimed at. A wholly LF-terminated message — the legacy case, and the one a naive or embedded origin actually produces — never reaches the rule. Only a mixed message does.
Independent of any leniency decision, false is the wrong answer here: false means "give me more bytes", and more bytes will not help. A terminated-but-malformed block should be rejected, not awaited. As it stands a peer can hold a client's connection and buffer open by sending a complete, syntactically-recognisable response that this parser will never call complete.
The leniency question
RFC 9112 §2.2 explicitly permits what is currently impossible:
Although the line terminator for the start-line and fields is the sequence CRLF, a recipient MAY recognize a single LF as a line terminator and ignore any preceding CR.
So accepting \n is not a deviation, it is a sanctioned recipient behaviour — and for some callers it is the right one. The threat models differ by role:
A server parsing requests from anyone, behind or in front of other HTTP implementations wants strict. Bare LF is a smuggling primitive precisely because implementations disagree about it.
A client parsing a response from an origin it chose, with no intermediary, has a much weaker case for refusing a message it can unambiguously understand.
If leniency is added, one constraint matters more than the rest: it must be uniform within a message, never mixed. The attack is not "LF exists", it is "two parsers disagree about where a line ended" — which is only reachable when CRLF and bare LF appear in the same block. Accepting LF everywhere is unambiguous; accepting it sometimes is the vulnerability.
Suggested shape
Fix the framing independently of policy. Find the block end as the earlier of CRLFCRLF and LFLF. If it ends with bare LFs, that is a decision (reject, or accept under 2) — never "incomplete". This alone removes the hang.
Add an opt-in, e.g. ParserLimits.AllowBareLf (default false, preserving today's behaviour for the strict path). When enabled, accept LF as a terminator uniformly and still reject a block that mixes both.
Related: #57 (C core has no response parser and the diff harness is request-only), so whatever lands here needs C-side coverage before the two implementations can drift on it too.
What happens
A header block is terminated by exactly one thing:
So a message that uses bare LF throughout —
— contains no
CRLFCRLF, returnsfalse, and is reported as incomplete. The caller waits for a terminator that is never coming: until its own timeout, or until the receive buffer hits its ceiling and the request fails for an unrelated-sounding reason.I hit this writing tests for
ioxide.httpclient, which now parses responses with Glyph11. The test asserted "malformed response is refused" and instead the client hung until the harness timed it out.The part that makes it a bug rather than a policy choice
The parser already rejects bare LF:
But those checks run after a
CRLFCRLFblock has been located. So:The strictness is unreachable for exactly the messages it is aimed at. A wholly LF-terminated message — the legacy case, and the one a naive or embedded origin actually produces — never reaches the rule. Only a mixed message does.
Independent of any leniency decision,
falseis the wrong answer here:falsemeans "give me more bytes", and more bytes will not help. A terminated-but-malformed block should be rejected, not awaited. As it stands a peer can hold a client's connection and buffer open by sending a complete, syntactically-recognisable response that this parser will never call complete.The leniency question
RFC 9112 §2.2 explicitly permits what is currently impossible:
So accepting
\nis not a deviation, it is a sanctioned recipient behaviour — and for some callers it is the right one. The threat models differ by role:If leniency is added, one constraint matters more than the rest: it must be uniform within a message, never mixed. The attack is not "LF exists", it is "two parsers disagree about where a line ended" — which is only reachable when CRLF and bare LF appear in the same block. Accepting LF everywhere is unambiguous; accepting it sometimes is the vulnerability.
Suggested shape
CRLFCRLFandLFLF. If it ends with bare LFs, that is a decision (reject, or accept under 2) — never "incomplete". This alone removes the hang.ParserLimits.AllowBareLf(defaultfalse, preserving today's behaviour for the strict path). When enabled, accept LF as a terminator uniformly and still reject a block that mixes both.Related: #57 (C core has no response parser and the diff harness is request-only), so whatever lands here needs C-side coverage before the two implementations can drift on it too.