From cecccdfa063898a8e35493de7464bba4348006d8 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Wed, 17 Jun 2026 04:52:47 +0300 Subject: [PATCH] fix: reject control characters in header names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../dexpace/sdk/core/http/common/Headers.kt | 40 ++++++++ .../sdk/core/http/common/HeadersTest.kt | 97 +++++++++++++++++++ .../jdkhttp/internal/RequestAdapter.kt | 9 +- .../okhttp/internal/RequestAdapter.kt | 7 +- 4 files changed, 146 insertions(+), 7 deletions(-) diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/common/Headers.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/common/Headers.kt index 85ef3af8..ceeb7aee 100644 --- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/common/Headers.kt +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/common/Headers.kt @@ -160,6 +160,7 @@ public data class Headers private constructor( values: List, ): Builder = apply { + validateName(name) validateValues(name, values) headersMap.computeIfAbsent(sanitizeName(name)) { mutableListOf() }.addAll(values) } @@ -218,6 +219,7 @@ public data class Headers private constructor( values: List, ): Builder = apply { + validateName(name) validateValues(name, values) headersMap[sanitizeName(name)] = values.toMutableList() } @@ -333,5 +335,43 @@ public data class Headers private constructor( } } } + + /** + * Rejects header names that cannot legally appear on the wire before they reach a transport. + * + * An embedded carriage return (`\r`) or line feed (`\n`) in a name is the same + * request/header-splitting vector guarded against for values: once the name is serialised an + * attacker could inject a new header or a second request. A NUL or any other ASCII control + * character is likewise illegal in an RFC 7230 field-name (`token`) and is rejected by — or + * silently dropped at — the transport layer, so the two reference transports diverge (OkHttp + * throws unchecked, the JDK transport drops the header) when such a name slips through. + * Validating here at the transport-agnostic model layer fails fast and uniformly. + * + * Note [sanitizeName] trims surrounding whitespace and lower-cases, but it never removes an + * *interior* control character, so this check is the only thing standing between a malformed + * name and the transport. A blank name has no canonical form and is rejected as well. + * + * Policy: reject the C0 control range and DEL (code points `0x00`-`0x1F` and `0x7F`), which + * covers CR, LF, and NUL. This is intentionally narrower than RFC 7230's full `tchar` + * allow-list — restricting names to `tchar` only would reject some non-ASCII names that + * certain transports accept, whereas the control-character set is illegal everywhere and + * covers the splitting/injection surface. + */ + private fun validateName(name: String) { + require(name.isNotBlank()) { "Header name must not be blank." } + name.forEach { ch -> + require(ch.code > LAST_C0_CONTROL && ch.code != DEL_CONTROL) { + "Header name '$name' must not contain control characters " + + "(carriage return, line feed, NUL, or other C0/DEL bytes); " + + "such characters enable request/header splitting." + } + } + } + + /** Highest code point in the C0 control range (US, `0x1F`); everything at or below is illegal. */ + private const val LAST_C0_CONTROL: Int = 0x1F + + /** The DEL control character (`0x7F`), the lone control code above the C0 range. */ + private const val DEL_CONTROL: Int = 0x7F } } diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/common/HeadersTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/common/HeadersTest.kt index 72e5b2be..522c1069 100644 --- a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/common/HeadersTest.kt +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/common/HeadersTest.kt @@ -258,6 +258,103 @@ class HeadersTest { assertNull(headers.get("X-Trace-Id")) } + // ---- name validation (request/header-splitting guard) ----------------------- + + @Test + fun `add rejects a name containing a line feed`() { + assertFailsWith { + Headers.builder().add("X-Evil\nInjected", "v") + } + } + + @Test + fun `add rejects a name containing a carriage return`() { + assertFailsWith { + Headers.builder().add("X-Evil\rInjected", "v") + } + } + + @Test + fun `add rejects a name containing CRLF`() { + assertFailsWith { + Headers.builder().add("X-Evil\r\nInjected: 1", "v") + } + } + + @Test + fun `add rejects a name containing a NUL`() { + assertFailsWith { + Headers.builder().add("X-Evil\u0000Injected", "v") + } + } + + @Test + fun `add rejects a name containing a DEL control character`() { + assertFailsWith { + Headers.builder().add("X-Evil\u007FInjected", "v") + } + } + + @Test + fun `add list overload rejects a name containing CR or LF`() { + assertFailsWith { + Headers.builder().add("X-Evil\nInjected", listOf("v")) + } + } + + @Test + fun `set rejects a name containing a line feed`() { + assertFailsWith { + Headers.builder().set("X-Evil\nInjected", "v") + } + } + + @Test + fun `set rejects a name containing a carriage return`() { + assertFailsWith { + Headers.builder().set("X-Evil\rInjected", "v") + } + } + + @Test + fun `set list overload rejects a name containing a NUL`() { + assertFailsWith { + Headers.builder().set("X-Evil\u0000Injected", listOf("v")) + } + } + + @Test + fun `add rejects a blank name`() { + assertFailsWith { + Headers.builder().add(" ", "v") + } + } + + @Test + fun `the name rejection message names the offending header`() { + val thrown = + assertFailsWith { + Headers.builder().add("X-Trace-Id\nInjected", "v") + } + assertTrue( + thrown.message?.lowercase()?.contains("x-trace-id") == true, + "message should name the header, got: ${thrown.message}", + ) + } + + @Test + fun `normal names without control characters are accepted`() { + val headers = + Headers.builder() + .add("X-Plain", "a") + // Surrounding whitespace is trimmed by name normalisation, not rejected. + .set(" Authorization ", "Bearer t") + .build() + + assertEquals("a", headers.get("X-Plain")) + assertEquals("Bearer t", headers.get("Authorization")) + } + // ---- accessors & equality coverage ------------------------------------------ @Test diff --git a/sdk-transport-jdkhttp/src/main/kotlin/org/dexpace/sdk/transport/jdkhttp/internal/RequestAdapter.kt b/sdk-transport-jdkhttp/src/main/kotlin/org/dexpace/sdk/transport/jdkhttp/internal/RequestAdapter.kt index 032886d2..1c17bcfb 100644 --- a/sdk-transport-jdkhttp/src/main/kotlin/org/dexpace/sdk/transport/jdkhttp/internal/RequestAdapter.kt +++ b/sdk-transport-jdkhttp/src/main/kotlin/org/dexpace/sdk/transport/jdkhttp/internal/RequestAdapter.kt @@ -102,10 +102,11 @@ internal class RequestAdapter( * `IllegalArgumentException` escape [adapt] (and therefore `execute`, declared * `@Throws(IOException)`) where a caller's `catch(IOException)` would not observe it. * - * Note this catch guards against the JDK's restricted *name* set only. Illegal header - * *values* (CR/LF and similar) are now rejected upstream by `Headers.Builder`, so a value - * with control characters never reaches this point — the `IllegalArgumentException` handled - * here is the JDK refusing a restricted name, not a malformed value. + * Note this catch guards against the JDK's restricted *name* set only. Malformed header names + * (CR/LF, NUL, other control characters) and illegal header values (CR/LF) are rejected + * upstream by `Headers.Builder`, so neither a control-character name nor such a value reaches + * this point — the `IllegalArgumentException` handled here is the JDK refusing a *restricted* + * (but otherwise well-formed) name, not a malformed name or value. */ private fun attachHeaders( builder: HttpRequest.Builder, diff --git a/sdk-transport-okhttp/src/main/kotlin/org/dexpace/sdk/transport/okhttp/internal/RequestAdapter.kt b/sdk-transport-okhttp/src/main/kotlin/org/dexpace/sdk/transport/okhttp/internal/RequestAdapter.kt index 88af552b..b7be9172 100644 --- a/sdk-transport-okhttp/src/main/kotlin/org/dexpace/sdk/transport/okhttp/internal/RequestAdapter.kt +++ b/sdk-transport-okhttp/src/main/kotlin/org/dexpace/sdk/transport/okhttp/internal/RequestAdapter.kt @@ -51,9 +51,10 @@ internal class RequestAdapter( continue } for (value in values) { - // Header names/values are validated upstream by Headers.Builder (CR/LF and other - // illegal characters are rejected at construction), so addHeader receives only - // well-formed values here. + // Header names and values are validated upstream by Headers.Builder: names reject + // control characters (CR/LF, NUL, the rest of the C0/DEL range) and values reject + // CR/LF, so addHeader receives only well-formed input here and never throws on a + // malformed name or value. builder.addHeader(rawName, value) } }