Conversation
The inbound pipeline runs _reject_empty_header_names before _reject_pseudo_header_fields, so an empty name is caught before the `header[0][0]` lookup. The outbound pipeline has no such guard, so sending a header block with an empty name raises IndexError from utilities.py:335 instead of ProtocolError. Add the guard to validate_outbound_headers in the same position. The message differs by direction, so the body is shared by _validate_nonempty_header_names, mirroring how _check_host_authority_header and _check_sent_host_authority_header already share _validate_host_authority_header.
Use a single generator for both directions, with a direction-neutral message matching the style of the other checks that run on both paths. Update the inbound test to the new wording.
|
Checked this against RFC 9113 section 8.2.1 directly rather than only through the added tests, and the rule is right. One sequencing note at the end. Name rule, all 256 byte values. I drove the new name pass over every byte as the name The pseudo-header case, which is the one most likely to break here. Applying an inbound-only rule to outbound headers would be a problem if it rejected the leading colon, since every outbound request needs End-to-end, the two paths now agree. With a complete valid request header set and one header under test, Suite and coverage. One thing to sort out: this conflicts with your #1325. Applying #1325 on top of this branch conflicts in both
Nothing wrong with either change on its own — #1325 is a clean consolidation and this is a clean addition — but as they stand one of them will need a rebase at merge time. Deciding which lands first, or folding them into one PR, would avoid that. Flagging it because the two are yours and the pairing is not obvious from either PR on its own. |
Split _reject_illegal_characters into a name half and a value half, and run the name half from validate_outbound_headers as well. The four name classes from RFC 9113 section 8.2.1 were checked on the inbound path only. Classes 2, 3 and 4 went out on the wire untouched; class 1 was normalised away by _lowercase_header_names with defaults, but not with normalize_outbound_headers=False. The value rules (NUL/LF/CR and surrounding whitespace) stay inbound-only: _strip_surrounding_whitespace already normalises the whitespace case on the outbound path. "Received uppercase header name" is now direction-neutral, since the check runs both ways. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
55bd70a to
d9792fa
Compare
|
Thanks for running the full byte range and the two-path comparison. That is a stronger check than the tests alone give, and it matches what I see on the branch. On sequencing: #1325 lands first, since Kriechi has already read it. I have rebased this branch onto #1325, so the two now stack; the diff here carries #1325's two commits until it merges, after which I rebase onto master. Only src/h2/utilities.py conflicts, since both branches add one pass at the head of validate_outbound_headers. tests/test_invalid_headers.py merges cleanly, because the two test blocks land in different places. The resolution chains the two passes, name characters first, then empty names. A name with no bytes passes the character rule, so no name fails both and the order changes no verdict. Full suite on the stack: 1694 passed. |
|
Thanks — and I checked the disjointness argument rather than just accepting it, because it's the thing the whole resolution rests on. It holds, and there's a stronger reason underneath it. The per-name claim is correct. I ran both predicates over 30 names (empty, uppercase, mixed case, space/tab/newline/NUL/0x7f/0x80/0xff, leading and interior colons, The reason is structural: every branch of But the order doesn't matter for a stronger reason than that, which is worth knowing when you rebase. The two passes are lazily chained generators, not two sequential loops. Same message both ways in every case. So your resolution is safe by construction, and the per-name disjointness argument — correct as it is — isn't actually load-bearing. That's a small reassurance for the rebase: the conflict in One asymmetry I checked while I was in there, since it's the kind of thing that bites later: the colon search uses Glad the byte-range run was useful. I'll leave the sequencing to you and Kriechi. |
Stacked on #1325: the first two commits here (4aa3d90, ae42b9a) are #1325's. Rebase onto master follows once #1325 merges.
Follow-up to #1325, as discussed there in #1325 (comment).
Cause
_reject_illegal_charactersis called fromvalidate_headersbut not fromvalidate_outbound_headers. Once #1325 lands it is the only check in the inbound chain with no outbound counterpart.It could not simply be added to the outbound chain, because it carries two value rules in addition to the four name classes of RFC 9113 section 8.2.1.
Change
_reject_illegal_charactersis split into_reject_illegal_name_characters(uppercase 0x41-0x5a,<= 0x20,>= 0x7f, colon after position 0) and_reject_illegal_value_characters(NUL/LF/CR, surrounding SP/HTAB).validate_headerscalls both in the previous order, so inbound behaviour is unchanged.validate_outbound_headerscalls the name half only, first in the chain, mirroring the inbound order.The value rules deliberately stay inbound-only:
_strip_surrounding_whitespacealready normalises the whitespace case before outbound validation, so enabling that half would only change behaviour fornormalize_outbound_headers=False.The uppercase message is made direction-neutral (
Uppercase header name present: ...) because the check now runs both ways; this also changes the inbound message.Received header value surrounded by whitespaceis left alone, as that rule is still inbound-only.Measured
Through
send_headers, decoding what reaches the wire:X-Foo(class 1)x-fooX-Foox-foofoo bar(class 2)foo\x7f(class 3)foo:bar(class 4)src/h2/utilities.pyis +21/-5. Seven of the eleven added tests fail against the unmodified file; the other four are regression guards that pass either way. Full suite 1673 passed,ruff check src/andmypy --strict-bytesclean.Note
This touches
validate_outbound_headersinutilities.py, the same function as #1325. Whichever lands second, I will rebase it.🤖 Generated with Claude Code