Skip to content

Commit 3eb709c

Browse files
authored
fix: reject control characters in header names and values, harden transport header adaptation (#140)
PR: #140
1 parent b8a26f6 commit 3eb709c

15 files changed

Lines changed: 704 additions & 57 deletions

File tree

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
/*
2+
* Copyright (c) 2026 dexpace and Omar Aljarrah
3+
*
4+
* Licensed under the MIT License. See LICENSE in the project root.
5+
* SPDX-License-Identifier: MIT
6+
*/
7+
8+
package org.dexpace.sdk.core.http.common
9+
10+
/**
11+
* Validates an HTTP header name at the transport-agnostic model layer and returns its trimmed
12+
* form. Shared by the String-keyed [Headers.Builder] API and the typed [HttpHeaderName.fromString]
13+
* entry point so a malformed name cannot slip through either one — the two were previously
14+
* inconsistent (only the String API validated, and only against the raw input). The trimmed name is
15+
* returned so callers reuse it instead of trimming a second time.
16+
*
17+
* The check runs on the **trimmed** name. `String.trim()` removes only surrounding *whitespace* —
18+
* the full Unicode class `Char.isWhitespace` recognises (`Character.isWhitespace ||
19+
* Character.isSpaceChar`: ASCII space and tab, the C0 line/separator controls, and the Unicode
20+
* space separators such as NBSP) — so leading or trailing whitespace is stripped before it could
21+
* reach the wire and is harmless. A surrounding control byte that is *not* whitespace — NUL, DEL,
22+
* and the other non-whitespace C0 codes — survives the trim and is rejected, exactly like an
23+
* *interior* control character. What is rejected:
24+
*
25+
* - **A blank name.** A field-name must be a non-empty RFC 7230 `token`; an empty or
26+
* all-whitespace name has no canonical form.
27+
* - **Any interior control character** — the C0 control range and DEL (code points `0x00`–`0x1F`
28+
* and `0x7F`), which covers CR, LF, and NUL. An embedded `\r`/`\n` is the same
29+
* request/header-splitting vector guarded against for header values: once the name is
30+
* serialised an attacker could inject a new header or a second request. A NUL or other control
31+
* character is illegal in a field-name, and the two reference transports handle it differently at
32+
* their raw API (OkHttp's `addHeader` throws unchecked, the JDK builder drops it); their adapters
33+
* now catch and drop uniformly, but a splitting vector should never get that far. Validating here
34+
* rejects it loudly at construction — fast, uniform, and transport-independent.
35+
*
36+
* Policy: the control-character set is intentionally narrower than RFC 7230's full `tchar`
37+
* allow-list — restricting names to `tchar` would reject some non-ASCII names that certain
38+
* transports accept, whereas the control-character set is illegal everywhere and covers the
39+
* splitting/injection surface. This mirrors the conservative stance taken for values in
40+
* [requireValidHeaderValues].
41+
*
42+
* @return the trimmed, validated name
43+
* @throws IllegalArgumentException if the trimmed name is blank or contains a control character
44+
*/
45+
@JvmSynthetic
46+
internal fun requireValidHeaderName(rawName: String): String {
47+
val trimmed = rawName.trim()
48+
require(trimmed.isNotEmpty()) { "Header name must not be blank." }
49+
trimmed.forEach { ch ->
50+
require(!isProhibitedInName(ch.code)) {
51+
"Header name '${escapeControlCharacters(rawName)}' must not contain control characters " +
52+
"(carriage return, line feed, NUL, or other C0/DEL bytes); " +
53+
"such characters enable request/header splitting."
54+
}
55+
}
56+
return trimmed
57+
}
58+
59+
/**
60+
* Validates the [values] of a header [name] at the transport-agnostic model layer, applying the
61+
* same control-character policy as [requireValidHeaderName] with **one deliberate exception**:
62+
* horizontal tab (`0x09`) is permitted. Unlike a field-name `token`, an RFC 7230 field-value may
63+
* carry HTAB as whitespace between field-content, and the two reference transports accept it (it is
64+
* the one control byte OkHttp's value rule allows), so rejecting it would refuse a legitimate value.
65+
*
66+
* Every other C0 control (`0x00`–`0x1F`, which covers CR, LF, and NUL) and DEL (`0x7F`) is
67+
* rejected. A bare CR/LF is the request/header-splitting vector — once a value is serialised an
68+
* attacker could inject a new header or a second request — and the remaining control bytes are
69+
* illegal in a field-value on every transport. The earlier policy here rejected only CR/LF; the
70+
* broader control-character set closes the same splitting/injection surface the name check does
71+
* while staying narrower than the strict field-value grammar.
72+
*
73+
* Non-ASCII (for example UTF-8) bytes are NOT rejected — that is the conservative stance shared
74+
* with the name check: a value some transports accept is not refused at the model layer. [name]
75+
* only labels the error message; the value itself is never echoed, so a secret or oversized value
76+
* is not leaked into a log line.
77+
*
78+
* @throws IllegalArgumentException if any value contains a prohibited control character
79+
*/
80+
@JvmSynthetic
81+
internal fun requireValidHeaderValues(
82+
name: String,
83+
values: List<String>,
84+
) {
85+
values.forEach { value ->
86+
value.forEach { ch ->
87+
require(!isProhibitedInValue(ch.code)) {
88+
"Header value for '$name' must not contain control characters (carriage return, " +
89+
"line feed, NUL, or other C0/DEL bytes, except horizontal tab); " +
90+
"such characters enable request/header splitting."
91+
}
92+
}
93+
}
94+
}
95+
96+
/** Whether [code] is a control character prohibited in a header name — the full C0 range and DEL. */
97+
private fun isProhibitedInName(code: Int): Boolean = code <= LAST_C0_CONTROL || code == DEL_CONTROL
98+
99+
/**
100+
* Whether [code] is a control character prohibited in a header value — the same set as for a name,
101+
* minus horizontal tab (`0x09`), which RFC 7230 permits as field-value whitespace.
102+
*/
103+
private fun isProhibitedInValue(code: Int): Boolean =
104+
(code <= LAST_C0_CONTROL && code != HORIZONTAL_TAB) || code == DEL_CONTROL
105+
106+
/**
107+
* Renders [name] for an error message with every control character replaced by its `\uXXXX`
108+
* escape, so a raw CR/LF/NUL from the rejected name never lands verbatim in a log line while the
109+
* printable portion still identifies the offending header.
110+
*/
111+
private fun escapeControlCharacters(name: String): String =
112+
buildString {
113+
name.forEach { ch ->
114+
if (ch.code <= LAST_C0_CONTROL || ch.code == DEL_CONTROL) {
115+
append("\\u")
116+
append(ch.code.toString(HEX_RADIX).padStart(ESCAPE_HEX_WIDTH, '0'))
117+
} else {
118+
append(ch)
119+
}
120+
}
121+
}
122+
123+
/** Horizontal tab (`0x09`) — the one C0 control RFC 7230 permits in a field-value (but not a name). */
124+
private const val HORIZONTAL_TAB: Int = 0x09
125+
126+
/** Highest code point in the C0 control range (US, `0x1F`); everything at or below is illegal in a name. */
127+
private const val LAST_C0_CONTROL: Int = 0x1F
128+
129+
/** The DEL control character (`0x7F`), the lone control code above the C0 range. */
130+
private const val DEL_CONTROL: Int = 0x7F
131+
132+
/** Radix for rendering a control character's code point as the hex digits of a `\uXXXX` escape. */
133+
private const val HEX_RADIX: Int = 16
134+
135+
/** Zero-padded width of a `\uXXXX` escape's hex digits. */
136+
private const val ESCAPE_HEX_WIDTH: Int = 4

sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/common/Headers.kt

Lines changed: 14 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -160,8 +160,9 @@ public data class Headers private constructor(
160160
values: List<String>,
161161
): Builder =
162162
apply {
163-
validateValues(name, values)
164-
headersMap.computeIfAbsent(sanitizeName(name)) { mutableListOf() }.addAll(values)
163+
val trimmedName = requireValidHeaderName(name)
164+
requireValidHeaderValues(trimmedName, values)
165+
headersMap.computeIfAbsent(canonicalKey(trimmedName)) { mutableListOf() }.addAll(values)
165166
}
166167

167168
/**
@@ -180,7 +181,7 @@ public data class Headers private constructor(
180181
values: List<String>,
181182
): Builder =
182183
apply {
183-
validateValues(name.caseInsensitiveName, values)
184+
requireValidHeaderValues(name.caseInsensitiveName, values)
184185
headersMap.computeIfAbsent(name.caseInsensitiveName) { mutableListOf() }.addAll(values)
185186
}
186187

@@ -218,8 +219,9 @@ public data class Headers private constructor(
218219
values: List<String>,
219220
): Builder =
220221
apply {
221-
validateValues(name, values)
222-
headersMap[sanitizeName(name)] = values.toMutableList()
222+
val trimmedName = requireValidHeaderName(name)
223+
requireValidHeaderValues(trimmedName, values)
224+
headersMap[canonicalKey(trimmedName)] = values.toMutableList()
223225
}
224226

225227
/**
@@ -246,7 +248,7 @@ public data class Headers private constructor(
246248
values: List<String>,
247249
): Builder =
248250
apply {
249-
validateValues(name.caseInsensitiveName, values)
251+
requireValidHeaderValues(name.caseInsensitiveName, values)
250252
headersMap[name.caseInsensitiveName] = values.toMutableList()
251253
}
252254

@@ -304,34 +306,18 @@ public data class Headers private constructor(
304306
public fun builder(): Builder = Builder()
305307

306308
/**
307-
* Normalises a header name to its canonical (lower-case, trimmed) storage key.
309+
* Normalises a raw, caller-supplied header name to its canonical (lower-case, trimmed)
310+
* storage key. Used by the accessors and `remove`, which receive untrimmed input.
308311
* `Locale.US` is used deliberately — HTTP header names are ASCII-only per RFC 7230,
309312
* so locale-sensitive folding (Turkish `i`, etc.) would be incorrect here.
310313
*/
311314
private fun sanitizeName(value: String): String = value.lowercase(Locale.US).trim()
312315

313316
/**
314-
* Rejects header values that would enable request/header splitting before they reach a
315-
* transport. A bare carriage return (`\r`) or line feed (`\n`) in a value lets an
316-
* attacker inject a new header or even a second request once the value is serialised;
317-
* OkHttp throws unchecked on such values and the JDK transport silently drops them, so we
318-
* validate here at the transport-agnostic model layer to fail fast and uniformly.
319-
*
320-
* Policy: reject **only** CR/LF, not OkHttp's stricter printable-ASCII-only rule. CR/LF
321-
* are the splitting vector and are illegal in every transport; tightening further would
322-
* reject legitimate UTF-8 values that some transports (and the JDK) accept, so the
323-
* conservative CR/LF check is the right model-layer contract.
317+
* Canonical storage key for a name that was already trimmed and validated by
318+
* [requireValidHeaderName]. Only case-folding is needed — re-trimming (as [sanitizeName]
319+
* does for raw input) would be redundant. `Locale.US` per [sanitizeName]'s rationale.
324320
*/
325-
private fun validateValues(
326-
name: String,
327-
values: List<String>,
328-
) {
329-
values.forEach { value ->
330-
require(value.indexOf('\r') < 0 && value.indexOf('\n') < 0) {
331-
"Header value for '$name' must not contain a carriage return or line feed " +
332-
"(\\r / \\n); such characters enable request/header splitting."
333-
}
334-
}
335-
}
321+
private fun canonicalKey(validatedName: String): String = validatedName.lowercase(Locale.US)
336322
}
337323
}

sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/common/HttpHeaderName.kt

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ import java.util.concurrent.ConcurrentHashMap
2424
* first caller to intern a given name "wins"; subsequent lookups with different casing
2525
* yield the same shared instance.
2626
*
27-
* Whitespace is trimmed from the input before interning.
27+
* Whitespace is trimmed from the input before interning, and the name is validated: a blank name
28+
* or one carrying an interior control character is rejected (see [fromString]).
2829
*
2930
* Designed for Java 8 bytecode compatibility — no APIs newer than Java 8 are used.
3031
*/
@@ -216,10 +217,21 @@ public class HttpHeaderName private constructor(
216217
* lower-case (US locale) for the interning key. The case-preserved form of the
217218
* first caller to intern a given key wins; subsequent calls with different casing
218219
* yield the same shared instance.
220+
*
221+
* The name is validated up front by [requireValidHeaderName]: a blank name, or one whose
222+
* trimmed form contains an interior control character (CR, LF, NUL, or any other C0/DEL
223+
* byte), is rejected with an [IllegalArgumentException]. This is the same guard the
224+
* String-keyed [Headers.Builder] API applies, so an interned name carried through the typed
225+
* header API is guaranteed control-character-free and cannot reach a transport as a
226+
* header-splitting vector.
227+
*
228+
* @throws IllegalArgumentException if [name] is blank or contains a control character
219229
*/
220230
@JvmStatic
221231
public fun fromString(name: String): HttpHeaderName {
222-
val trimmed = name.trim()
232+
// requireValidHeaderName trims and validates, returning the trimmed form so we do not
233+
// trim a second time before interning.
234+
val trimmed = requireValidHeaderName(name)
223235
val key = trimmed.lowercase(Locale.US)
224236
// computeIfAbsent is available on Java 8.
225237
return INTERN.computeIfAbsent(key) { HttpHeaderName(trimmed, key) }

0 commit comments

Comments
 (0)