SwiftNIO: CRLF Injection in outbound HTTP request URI via NIOHTTPRequestHeadersValidator
Description
CRLF injection in swift-nio HTTP/1.1 start line allows request smuggling and response splitting in versions 2.0.0–2.99.0; fixed in 2.100.0.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
CRLF injection in swift-nio HTTP/1.1 start line allows request smuggling and response splitting in versions 2.0.0–2.99.0; fixed in 2.100.0.
Vulnerability
The vulnerability is a CRLF injection in the HTTP/1.1 start line components (request URI, request method, and response reason phrase) due to insufficient validation by NIOHTTPRequestHeadersValidator and NIOHTTPResponseHeadersValidator channel handlers. All versions of swift-nio from 2.0.0 to 2.99.0 are affected [1][2].
Exploitation
An attacker must be able to influence the content of outbound HTTP start line fields. In proxy applications, attacker-controlled URIs or methods are directly forwarded. For clients, a malicious server can trigger a redirect to a crafted URL. For servers, a client can cause a crafted response reason phrase. Injecting a CRLF sequence requires a single crafted request [1][2].
Impact
Successful exploitation enables HTTP request smuggling or HTTP response splitting, potentially bypassing WAFs, poisoning web caches, or smuggling requests past intermediaries [1][2].
Mitigation
Fixed in swift-nio version 2.100.0 and later. As a workaround, ensure all user-controlled input is sanitized before inclusion in HTTP start line components [1][2].
AI Insight generated on Jun 12, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
1- Range: >= 2.0.0, <= 2.99.0
Patches
1dd16365724d5Merge commit from fork
2 files changed · +511 −4
Sources/NIOHTTP1/HTTPHeaderValidator.swift+243 −4 modified@@ -20,7 +20,7 @@ import NIOCore /// are emitted on the network. If a header block is invalid, then ``NIOHTTPRequestHeadersValidator`` /// will send a ``HTTPParserError/invalidHeaderToken``. /// -/// ``NIOHTTPRequestHeadersValidator`` will also valid that the HTTP trailers are within specification, +/// ``NIOHTTPRequestHeadersValidator`` will also validate that the HTTP trailers are within specification, /// if they are present. public final class NIOHTTPRequestHeadersValidator: ChannelOutboundHandler, RemovableChannelHandler { public typealias OutboundIn = HTTPClientRequestPart @@ -31,7 +31,7 @@ public final class NIOHTTPRequestHeadersValidator: ChannelOutboundHandler, Remov public func write(context: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise<Void>?) { switch NIOHTTPRequestHeadersValidator.unwrapOutboundIn(data) { case .head(let head): - guard head.headers.areValidToSend else { + guard Self.uriOnlyContainsAllowedCharacters(head.uri), head.method.isValidToSend, head.headers.areValidToSend else { promise?.fail(HTTPParserError.invalidHeaderToken) context.fireErrorCaught(HTTPParserError.invalidHeaderToken) return @@ -48,6 +48,79 @@ public final class NIOHTTPRequestHeadersValidator: ChannelOutboundHandler, Remov context.write(data, promise: promise) } + + static func uriOnlyContainsAllowedCharacters(_ uri: String) -> Bool { + // The spec in [RFC 9112](https://datatracker.ietf.org/doc/html/rfc9112#section-3.2) defines the valid + // characters for the request-target as the following: + // + // ``` + // request-target = origin-form / absolute-form / authority-form / asterisk-form + // + // origin-form = absolute-path [ "?" query ] + // absolute-form = absolute-URI + // authority-form = uri-host ":" port ; CONNECT only + // asterisk-form = "*" ; OPTIONS only + // ``` + // + // The component grammar comes from [RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986#section-3) + // (updated by [RFC 8820](https://datatracker.ietf.org/doc/html/rfc8820), which adds best-practice + // guidance for URI design but does not change the syntax): + // + // ``` + // absolute-path = 1*( "/" segment ) + // segment = *pchar + // query = *( pchar / "/" / "?" ) + // + // pchar = unreserved / pct-encoded / sub-delims / ":" / "@" + // pct-encoded = "%" HEXDIG HEXDIG + // + // unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" + // reserved = gen-delims / sub-delims + // gen-delims = ":" / "/" / "?" / "#" / "[" / "]" / "@" + // sub-delims = "!" / "$" / "&" / "'" / "(" / ")" + // / "*" / "+" / "," / ";" / "=" + // ``` + // + // In other words, the literal byte set allowed on the wire is: + // + // ``` + // ALPHA %x41-5A / %x61-7A ; A–Z a–z + // DIGIT %x30-39 ; 0–9 + // unreserved "-" "." "_" "~" + // gen-delims ":" "/" "?" "#" "[" "]" "@" + // sub-delims "!" "$" "&" "'" "(" ")" "*" "+" "," ";" "=" + // pct-encoded "%" HEXDIG HEXDIG ; escape for anything else + // ``` + // + // Everything outside this set — SP, CTLs (%x00-1F / %x7F), non-ASCII (%x80-FF), + // and `" < > \ ^ ` { | }` — MUST be percent-encoded. Bare CR, LF, or NUL in + // the request-target MUST be rejected (request smuggling / response splitting). + + uri.utf8.allSatisfy { byte in + switch byte { + case + // unreserved + // - ALPHA + UInt8(ascii: "A")...UInt8(ascii: "Z"), UInt8(ascii: "a")...UInt8(ascii: "z"), + // - DIGIT + UInt8(ascii: "0")...UInt8(ascii: "9"), + // - extra characters + UInt8(ascii: "-"), UInt8(ascii: "."), UInt8(ascii: "_"), UInt8(ascii: "~"), + // gen-delims + UInt8(ascii: ":"), UInt8(ascii: "/"), UInt8(ascii: "?"), UInt8(ascii: "#"), + UInt8(ascii: "["), UInt8(ascii: "]"), UInt8(ascii: "@"), + // sub-delims + UInt8(ascii: "!"), UInt8(ascii: "$"), UInt8(ascii: "&"), UInt8(ascii: "'"), + UInt8(ascii: "("), UInt8(ascii: ")"), UInt8(ascii: "*"), UInt8(ascii: "+"), + UInt8(ascii: ","), UInt8(ascii: ";"), UInt8(ascii: "="), + // pct-encoded + UInt8(ascii: "%"): + return true + default: + return false + } + } + } } /// A ChannelHandler to validate that outbound response headers are spec-compliant. @@ -57,7 +130,7 @@ public final class NIOHTTPRequestHeadersValidator: ChannelOutboundHandler, Remov /// are emitted on the network. If a header block is invalid, then ``NIOHTTPResponseHeadersValidator`` /// will send a ``HTTPParserError/invalidHeaderToken``. /// -/// ``NIOHTTPResponseHeadersValidator`` will also valid that the HTTP trailers are within specification, +/// ``NIOHTTPResponseHeadersValidator`` will also validate that the HTTP trailers are within specification, /// if they are present. public final class NIOHTTPResponseHeadersValidator: ChannelOutboundHandler, RemovableChannelHandler { public typealias OutboundIn = HTTPServerResponsePart @@ -79,7 +152,7 @@ public final class NIOHTTPResponseHeadersValidator: ChannelOutboundHandler, Remo public func write(context: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise<Void>?) { switch (NIOHTTPResponseHeadersValidator.unwrapOutboundIn(data), self.state) { case (.head(let head), .validating): - if head.headers.areValidToSend { + if head.headers.areValidToSend, head.status.isValidToSend { context.write(data, promise: promise) } else { self.state = .dropping @@ -111,3 +184,169 @@ extension NIOHTTPRequestHeadersValidator: Sendable {} @available(*, unavailable) extension NIOHTTPResponseHeadersValidator: Sendable {} + +extension HTTPMethod { + /// Whether these HTTPHeaders are valid to send on the wire. + var isValidToSend: Bool { + switch self { + case .GET, + .PUT, + .ACL, + .HEAD, + .POST, + .COPY, + .LOCK, + .MOVE, + .BIND, + .LINK, + .PATCH, + .TRACE, + .MKCOL, + .MERGE, + .PURGE, + .NOTIFY, + .SEARCH, + .UNLOCK, + .REBIND, + .UNBIND, + .REPORT, + .DELETE, + .UNLINK, + .CONNECT, + .MSEARCH, + .OPTIONS, + .PROPFIND, + .CHECKOUT, + .PROPPATCH, + .SUBSCRIBE, + .MKCALENDAR, + .MKACTIVITY, + .UNSUBSCRIBE, + .SOURCE: + true + + case .RAW(value: let value): + // The spec in [RFC 9110](https://httpwg.org/specs/rfc9110.html#method.overview) defines the valid + // characters as the following: + // + // ``` + // method = token + // + // token = 1*tchar + // + // tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" + // / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~" + // / DIGIT / ALPHA + // ; any VCHAR, except delimiters + + value.utf8.allSatisfy { byte in + switch byte { + case + // ALPHA + UInt8(ascii: "A")...UInt8(ascii: "Z"), UInt8(ascii: "a")...UInt8(ascii: "z"), + // DIGIT + UInt8(ascii: "0")...UInt8(ascii: "9"), + // token + UInt8(ascii: "!"), UInt8(ascii: "#"), UInt8(ascii: "$"), UInt8(ascii: "%"), + UInt8(ascii: "&"), UInt8(ascii: "'"), UInt8(ascii: "*"), UInt8(ascii: "+"), + UInt8(ascii: "-"), UInt8(ascii: "."), UInt8(ascii: "^"), UInt8(ascii: "_"), + UInt8(ascii: "`"), UInt8(ascii: "|"), UInt8(ascii: "~"): + true + default: + false + } + } + } + } +} + +extension HTTPResponseStatus { + var isValidToSend: Bool { + switch self { + case .continue, + .switchingProtocols, + .processing, + .ok, + .created, + .accepted, + .nonAuthoritativeInformation, + .noContent, + .resetContent, + .partialContent, + .multiStatus, + .alreadyReported, + .imUsed, + .multipleChoices, + .movedPermanently, + .found, + .seeOther, + .notModified, + .useProxy, + .temporaryRedirect, + .permanentRedirect, + .badRequest, + .unauthorized, + .paymentRequired, + .forbidden, + .notFound, + .methodNotAllowed, + .notAcceptable, + .proxyAuthenticationRequired, + .requestTimeout, + .conflict, + .gone, + .lengthRequired, + .preconditionFailed, + .payloadTooLarge, + .uriTooLong, + .unsupportedMediaType, + .rangeNotSatisfiable, + .expectationFailed, + .imATeapot, + .misdirectedRequest, + .unprocessableEntity, + .locked, + .failedDependency, + .upgradeRequired, + .preconditionRequired, + .tooManyRequests, + .requestHeaderFieldsTooLarge, + .unavailableForLegalReasons, + .internalServerError, + .notImplemented, + .badGateway, + .serviceUnavailable, + .gatewayTimeout, + .httpVersionNotSupported, + .variantAlsoNegotiates, + .insufficientStorage, + .loopDetected, + .notExtended, + .networkAuthenticationRequired: + true + + case .custom(_, let reasonPhrase): + // The spec in [RFC 9112](https://datatracker.ietf.org/doc/html/rfc9112#section-4) defines the valid + // characters as the following: + // + // ``` + // reason-phrase = 1*( HTAB / SP / VCHAR / obs-text ) + // + // obs-text = %x80-FF + // ``` + + reasonPhrase.utf8.allSatisfy { byte in + switch byte { + case + 9, // HTAB + 32, // SP + 33...126, // VCHAR + 128...255: // obs-text + return true + default: + return false + } + } + } + } +}
Tests/NIOHTTP1Tests/HTTPHeaderValidationTests.swift+268 −0 modified@@ -501,6 +501,223 @@ import Testing } } + @Test func encodingInvalidUriInRequest() throws { + // The spec in [RFC 9112](https://datatracker.ietf.org/doc/html/rfc9112#section-3.2) defines the valid + // characters for the request-target as the following: + // + // ``` + // request-target = origin-form / absolute-form / authority-form / asterisk-form + // + // origin-form = absolute-path [ "?" query ] + // absolute-form = absolute-URI + // authority-form = uri-host ":" port ; CONNECT only + // asterisk-form = "*" ; OPTIONS only + // ``` + // + // The component grammar comes from [RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986#section-3) + // (updated by [RFC 8820](https://datatracker.ietf.org/doc/html/rfc8820), which adds best-practice + // guidance for URI design but does not change the syntax): + // + // ``` + // absolute-path = 1*( "/" segment ) + // segment = *pchar + // query = *( pchar / "/" / "?" ) + // + // pchar = unreserved / pct-encoded / sub-delims / ":" / "@" + // pct-encoded = "%" HEXDIG HEXDIG + // + // unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" + // reserved = gen-delims / sub-delims + // gen-delims = ":" / "/" / "?" / "#" / "[" / "]" / "@" + // sub-delims = "!" / "$" / "&" / "'" / "(" / ")" + // / "*" / "+" / "," / ";" / "=" + // ``` + // + // In other words, the literal byte set allowed on the wire is: + // + // ``` + // ALPHA %x41-5A / %x61-7A ; A–Z a–z + // DIGIT %x30-39 ; 0–9 + // unreserved "-" "." "_" "~" + // gen-delims ":" "/" "?" "#" "[" "]" "@" + // sub-delims "!" "$" "&" "'" "(" ")" "*" "+" "," ";" "=" + // pct-encoded "%" HEXDIG HEXDIG ; escape for anything else + // ``` + // + // Everything outside this set — SP, CTLs (%x00-1F / %x7F), non-ASCII (%x80-FF), + // and `" < > \ ^ ` { | }` — MUST be percent-encoded. Bare CR, LF, or NUL in + // the request-target MUST be rejected (request smuggling / response splitting). + + let allowed = "-._~:/?#[]@!$&'()*+,;=%0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + + let channel = EmbeddedChannel() + try channel.pipeline.syncOperations.addHTTPClientHandlers() + + let headers = HTTPHeaders([("Host", "example.com")]) + let goodRequest = HTTPRequestHead(version: .http1_1, method: .GET, uri: allowed, headers: headers) + let goodRequestBytes = ByteBuffer( + string: "GET \(allowed) HTTP/1.1\r\nHost: example.com\r\n\r\n" + ) + + #expect(throws: Never.self) { try channel.writeOutbound(HTTPClientRequestPart.head(goodRequest)) } + #expect(throws: Never.self) { try channel.writeOutbound(HTTPClientRequestPart.end(nil)) } + #expect(try channel.readOutbound(as: ByteBuffer.self) == goodRequestBytes) + + // Now confirm all other bytes are rejected. + for byte in UInt8(0)...UInt8(255) { + // Skip bytes that we already believe are allowed. + if allowed.utf8.contains(byte) { + continue + } + + guard let disallowedBytes = Self.makeStringContainingLiteralByte(byte) else { + continue + } + + let forbiddenUri = allowed + disallowedBytes + + let channel = EmbeddedChannel() + try channel.pipeline.syncOperations.addHTTPClientHandlers() + + let headers = HTTPHeaders([("Host", "example.com")]) + let badRequest = HTTPRequestHead(version: .http1_1, method: .GET, uri: forbiddenUri, headers: headers) + + let error = #expect( + throws: HTTPParserError.self, + "Incorrectly tolerated character in method: \(String(decoding: [byte], as: UTF8.self))" + ) { + try channel.writeOutbound(HTTPClientRequestPart.head(badRequest)) + } + #expect(error == .invalidHeaderToken) + _ = try? channel.finish() + } + } + + @Test func encodingInvalidMethodInRequests() throws { + // The spec in [RFC 9110](https://httpwg.org/specs/rfc9110.html#method.overview) defines the valid + // characters as the following: + // + // ``` + // method = token + // + // token = 1*tchar + // + // tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" + // / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~" + // / DIGIT / ALPHA + // ; any VCHAR, except delimiters + let weirdAllowedMethodName = + "!#$%&'*+-.^_`|~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + + let channel = EmbeddedChannel() + try channel.pipeline.syncOperations.addHTTPClientHandlers() + + let headers = HTTPHeaders([("Host", "example.com")]) + let goodRequest = HTTPRequestHead(version: .http1_1, method: .RAW(value: weirdAllowedMethodName), uri: "/", headers: headers) + let goodRequestBytes = ByteBuffer( + string: "\(weirdAllowedMethodName) / HTTP/1.1\r\nHost: example.com\r\n\r\n" + ) + + #expect(throws: Never.self) { try channel.writeOutbound(HTTPClientRequestPart.head(goodRequest)) } + #expect(throws: Never.self) { try channel.writeOutbound(HTTPClientRequestPart.end(nil)) } + #expect(try channel.readOutbound(as: ByteBuffer.self) == goodRequestBytes) + + // Now confirm all other bytes are rejected. + for byte in UInt8(0)...UInt8(255) { + // Skip bytes that we already believe are allowed. + if weirdAllowedMethodName.utf8.contains(byte) { + continue + } + guard let extraString = Self.makeStringContainingLiteralByte(byte) else { + continue + } + + let forbiddenFieldName = weirdAllowedMethodName + extraString + + let channel = EmbeddedChannel() + try channel.pipeline.syncOperations.addHTTPClientHandlers() + + let headers = HTTPHeaders([("Host", "example.com")]) + let badRequest = HTTPRequestHead(version: .http1_1, method: .RAW(value: forbiddenFieldName), uri: "/", headers: headers) + + let error = #expect( + throws: HTTPParserError.self, + "Incorrectly tolerated character in method: \(extraString)" + ) { + try channel.writeOutbound(HTTPClientRequestPart.head(badRequest)) + } + #expect(error == .invalidHeaderToken) + _ = try? channel.finish() + } + } + + @Test func encodingInvalidStatusReasonInResponses() throws { + // The spec in [RFC 9112](https://datatracker.ietf.org/doc/html/rfc9112#section-4) defines the valid + // characters as the following: + // + // ``` + // reason-phrase = 1*( HTAB / SP / VCHAR / obs-text ) + // + // obs-text = %x80-FF + // ``` + + let allowedRanges: [ClosedRange<UInt8>] = [ + 9...9, // HTAB + 32...32, // SP + 33...126, // VCHAR + 128...255, // obs-text + ] + + let base = "foo" + let headers = HTTPHeaders([("transfer-encoding", "chunked")]) + + // Now confirm all other bytes in the ASCII range are rejected. + for byte in UInt8(0)..<UInt8(255) { + let allowed = allowedRanges.contains(where: { $0.contains(byte) }) + + guard let literalByteCanBeRepresented = Self.makeStringContainingLiteralByte(byte) else { + continue + } + + let testReason = base + literalByteCanBeRepresented + + let channel = EmbeddedChannel() + try channel.pipeline.syncOperations.configureHTTPServerPipeline(withErrorHandling: false) + try channel.primeForResponse() + + let response = HTTPResponseHead( + version: .http1_1, + status: .custom(code: 600, reasonPhrase: testReason), + headers: headers + ) + + switch allowed { + case true: + let goodRequestBytes = ByteBuffer(string: "HTTP/1.1 600 \(testReason)\r\ntransfer-encoding: chunked\r\n\r\n") + let goodEnd = ByteBuffer(string: "0\r\n\r\n") + #expect(throws: Never.self, "Rejected reason phrase with byte: \(byte)") { + try channel.writeOutbound(HTTPServerResponsePart.head(response)) + } + let bytes = try channel.readOutbound(as: ByteBuffer.self) + #expect(bytes == goodRequestBytes) + #expect(throws: Never.self) { + try channel.writeOutbound(HTTPServerResponsePart.end(nil)) + } + #expect(try channel.readOutbound(as: ByteBuffer.self) == goodEnd) + case false: + let error = #expect( + throws: HTTPParserError.self, + "Incorrectly tolerated character in reason phrase: \(String(decoding: [byte], as: UTF8.self))" + ) { + try channel.writeOutbound(HTTPServerResponsePart.head(response)) + } + #expect(error == .invalidHeaderToken) + } + + _ = try? channel.finish() + } + } + @Test func responseIsDroppedIfHeadersInvalid() throws { let channel = EmbeddedChannel() try channel.pipeline.syncOperations.configureHTTPServerPipeline(withErrorHandling: false) @@ -597,6 +814,56 @@ import Testing #expect(try channel.readOutbound(as: ByteBuffer.self) == toleratedResponseBytes) #expect(try channel.readOutbound(as: ByteBuffer.self) == toleratedTrailerBytes) } + + /// Returns a valid UTF-8 string whose utf8 bytes *literally contain* `byte`. + /// Returns nil if `byte` cannot appear in any valid UTF-8 sequence. + static func makeStringContainingLiteralByte(_ byte: UInt8) -> String? { + switch byte { + case 0x00...0x7F: + // ASCII — appears as itself + return String(Unicode.Scalar(byte)) + + case 0x80...0xBF: + // Continuation byte — pair with lead 0xC2 to form U+0080…U+00BF + // UTF-8 of U+00XX (for 0x80…0xBF) is [0xC2, 0xXX] + return String(Unicode.Scalar(byte)) + + case 0xC0, 0xC1: + return nil // forbidden in UTF-8 + + case 0xC2...0xDF: + // Lead of 2-byte seq. Smallest scalar with this lead: + // scalar = (byte & 0x1F) << 6 → UTF-8 = [byte, 0x80] + let scalar = UInt32(byte & 0x1F) << 6 + return Unicode.Scalar(scalar).map { String($0) } + + case 0xE0...0xEF: + // Lead of 3-byte seq. Smallest non-overlong scalar: + // 0xE0 → U+0800 (special: must be ≥ 0x0800 to avoid overlong) + // 0xE1…0xEF → (byte & 0x0F) << 12 + let base = UInt32(byte & 0x0F) << 12 + let scalar = (byte == 0xE0) ? 0x0800 : base + // Skip surrogates if we land in D800–DFFF (byte == 0xED) + if byte == 0xED { return String(Unicode.Scalar(0xD000 - 0x0800 + base)!) } // simple pick + return Unicode.Scalar(scalar).map { String($0) } + + case 0xF0...0xF4: + // Lead of 4-byte seq. + // 0xF0 → U+10000 (minimum to avoid overlong) + // 0xF1…0xF3 → (byte & 0x07) << 18 + // 0xF4 → must be ≤ U+10FFFF + let scalar: UInt32 + switch byte { + case 0xF0: scalar = 0x10000 + case 0xF4: scalar = 0x100000 + default: scalar = UInt32(byte & 0x07) << 18 + } + return Unicode.Scalar(scalar).map { String($0) } + + default: // 0xF5...0xFF + return nil // forbidden in UTF-8 + } + } } extension EmbeddedChannel { @@ -605,3 +872,4 @@ extension EmbeddedChannel { try self.writeInbound(request) } } +
Vulnerability mechanics
Synthesis attempt was rejected by the grounding validator. Re-run pending.
References
2News mentions
0No linked articles in our index yet.