Skip to content

Commit b015fd3

Browse files
authored
fix!: reject non-ASCII headers at the model layer and resolve pagination follow-ups (#207)
PR: #207
1 parent 91ffcdf commit b015fd3

33 files changed

Lines changed: 1158 additions & 200 deletions

File tree

docs/implementation-plan.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -375,8 +375,8 @@ defaults (per Square: `FAIL_ON_UNKNOWN_PROPERTIES=false`, `WRITE_DATES_AS_TIMEST
375375
> Superseded by #30 (pagination unification): `Page` now exposes the raw per-page `Response` and is `Closeable` (materialized `items` and derived `statusCode` / `headers` / `request` survive `close()`), strategies return `PageInfo` (`nextRequest == null` = end of stream), and `SimplePage` was removed.
376376
377377
**Status: shipped.** `Page`, `Paginator`, `PaginationStrategy`, and the three strategies
378-
(`Cursor` / `PageNumber` / `LinkHeader`) are in `sdk-core/.../pagination`, alongside
379-
helper types `SimplePage` and `RequestRebuilder`. `Paginator` gained a `maxPages` safety cap
378+
(`Cursor` / `PageNumber` / `LinkHeader`) are in `sdk-core/.../pagination`, alongside the
379+
helper type `RequestRebuilder`. `Paginator` gained a `maxPages` safety cap
380380
(default `Long.MAX_VALUE`) beyond the original sketch, to bound runaway iteration against servers
381381
that never advance their cursor.
382382

docs/refs-comparison.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,7 @@ below records where each scheme's design was sourced from:
230230
### Pagination
231231

232232
`Paginator<T>` + `Page<T>` ship in `sdk-core`, driven by a `PaginationStrategy` (cursor,
233-
page-number, token, link-header), and `pagination.PagedIterable` wraps the result. Reference
233+
page-number, link-header), and `pagination.PagedIterable` wraps the result. Reference
234234
designs we drew on:
235235

236236
- **Square**: `SyncPagingIterable<T>` (`Iterable<T>` lazy iterator), `SyncPage<T>` (per-page holder), `BiDirectionalPage<T>` (forward + backward cursors), `CustomPager<T>` (user-implementation stub for HATEOAS).
@@ -240,7 +240,7 @@ designs we drew on:
240240
**What shipped, and what's left:**
241241

242242
1. `Paginator<T>` and `Page<T>` are in `sdk-core`. `iterateAll()` returns a lazy `Iterable<T>`; `streamAll()` returns a Java 8 `Stream<T>`. Each call hands back an independent iterator with its own state.
243-
2. The strategy is injected via `PaginationStrategy`, with concrete impls covering cursor (`next_cursor` / `prev_cursor`), page-number, token, and link-header (RFC 8288). A `maxPages` cap guards against servers that never advance their cursor.
243+
2. The strategy is injected via `PaginationStrategy`, with three concrete impls: cursor (`CursorPaginationStrategy`, forward-only), page-number, and link-header (RFC 8288). Token-style APIs (`next_page_token`, `pageToken`, …) reuse `CursorPaginationStrategy` with a configurable query-param name, so no separate token strategy ships (see `architecture.md`). A `maxPages` cap guards against servers that never advance their cursor.
244244
3. Async variants for `sdk-async-coroutines` (`Flow<T>`) and `sdk-async-reactor` (`Flux<T>`) are not yet built.
245245
4. `BiDirectionalPage` is deferred until a real API needs it; Square's pattern is good when needed.
246246

@@ -333,7 +333,7 @@ adapters where noted):
333333
- **Idempotency-key step.** Auto-injects `Idempotency-Key: UUID.randomUUID()` for `POST`/`PUT`/`PATCH`; caller-set header wins; pluggable key strategy. [`pipeline/step/IdempotencyKeyStep.kt`]
334334
- **Auth.** `Credential` family + RFC 7235 challenge parsing + Basic/Digest/Composite `ChallengeHandler`s + `AuthStep` pillar. [`auth/`, `http/pipeline/steps/`]
335335
- **`sdk-serde-jackson` adapter.** Kotlin + JSR-310 + Jdk8 modules; `FAIL_ON_UNKNOWN_PROPERTIES` and `WRITE_DATES_AS_TIMESTAMPS` disabled; `Tristate<T>` via `TristateModule`.
336-
- **Pagination primitives.** `Paginator<T>` + `Page<T>` + `PaginationStrategy` (cursor / page-number / token / link-header) with a `maxPages` cap; `PagedIterable` wrapper.
336+
- **Pagination primitives.** `Paginator<T>` + `Page<T>` + `PaginationStrategy` (cursor / page-number / link-header) with a `maxPages` cap; `PagedIterable` wrapper.
337337
- **Client identity header.** `ClientIdentityStep` building the `dexpace-sdk/<ver> jvm/<javaver>` token line.
338338
- **Tracer event vocabulary + metrics seam.** `HttpTracer` with named retry/request/response events; `Meter`/`LongCounter`/`DoubleHistogram` separate from tracing.
339339
- **SSE streaming.** WHATWG reader in `sdk-core`; backpressured `Flux<ServerSentEvent>` in `sdk-async-reactor`.

sdk-core/api/sdk-core.api

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,7 @@ public final class org/dexpace/sdk/core/http/common/Headers$Builder {
406406
public final fun add (Lorg/dexpace/sdk/core/http/common/HttpHeaderName;Ljava/lang/String;)Lorg/dexpace/sdk/core/http/common/Headers$Builder;
407407
public final fun add (Lorg/dexpace/sdk/core/http/common/HttpHeaderName;Ljava/util/List;)Lorg/dexpace/sdk/core/http/common/Headers$Builder;
408408
public final fun addAll (Lorg/dexpace/sdk/core/http/common/Headers;)Lorg/dexpace/sdk/core/http/common/Headers$Builder;
409+
public final fun addUnsafeNonAscii (Ljava/lang/String;Ljava/lang/String;)Lorg/dexpace/sdk/core/http/common/Headers$Builder;
409410
public final fun build ()Lorg/dexpace/sdk/core/http/common/Headers;
410411
public final fun remove (Ljava/lang/String;)Lorg/dexpace/sdk/core/http/common/Headers$Builder;
411412
public final fun remove (Lorg/dexpace/sdk/core/http/common/HttpHeaderName;)Lorg/dexpace/sdk/core/http/common/Headers$Builder;
@@ -1831,6 +1832,15 @@ public final class org/dexpace/sdk/core/instrumentation/ClientLogger {
18311832
public final class org/dexpace/sdk/core/instrumentation/ClientLogger$Companion {
18321833
}
18331834

1835+
public final class org/dexpace/sdk/core/instrumentation/DroppedHeaderLogging : java/lang/Enum {
1836+
public static final field ONCE_PER_HEADER Lorg/dexpace/sdk/core/instrumentation/DroppedHeaderLogging;
1837+
public static final field PER_OCCURRENCE Lorg/dexpace/sdk/core/instrumentation/DroppedHeaderLogging;
1838+
public static final field VERBOSE_ONLY Lorg/dexpace/sdk/core/instrumentation/DroppedHeaderLogging;
1839+
public static fun getEntries ()Lkotlin/enums/EnumEntries;
1840+
public static fun valueOf (Ljava/lang/String;)Lorg/dexpace/sdk/core/instrumentation/DroppedHeaderLogging;
1841+
public static fun values ()[Lorg/dexpace/sdk/core/instrumentation/DroppedHeaderLogging;
1842+
}
1843+
18341844
public abstract interface class org/dexpace/sdk/core/instrumentation/HttpTracer {
18351845
public fun attemptFailed (Ljava/lang/Throwable;Ljava/lang/Long;)V
18361846
public fun attemptRetriesExhausted (Ljava/lang/Throwable;)V

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

Lines changed: 81 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -28,29 +28,33 @@ package org.dexpace.sdk.core.http.common
2828
* and `0x7F`), which covers CR, LF, and NUL. An embedded `\r`/`\n` is the same
2929
* request/header-splitting vector guarded against for header values: once the name is
3030
* 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.
31+
* character is illegal in a field-name; validating here rejects it loudly at construction —
32+
* fast, uniform, and transport-independent.
33+
* - **Any non-ASCII byte** (code point `>= 0x80`). RFC 7230 field-names are ASCII `token`s, and
34+
* every shipped reference transport (OkHttp, the JDK `HttpClient`) rejects a non-ASCII name at
35+
* its own model layer, so a name no transport can put on the wire is refused here at
36+
* construction rather than accepted by the model and then silently dropped mid-dispatch.
3537
*
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
38+
* Policy: the accepted set is printable ASCII (`0x20`–`0x7E`) — still wider than RFC 7230's
39+
* `tchar`, so a name a transport's stricter grammar rejects (e.g. an interior space) is left for
40+
* the transport to drop rather than pre-judged here. Control characters and non-ASCII bytes, which
41+
* no transport can encode, are rejected outright. This mirrors the stance taken for values in
4042
* [requireValidHeaderValues].
4143
*
4244
* @return the trimmed, validated name
43-
* @throws IllegalArgumentException if the trimmed name is blank or contains a control character
45+
* @throws IllegalArgumentException if the trimmed name is blank, or contains a control character or
46+
* a non-ASCII byte
4447
*/
4548
@JvmSynthetic
4649
internal fun requireValidHeaderName(rawName: String): String {
4750
val trimmed = rawName.trim()
4851
require(trimmed.isNotEmpty()) { "Header name must not be blank." }
4952
trimmed.forEach { ch ->
5053
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+
"Header name '${escapeControlCharacters(rawName)}' must be printable ASCII: it must not " +
55+
"contain control characters (carriage return, line feed, NUL, or other C0/DEL bytes) " +
56+
"or non-ASCII bytes; control characters enable request/header splitting, and no " +
57+
"reference transport can encode a non-ASCII name."
5458
}
5559
}
5660
return trimmed
@@ -70,12 +74,19 @@ internal fun requireValidHeaderName(rawName: String): String {
7074
* broader control-character set closes the same splitting/injection surface the name check does
7175
* while staying narrower than the strict field-value grammar.
7276
*
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+
* Non-ASCII bytes (code point `>= 0x80`) are rejected as well, matching OkHttp's field-value
78+
* grammar (HTAB plus printable ASCII `0x20`–`0x7E`). The shipped transports are not unanimous here
79+
* — the JDK `HttpClient` will serialise obs-text (`0x80`+) on the wire — so the model deliberately
80+
* takes the *most restrictive* shipped transport's stance: a value it accepts is sendable by every
81+
* shipped transport, and a value only some can encode is refused at construction rather than
82+
* silently dropped by the stricter one mid-dispatch. This governs **outbound, caller-set** values;
83+
* a header parsed from an already-received response takes the lenient
84+
* [requireValidInboundHeaderValue] path (via `Headers.Builder.addUnsafeNonAscii`), which preserves
85+
* the obs-text a server may legitimately send. [name] only labels the error message; the value
86+
* itself is never echoed, so a secret or oversized value is not leaked into a log line.
7787
*
78-
* @throws IllegalArgumentException if any value contains a prohibited control character
88+
* @throws IllegalArgumentException if any value contains a prohibited control character or a
89+
* non-ASCII byte
7990
*/
8091
@JvmSynthetic
8192
internal fun requireValidHeaderValues(
@@ -85,22 +96,61 @@ internal fun requireValidHeaderValues(
8596
values.forEach { value ->
8697
value.forEach { ch ->
8798
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."
99+
"Header value for '$name' must be ASCII: it must not contain control characters " +
100+
"(carriage return, line feed, NUL, or other C0/DEL bytes, except horizontal tab) " +
101+
"or non-ASCII bytes; control characters enable request/header splitting, and " +
102+
"OkHttp's field-value grammar rejects a non-ASCII value."
91103
}
92104
}
93105
}
94106
}
95107

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
108+
/**
109+
* Validates the [value] of an *inbound* (response) header [name] leniently. Like OkHttp's
110+
* `addUnsafeNonAscii`/`addLenient` read path, the ASCII restriction is dropped so **non-ASCII bytes
111+
* are permitted**; unlike that path — which does no value validation at all — control characters are
112+
* still rejected here as defence-in-depth against a misbehaving transport (a stray CR/LF/NUL has no
113+
* place in a parsed field-value). RFC 7230 explicitly allows obs-text (`0x80`+) in field-content,
114+
* and a server legitimately puts Latin-1 bytes in values such as a `Content-Disposition` filename;
115+
* applying the stricter outbound grammar of [requireValidHeaderValues] to a response would silently
116+
* strip those headers. [name] only labels the error message; the value itself is never echoed.
117+
*
118+
* @throws IllegalArgumentException if [value] contains a prohibited control character
119+
*/
120+
@JvmSynthetic
121+
internal fun requireValidInboundHeaderValue(
122+
name: String,
123+
value: String,
124+
) {
125+
value.forEach { ch ->
126+
require(!isProhibitedInInboundValue(ch.code)) {
127+
"Inbound header value for '$name' must not contain control characters (carriage " +
128+
"return, line feed, NUL, or other C0/DEL bytes, except horizontal tab)."
129+
}
130+
}
131+
}
98132

99133
/**
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.
134+
* Whether [code] is prohibited in a header name — the full C0 range, DEL, and every non-ASCII byte
135+
* (`>= 0x80`). `code >= DEL_CONTROL` covers DEL (`0x7F`) and all of `0x80`+ in one comparison.
102136
*/
103-
private fun isProhibitedInValue(code: Int): Boolean =
137+
private fun isProhibitedInName(code: Int): Boolean = code <= LAST_C0_CONTROL || code >= DEL_CONTROL
138+
139+
/**
140+
* Whether [code] is prohibited in an outbound header value — the same set as for a name, minus
141+
* horizontal tab (`0x09`), which RFC 7230 permits as field-value whitespace. Shared with [MediaType]
142+
* so a media type's rendered form and the header value it becomes agree on the accepted byte set.
143+
*/
144+
@JvmSynthetic
145+
internal fun isProhibitedInValue(code: Int): Boolean =
146+
(code <= LAST_C0_CONTROL && code != HORIZONTAL_TAB) || code >= DEL_CONTROL
147+
148+
/**
149+
* Whether [code] is prohibited in an *inbound* header value: control characters (`0x00`–`0x1F`
150+
* except HTAB, plus DEL `0x7F`) are still rejected, but non-ASCII bytes (`0x80`+) are permitted —
151+
* the obs-text a server may legitimately send. See [requireValidInboundHeaderValue].
152+
*/
153+
private fun isProhibitedInInboundValue(code: Int): Boolean =
104154
(code <= LAST_C0_CONTROL && code != HORIZONTAL_TAB) || code == DEL_CONTROL
105155

106156
/**
@@ -126,7 +176,12 @@ private const val HORIZONTAL_TAB: Int = 0x09
126176
/** Highest code point in the C0 control range (US, `0x1F`); everything at or below is illegal in a name. */
127177
private const val LAST_C0_CONTROL: Int = 0x1F
128178

129-
/** The DEL control character (`0x7F`), the lone control code above the C0 range. */
179+
/**
180+
* The DEL control character (`0x7F`). Used as the low bound of the rejected high range: for a name
181+
* and an outbound value, DEL and every byte above it (the non-ASCII bytes, `0x80`+) are prohibited
182+
* (`code >= DEL_CONTROL`); an inbound value rejects only DEL itself (`code == DEL_CONTROL`),
183+
* permitting obs-text.
184+
*/
130185
private const val DEL_CONTROL: Int = 0x7F
131186

132187
/** Radix for rendering a control character's code point as the hex digits of a `\uXXXX` escape. */

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,36 @@ public data class Headers private constructor(
159159
headersMap.computeIfAbsent(canonicalKey(trimmedName)) { mutableListOf() }.addAll(values)
160160
}
161161

162+
/**
163+
* Adds an inbound (already-received) header, permitting non-ASCII (obs-text) bytes in
164+
* [value] that the strict outbound [add] grammar rejects.
165+
*
166+
* A transport uses this when copying a *response*'s headers into the model. RFC 7230 allows
167+
* obs-text (`0x80`+) in a field-value — a server legitimately puts Latin-1 bytes in values
168+
* such as a `Content-Disposition` filename — and both reference transports surface such
169+
* values on read, so validating them with the outbound grammar would silently drop
170+
* legitimate response headers. The name is still validated strictly (control and non-ASCII
171+
* bytes rejected); in the value, only the ASCII restriction is relaxed — control characters
172+
* are still rejected. Like OkHttp's `addUnsafeNonAscii` this relaxes the value's ASCII
173+
* restriction; unlike it (whose `addLenient` validates nothing on the value), the
174+
* control-character guard is kept here as defence-in-depth.
175+
*
176+
* @param name the header name
177+
* @param value the header value, which may contain non-ASCII bytes
178+
* @return this builder
179+
* @throws IllegalArgumentException if [name] is blank or contains a control/non-ASCII byte,
180+
* or [value] contains a prohibited control character
181+
*/
182+
public fun addUnsafeNonAscii(
183+
name: String,
184+
value: String,
185+
): Builder =
186+
apply {
187+
val trimmedName = requireValidHeaderName(name)
188+
requireValidInboundHeaderValue(trimmedName, value)
189+
headersMap.computeIfAbsent(canonicalKey(trimmedName)) { mutableListOf() }.add(value)
190+
}
191+
162192
/**
163193
* Adds a header with the specified typed name and value.
164194
*/

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

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +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, and the name is validated: a blank name
28-
* or one carrying an interior control character is rejected (see [fromString]).
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 or a non-ASCII byte, is rejected (see [fromString]).
2929
*
3030
* Designed for Java 8 bytecode compatibility — no APIs newer than Java 8 are used.
3131
*/
@@ -220,12 +220,14 @@ public class HttpHeaderName private constructor(
220220
*
221221
* The name is validated up front by [requireValidHeaderName]: a blank name, or one whose
222222
* 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.
223+
* byte) or a non-ASCII byte (code point >= 0x80), is rejected with an
224+
* [IllegalArgumentException]. This is the same guard the String-keyed [Headers.Builder] API
225+
* applies, so an interned name carried through the typed header API is guaranteed to be
226+
* printable ASCII — control-character-free (it cannot reach a transport as a header-splitting
227+
* vector) and free of the non-ASCII bytes no reference transport can encode.
227228
*
228-
* @throws IllegalArgumentException if [name] is blank or contains a control character
229+
* @throws IllegalArgumentException if [name] is blank, or contains a control character or a
230+
* non-ASCII byte
229231
*/
230232
@JvmStatic
231233
public fun fromString(name: String): HttpHeaderName {

0 commit comments

Comments
 (0)