|
| 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 |
0 commit comments