Use empty binary, not nil, for static table entries with no default value - #27
Conversation
…alue
HPAX.Table's @static_table stored `nil` as the value for the many
static entries that have no default (RFC 7541, Appendix A - e.g.
:authority, host, user-agent). Decoding a plain Indexed Header Field
Representation (section 6.1) against one of these entries - fully
legal per the RFC, and something real HTTP/2 clients (and h2spec) do
- returned that literal `nil` as the header value, silently violating
this module's own documented contract that decoded values are always
binaries (HPAX.header_value() :: binary()).
Any caller relying on that contract (e.g. calling byte_size/1 on
every decoded value) would crash on such a request. This also meant
the encoder could never emit a compact single-byte indexed reference
when asked to encode one of these headers with a literal empty-string
value, since the {:full, index} match required by lookup_by_header/3
was guarded with `is_binary(value)` and so never fired for the
nil-valued entries.
Storing "" instead of nil fixes both: decoded values are always
binaries as documented, and encoding a header whose value happens to
be "" for one of these names now correctly compresses to one byte.
Covers the encoder-side half of the previous commit's fix: headers
whose value matches a static table entry with no defined default
(e.g. "user-agent": "") can now be found via a {:full, index} match
and encoded as a single indexed-header-field byte, rather than always
falling back to a literal encoding because the {:full, _} lookup
clause was guarded with is_binary(value) and so never matched the
old nil-valued entries.
|
This also stands ready for review, split out from #26. I've had the robots go over all of hpax's dependent libraries per hex.pm to see if any of them may require updates to this (since it's technically a change in external behaviour) and all came up clean, other than Bandit (which as of recently now checks for decoded header size, and was bailing on the nils that were emitted here as a result). |
|
Isn't this technically changing behaviour, though? I understand it was spec'ed without |
|
That's exactly why I broke it out into a separate PR. On the encode side this is a clear win, since we were never encoding down to a single byte for many static entries. On the decode side it's less clear; on the one hand I kinda like the "" representation since that's closer to what you'd actually parse out of headers on the wire, but it is a change. Up to you (obviously) which was you want to go here. I can easily fix Bandit to work well with either decode style |
|
I can't remember if HPACK can represent absence vs empty values; given the PR, I assume they're indistinguishable? |
HPAX has never (either with or without this PR) been able to encode headers with a nil value (ie: The first of the differences is fairly minor, having to do with encoding a header that's in the static table, in the particular case where its value is "". On main, we get the following: With this PR: They both roundtrip to the same thing, but HPAX as it stands today will create a new row in the table to store this row per RFC7541§6.2.1 (Literal Header Field with Incremental Indexing). This PR optimizes this case to instead encode it per RFC751§6.1 (Indexed Header Field Representation), saving a byte on the wire and not consuming a context entry. It's a tiny optimization, and not really worth it on its own. The second difference is the more impactful one. If a peer happens to send a static table entry with a "" value in the minimally encoded form (ie: per RFC751§6.1 (Indexed Header Field Representation), HPAX will decode it as follows: On main: With this PR: There's a bit of an asymmetry there - we currently don't accept Again, it's a fairly minor corner in practice, though it IS a legal one. In particular, h2spec will send such headers in its |
|
Ok, I’m on board with this change. Thank you so much for (patiently) walking me through the change and for the explanation 🫶 |
|
Thanks for the merge! Could I bug you to push a new version with this change? |
I had Claude check the code against various RFC conformance, and it flagged below. Claude helped with the desc. ## Problem Bandit's HTTP/2 request validation rejects uppercase regular field names and values containing CR, LF, or NUL. It does not reject several other field-name and field-value forms that RFC 9113 defines as malformed. Examples that can currently pass validation include: ```text bad name: value bad:name: value x-test: leading x-test: trailing<TAB> ``` Field-value validation is also applied only to regular fields, leaving pseudo-field values outside the shared validation step. ## RFC requirement [RFC 9110 section 5.1](https://www.rfc-editor.org/rfc/rfc9110.html#section-5.1) defines a field name as a non-empty `token`, using the grammar in [section 5.6.2](https://www.rfc-editor.org/rfc/rfc9110.html#section-5.6.2). [RFC 9110 section 5.5](https://www.rfc-editor.org/rfc/rfc9110.html#section-5.5) limits field values to visible ASCII or `obs-text`, with SP and HTAB allowed internally; other control bytes and DEL are outside the grammar. [RFC 9113 section 8.2.1](https://www.rfc-editor.org/rfc/rfc9113.html#section-8.2.1) adds HTTP/2-specific requirements and requires an endpoint to treat a message as malformed when: - A field name contains bytes `0x00` through `0x20`, uppercase ASCII, or bytes `0x7f` through `0xff`. - A regular field name contains a colon. - A field value contains NUL, CR, or LF. - A field value begins or ends with SP or HTAB. For a malformed request, [RFC 9113 section 8.1.1](https://www.rfc-editor.org/rfc/rfc9113.html#section-8.1.1) requires the server to reset the stream with `PROTOCOL_ERROR`. ## Implementation The HTTP/2 stream validator now: - Rejects empty names and every byte outside the RFC 9110 `token` grammar, including delimiters, controls, spaces, DEL, and non-ASCII bytes. - Preserves the existing, more specific uppercase-field diagnostic. - Rejects control bytes and DEL outside the RFC 9110 field-value grammar while retaining legal `obs-text` and internal SP or HTAB. - Rejects field values beginning or ending with SP or HTAB. - Applies field-value validation to pseudo-fields as well as regular fields. - Treats a field value that HPAX decodes as `nil` as the empty value it represents. HPAX decodes an indexed static-table entry with no value, such as a bare `user-agent` at index 58, to a nil value (elixir-mint/hpax#27). On current `main` such a request draws a 500 from an `ArgumentError` inside validation, and h2spec `generic/5/1` requires it to be accepted; regular fields are normalized to the empty binary before reaching the Plug. - Performs field-value checks before interpreting pseudo-field values such as `:authority`. The existing CR, LF, and NUL checks remain in place. ## Scope notes `Bandit.Headers.field_value_valid?/1` also backs HTTP/1 header validation, so HTTP/1 requests whose field values contain C0 control bytes other than HTAB, or DEL, are now rejected with 400 as well. RFC 9110 section 5.5 places those bytes outside the field-value grammar for every HTTP version; rejecting them is mandatory for HTTP/2 and permitted for HTTP/1. CR, LF, and NUL were already rejected on both protocols. The recursive byte-level validation replaces the cached compiled-pattern scan. Measured on typical short header values the recursive walk is faster than the previous `:binary.match/2` scan, and within a few percent on multi-kilobyte values, so the header hot path does not regress. ## Tests New protocol tests send HPACK-encoded requests containing: - Empty regular field names and names containing spaces, tabs, delimiters, DEL, or `0xff`. - Control bytes and DEL embedded in regular field values. - Leading and trailing SP in field values. - Leading and trailing HTAB in field values. - Invalid edge whitespace and control bytes in pseudo-field values. - An indexed static-table field with no value (`user-agent` at index 58), which must be accepted and observed by the Plug as an empty value. Each case asserts that Bandit sends `RST_STREAM` with `PROTOCOL_ERROR` while keeping the HTTP/2 connection usable. Verified against Bandit `main` at `b084b37` with Erlang/OTP 27.3 and Elixir 1.18.4: - `mix format --check-formatted` - `MIX_ENV=test mix compile --warnings-as-errors` - Full non-slow suite: 821 tests pass (the suite's one IPv6 server binding test needs an IPv6-capable host) - `mix credo --strict` and `mix dialyzer` pass on the combined tree of all nineteen prepared Bandit changes - h2spec 2.6.0 passes in full on the combined tree - HTTP/2 protocol suite: 184 tests pass
I had Claude check the code against various RFC conformance, and it flagged below. Claude helped with the desc. ## Summary Validate request trailers as an HTTP/2 field section and require the trailer HEADERS frame to carry `END_STREAM`. ## What was not conformant Bandit accepted a second request HEADERS frame without `END_STREAM`, logged and ignored its fields, and continued reading DATA. It also checked only for pseudo-headers in trailers, so the normal HTTP/2 rules for lowercase names, field values, connection-specific fields, and `TE` were not applied. [RFC 9113 section 8.1](https://www.rfc-editor.org/rfc/rfc9113.html#section-8.1) says a trailer field section must terminate the stream. It specifically requires `END_STREAM` on the terminating HEADERS frame and says a trailer section without it is a malformed request. [RFC 9113 section 8.2.1](https://www.rfc-editor.org/rfc/rfc9113.html#section-8.2.1), [RFC 9110 section 5.1](https://www.rfc-editor.org/rfc/rfc9110.html#section-5.1), and [RFC 9113 section 8.2.2](https://www.rfc-editor.org/rfc/rfc9113.html#section-8.2.2) define the field-name, field-value, and connection-specific field rules that apply to HTTP/2 field sections. ## Implementation - Reject trailer HEADERS unless the frame carries `END_STREAM`. - Continue rejecting pseudo-headers in trailers. - Apply Bandit's existing lowercase-name, field-value, connection-specific field, and `TE` validators to trailers before discarding them. - Enforce the full regular field-name token grammar and the HTTP/2 prohibition on edge SP or HTAB in trailer values. - Validate every member of every `TE` field line, while accepting repeated `trailers` members. - Match RFC 9113's exact connection-specific field list without rejecting unrelated extension fields by name. - Keep the existing behavior of accepting and ignoring a valid trailer section. - Treat a trailer field value that HPAX decodes as `nil` (an indexed static-table entry with no value) as the empty value it represents, instead of letting it crash validation (elixir-mint/hpax#27). This is intentionally a focused change. It does not add trailer exposure to Plug and does not alter valid request-body handling. ## Tests The protocol tests cover: - acceptance of a valid trailer section with `END_STREAM`, including an unrelated extension field named `trailers`; - rejection of a trailer section without `END_STREAM` with stream `PROTOCOL_ERROR`; - rejection of a connection-specific trailer field; - rejection when any of multiple `TE` field lines contains a value other than `trailers`; - rejection of invalid trailer field names; and - rejection of CR and edge whitespace in trailer field values; and - acceptance of a trailer field encoded as an indexed static-table entry with no value. Run: ```console mix test test/bandit/http2/protocol_test.exs ``` Verified against Bandit `main` at `b084b37` with Erlang/OTP 27.3 and Elixir 1.18.4: - `mix format --check-formatted` - `MIX_ENV=test mix compile --warnings-as-errors` - Full non-slow suite: 821 tests pass (the suite's one IPv6 server binding test needs an IPv6-capable host) - `mix credo --strict` and `mix dialyzer` pass on the combined tree of all nineteen prepared Bandit changes - h2spec 2.6.0 passes in full on the combined tree - HTTP/2 protocol suite: 184 tests pass
I had Claude check the code against various RFC conformance, and it flagged below. Claude helped with the desc. ## Problem Bandit's HTTP/2 request validation rejects uppercase regular field names and values containing CR, LF, or NUL. It does not reject several other field-name and field-value forms that RFC 9113 defines as malformed. Examples that can currently pass validation include: ```text bad name: value bad:name: value x-test: leading x-test: trailing<TAB> ``` Field-value validation is also applied only to regular fields, leaving pseudo-field values outside the shared validation step. ## RFC requirement [RFC 9110 section 5.1](https://www.rfc-editor.org/rfc/rfc9110.html#section-5.1) defines a field name as a non-empty `token`, using the grammar in [section 5.6.2](https://www.rfc-editor.org/rfc/rfc9110.html#section-5.6.2). [RFC 9110 section 5.5](https://www.rfc-editor.org/rfc/rfc9110.html#section-5.5) limits field values to visible ASCII or `obs-text`, with SP and HTAB allowed internally; other control bytes and DEL are outside the grammar. [RFC 9113 section 8.2.1](https://www.rfc-editor.org/rfc/rfc9113.html#section-8.2.1) adds HTTP/2-specific requirements and requires an endpoint to treat a message as malformed when: - A field name contains bytes `0x00` through `0x20`, uppercase ASCII, or bytes `0x7f` through `0xff`. - A regular field name contains a colon. - A field value contains NUL, CR, or LF. - A field value begins or ends with SP or HTAB. For a malformed request, [RFC 9113 section 8.1.1](https://www.rfc-editor.org/rfc/rfc9113.html#section-8.1.1) requires the server to reset the stream with `PROTOCOL_ERROR`. ## Implementation The HTTP/2 stream validator now: - Rejects empty names and every byte outside the RFC 9110 `token` grammar, including delimiters, controls, spaces, DEL, and non-ASCII bytes. - Preserves the existing, more specific uppercase-field diagnostic. - Rejects control bytes and DEL outside the RFC 9110 field-value grammar while retaining legal `obs-text` and internal SP or HTAB. - Rejects field values beginning or ending with SP or HTAB. - Applies field-value validation to pseudo-fields as well as regular fields. - Treats a field value that HPAX decodes as `nil` as the empty value it represents. HPAX decodes an indexed static-table entry with no value, such as a bare `user-agent` at index 58, to a nil value (elixir-mint/hpax#27). On current `main` such a request draws a 500 from an `ArgumentError` inside validation, and h2spec `generic/5/1` requires it to be accepted; regular fields are normalized to the empty binary before reaching the Plug. - Performs field-value checks before interpreting pseudo-field values such as `:authority`. The existing CR, LF, and NUL checks remain in place. ## Scope notes `Bandit.Headers.field_value_valid?/1` also backs HTTP/1 header validation, so HTTP/1 requests whose field values contain C0 control bytes other than HTAB, or DEL, are now rejected with 400 as well. RFC 9110 section 5.5 places those bytes outside the field-value grammar for every HTTP version; rejecting them is mandatory for HTTP/2 and permitted for HTTP/1. CR, LF, and NUL were already rejected on both protocols. The recursive byte-level validation replaces the cached compiled-pattern scan. Measured on typical short header values the recursive walk is faster than the previous `:binary.match/2` scan, and within a few percent on multi-kilobyte values, so the header hot path does not regress. ## Tests New protocol tests send HPACK-encoded requests containing: - Empty regular field names and names containing spaces, tabs, delimiters, DEL, or `0xff`. - Control bytes and DEL embedded in regular field values. - Leading and trailing SP in field values. - Leading and trailing HTAB in field values. - Invalid edge whitespace and control bytes in pseudo-field values. - An indexed static-table field with no value (`user-agent` at index 58), which must be accepted and observed by the Plug as an empty value. Each case asserts that Bandit sends `RST_STREAM` with `PROTOCOL_ERROR` while keeping the HTTP/2 connection usable. Verified against Bandit `main` at `b084b37` with Erlang/OTP 27.3 and Elixir 1.18.4: - `mix format --check-formatted` - `MIX_ENV=test mix compile --warnings-as-errors` - Full non-slow suite: 821 tests pass (the suite's one IPv6 server binding test needs an IPv6-capable host) - `mix credo --strict` and `mix dialyzer` pass on the combined tree of all nineteen prepared Bandit changes - h2spec 2.6.0 passes in full on the combined tree - HTTP/2 protocol suite: 184 tests pass
) I had Claude check the code against various RFC conformance, and it flagged below. Claude helped with the desc. ## Summary Validate request trailers as an HTTP/2 field section and require the trailer HEADERS frame to carry `END_STREAM`. ## What was not conformant Bandit accepted a second request HEADERS frame without `END_STREAM`, logged and ignored its fields, and continued reading DATA. It also checked only for pseudo-headers in trailers, so the normal HTTP/2 rules for lowercase names, field values, connection-specific fields, and `TE` were not applied. [RFC 9113 section 8.1](https://www.rfc-editor.org/rfc/rfc9113.html#section-8.1) says a trailer field section must terminate the stream. It specifically requires `END_STREAM` on the terminating HEADERS frame and says a trailer section without it is a malformed request. [RFC 9113 section 8.2.1](https://www.rfc-editor.org/rfc/rfc9113.html#section-8.2.1), [RFC 9110 section 5.1](https://www.rfc-editor.org/rfc/rfc9110.html#section-5.1), and [RFC 9113 section 8.2.2](https://www.rfc-editor.org/rfc/rfc9113.html#section-8.2.2) define the field-name, field-value, and connection-specific field rules that apply to HTTP/2 field sections. ## Implementation - Reject trailer HEADERS unless the frame carries `END_STREAM`. - Continue rejecting pseudo-headers in trailers. - Apply Bandit's existing lowercase-name, field-value, connection-specific field, and `TE` validators to trailers before discarding them. - Enforce the full regular field-name token grammar and the HTTP/2 prohibition on edge SP or HTAB in trailer values. - Validate every member of every `TE` field line, while accepting repeated `trailers` members. - Match RFC 9113's exact connection-specific field list without rejecting unrelated extension fields by name. - Keep the existing behavior of accepting and ignoring a valid trailer section. - Treat a trailer field value that HPAX decodes as `nil` (an indexed static-table entry with no value) as the empty value it represents, instead of letting it crash validation (elixir-mint/hpax#27). This is intentionally a focused change. It does not add trailer exposure to Plug and does not alter valid request-body handling. ## Tests The protocol tests cover: - acceptance of a valid trailer section with `END_STREAM`, including an unrelated extension field named `trailers`; - rejection of a trailer section without `END_STREAM` with stream `PROTOCOL_ERROR`; - rejection of a connection-specific trailer field; - rejection when any of multiple `TE` field lines contains a value other than `trailers`; - rejection of invalid trailer field names; and - rejection of CR and edge whitespace in trailer field values; and - acceptance of a trailer field encoded as an indexed static-table entry with no value. Run: ```console mix test test/bandit/http2/protocol_test.exs ``` Verified against Bandit `main` at `b084b37` with Erlang/OTP 27.3 and Elixir 1.18.4: - `mix format --check-formatted` - `MIX_ENV=test mix compile --warnings-as-errors` - Full non-slow suite: 821 tests pass (the suite's one IPv6 server binding test needs an IPv6-capable host) - `mix credo --strict` and `mix dialyzer` pass on the combined tree of all nineteen prepared Bandit changes - h2spec 2.6.0 passes in full on the combined tree - HTTP/2 protocol suite: 184 tests pass
Summary
HPAX.Table's@static_tablestoresnilas the value for the many static entries with no default (RFC 7541, Appendix A — e.g.:authority,host,user-agent). Decoding a plain Indexed Header Field Representation (RFC 7541 §6.1) against one of these — fully legal, and something real clients (and h2spec) do — returns that literalnil, silently violating this module's own documented contract that decoded values are always binaries (HPAX.header_value() :: binary()):Any caller relying on that documented contract (e.g. calling
byte_size/1on every decoded value) crashes on such a request. It also meant the encoder could never emit a compact single-byte indexed reference when asked to encode one of these headers with a literal""value, since the{:full, index}match inlookup_by_header/3is guarded withis_binary(value)and never fired for the nil-valued entries.Fix
Store
""instead ofnilfor these static table entries. Decoded values are now always binaries as documented, and encoding a header whose value is""for one of these names now correctly compresses to one byte.Test plan
""notnil""value now compresses to a single bytenil-based behavior