fix: reject control characters in header names and values, harden transport header adaptation#140
Merged
Merged
Conversation
Headers.Builder validated header values for CR/LF but never validated header names: name handling only lower-cased and trimmed surrounding whitespace, so an interior \r, \n, NUL, or other control character survived into the model layer. Downstream the two reference transports diverged on such a name — the OkHttp transport threw an unchecked IllegalArgumentException out of execute() (escaping its IOException contract and bypassing the retry pipeline), while the JDK transport silently dropped the header. It was also a header-injection surface for attacker-controlled names. Add validateName beside validateValues, invoked from the String-based add/set before the name is stored. It rejects blank names and any character in the C0 control range (0x00-0x1F, covering CR/LF/NUL) or DEL (0x7F). The typed (HttpHeaderName) overloads already carry pre-validated token names, so they are unaffected. The policy deliberately stops at control characters rather than RFC 7230's full tchar allow-list, so it does not reject non-ASCII names that some transports accept while still closing the splitting/injection surface. Correct the OkHttp and JDK transport-adapter comments that previously claimed names were validated upstream; they now describe the actual name and value guarantees.
…er adaptation Header-name validation previously lived only on the String-keyed Headers.Builder path and checked the raw, untrimmed name. HttpHeaderName.fromString — the typed header API — performed no name validation at all, so a control-character name could be interned and handed to a transport as a request/header-splitting vector. - Extract the name check into a shared requireValidHeaderName, now invoked by both Headers.Builder and HttpHeaderName.fromString. It validates the trimmed name (surrounding HTTP whitespace is stripped and harmless; an interior C0/DEL control byte is rejected) and rejects blank names. fromString now rejects a blank or control-character name instead of interning it. - OkHttp's addHeader rejects any non-printable-ASCII byte, so a model-valid non-ASCII (for example UTF-8) name or value would throw an unchecked IllegalArgumentException out of the synchronous execute(), past its @throws(IOException) contract. Catch it per header and drop the offending entry, mirroring the JDK transport adapter, and correct the adapter comments that claimed addHeader never throws. - Render control characters as a unicode escape in the rejection message rather than echoing the raw bytes. No public API change. Adds coverage for the typed-API rejection, the C0/DEL predicate boundary, the rejection-message escaping, and the OkHttp and JDK drop-not-throw paths (sync and async).
…ader adaptation Broaden header-value validation to reject the full C0 control range and DEL, matching the policy already applied to header names, while still permitting horizontal tab (legal in field-values per RFC 7230) and non-ASCII bytes. Name and value validation now share a single HeaderValidation module, and the name check returns its trimmed form so callers no longer trim twice. Make response adaptation resilient: both transports copy inbound response headers through a per-header guard that drops and logs a header the model layer rejects instead of failing the entire response. The underlying clients parse response headers leniently and can surface a control byte (notably over HTTP/2); the model-layer guard is an outbound injection concern and must not turn a readable response into an error. Raise the outbound dropped-header log to WARNING: a header the caller set that the transport cannot encode is silent data loss and should be visible by default, whereas restricted-header drops and the inbound response-header drop remain at verbose. Add coverage for value rejection (NUL, DEL, and the predicate boundary), the inbound drop path on both transports, and the JDK async drop-not-throw path.
The String-keyed add/set already trim and validate the name via requireValidHeaderName before storing it, so routing the trimmed name back through sanitizeName re-trimmed it. Add a canonicalKey helper that only case-folds an already-validated name and use it for the storage key; keep sanitizeName for the accessors and remove, which receive raw caller input.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Headers.Buildervalidated header values for CR/LF but never validated header names. Name handling only lower-cased and trimmed surrounding whitespace, so an interior\r,\n, NUL, or other control character passed through the model layer intact.Downstream, the two reference transports then diverged on such a name:
addHeader(name, value)with no guard. OkHttp rejects an illegal name with an uncheckedIllegalArgumentException, which escapedexecute()despite its@Throws(IOException)contract and bypassed the transport's IOException handling / retry pipeline.header(name, value)intry/catch(IllegalArgumentException)and silently dropped the offending header.So the same SDK-level request produced two different observable outcomes depending on transport. It was also a header-injection surface for attacker-controlled names.
Change
validateNamebesidevalidateValuesinHeaders.Builder, invoked from theString-basedadd/setbefore the name is stored. It rejects blank names and any character in the C0 control range (0x00–0x1F, which covers CR/LF/NUL) or DEL (0x7F).HttpHeaderNameoverloads carry pre-validated token names, so they are unaffected.The policy deliberately stops at control characters rather than RFC 7230's full
tcharallow-list, mirroring the conservative CR/LF-only stance already used for values: it closes the splitting/injection surface (control characters are illegal in every transport) without rejecting non-ASCII names that some transports accept.Tests
Added coverage in
HeadersTestfor CR, LF, CRLF, NUL, and DEL injection in names via bothaddandset(single- and list-value overloads), a blank-name rejection, a message-naming check, and an accepted-name case (including whitespace trimming).Gated build (scoped,
--no-daemon)./gradlew :sdk-core:test :sdk-core:ktlintCheck :sdk-core:detekt :sdk-core:apiCheck— passed./gradlew :sdk-transport-okhttp:compileKotlin :sdk-transport-okhttp:ktlintCheck :sdk-transport-okhttp:detekt :sdk-transport-jdkhttp:compileKotlin :sdk-transport-jdkhttp:ktlintCheck— passed (detekt is not run on the JDK 11 module)No public API change (
validateNameis private), soapiCheckpasses with noapiDump.Closes #114
Update: typed-API validation, transport hardening, and message escaping
Follow-up addressing gaps in the original change:
requireValidHeaderNameinvoked by bothHeaders.BuilderandHttpHeaderName.fromString.Previously
fromStringperformed no name validation, so the typed API could intern acontrol-character name and hand it to a transport — the same splitting surface the String API
closes. The validator runs on the trimmed name (surrounding HTTP whitespace is stripped and
harmless; an interior C0/DEL byte is rejected) and rejects blank names;
fromString("")and acontrol-character name are now rejected rather than interned.
addHeaderrejects anynon-printable-ASCII byte, so a model-valid non-ASCII (for example UTF-8) name or value would throw
an
IllegalArgumentExceptionout of the synchronousexecute(), past its@Throws(IOException)contract. The adapter now catches it per header and drops the offending entry, mirroring the JDK
transport adapter. The adapter comments that claimed
addHeadernever throws are corrected.instead of echoing the raw bytes.
Added tests for the typed-API rejection, the C0/DEL predicate boundary, the message escaping, and the
OkHttp and JDK drop-not-throw paths (sync and async). No public API change.
Behavioral changes
Pre-1.0 alpha, so these are acceptable, but worth calling out for reviewers and consumers:
HttpHeaderName.fromString("")andfromString(" ")now throwIllegalArgumentExceptioninstead of interning an empty header name.Follow-ups
Two design questions deliberately left out of scope here are tracked separately: