Skip to content

fix: reject control characters in header names and values, harden transport header adaptation#140

Merged
OmarAlJarrah merged 5 commits into
mainfrom
fix/header-name-crlf-validation
Jun 24, 2026
Merged

fix: reject control characters in header names and values, harden transport header adaptation#140
OmarAlJarrah merged 5 commits into
mainfrom
fix/header-name-crlf-validation

Conversation

@OmarAlJarrah

@OmarAlJarrah OmarAlJarrah commented Jun 17, 2026

Copy link
Copy Markdown
Member

Problem

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 passed through the model layer intact.

Downstream, the two reference transports then diverged on such a name:

  • OkHttp called addHeader(name, value) with no guard. OkHttp rejects an illegal name with an unchecked IllegalArgumentException, which escaped execute() despite its @Throws(IOException) contract and bypassed the transport's IOException handling / retry pipeline.
  • JDK wrapped header(name, value) in try/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

  • Add validateName beside validateValues in Headers.Builder, invoked from the String-based add/set before the name is stored. It rejects blank names and any character in the C0 control range (0x000x1F, which covers CR/LF/NUL) or DEL (0x7F).
  • The typed HttpHeaderName overloads carry pre-validated token names, so they are unaffected.
  • Correct the OkHttp and JDK transport-adapter comments that previously claimed names were validated upstream; they now describe the actual name and value guarantees.

The policy deliberately stops at control characters rather than RFC 7230's full tchar allow-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 HeadersTest for CR, LF, CRLF, NUL, and DEL injection in names via both add and set (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:apiCheckpassed
  • ./gradlew :sdk-transport-okhttp:compileKotlin :sdk-transport-okhttp:ktlintCheck :sdk-transport-okhttp:detekt :sdk-transport-jdkhttp:compileKotlin :sdk-transport-jdkhttp:ktlintCheckpassed (detekt is not run on the JDK 11 module)

No public API change (validateName is private), so apiCheck passes with no apiDump.

Closes #114

Update: typed-API validation, transport hardening, and message escaping

Follow-up addressing gaps in the original change:

  • The typed header API is now validated too. The name check moved into a shared
    requireValidHeaderName invoked by both Headers.Builder and HttpHeaderName.fromString.
    Previously fromString performed no name validation, so the typed API could intern a
    control-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 a
    control-character name are now rejected rather than interned.
  • The OkHttp adapter no longer leaks an unchecked exception. 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 IllegalArgumentException out of the synchronous execute(), 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 addHeader never throws are corrected.
  • Error-message escaping. The rejection message renders control characters as a unicode escape
    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("") and fromString(" ") now throw IllegalArgumentException instead of interning an empty header name.
  • Header value validation broadened from CR/LF-only to the full C0 control range (except horizontal tab) plus DEL. A value containing, for example, a vertical tab or form feed that previously passed is now rejected.

Follow-ups

Two design questions deliberately left out of scope here are tracked separately:

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.
@OmarAlJarrah OmarAlJarrah changed the title Reject control characters in header names fix: reject control characters in header names Jun 17, 2026
…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.
@OmarAlJarrah OmarAlJarrah changed the title fix: reject control characters in header names fix: reject control characters in header names and values, harden transport header adaptation Jun 24, 2026
@OmarAlJarrah OmarAlJarrah merged commit 3eb709c into main Jun 24, 2026
1 check passed
@OmarAlJarrah OmarAlJarrah deleted the fix/header-name-crlf-validation branch June 24, 2026 09:25
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.

Header names bypass CR/LF validation while header values are validated

1 participant