SwiftNIO: Out-of-bounds write via ByteBuffer index and length UInt32 overflow
Description
SwiftNIO ByteBuffer truncates attacker-controlled index/length values exceeding UInt32.max, enabling out-of-bounds writes.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
SwiftNIO ByteBuffer truncates attacker-controlled index/length values exceeding UInt32.max, enabling out-of-bounds writes.
Vulnerability
A program using swift-nio is vulnerable to an out-of-bounds write when attacker-controlled Int values exceeding UInt32.max are passed to certain ByteBuffer methods [1][2]. The internal helpers _toIndex and _toCapacity convert from Int to UInt32 using UInt32(truncatingIfNeeded:), which on 64‑bit platforms silently discards the upper 32 bits instead of trapping on overflow. This causes safety preconditions to pass incorrectly, and subsequent operations use the truncated value. Affected versions are all swift-nio releases from 1.0.0 through 2.99.0 [1][2].
Exploitation
An attacker must be able to supply an index or length parameter larger than UInt32.max (4 294 967 295) to one of the vulnerable ByteBuffer methods. No special authentication or network position is required beyond the ability to send these values (for example, through parsing malformed network data or invoking an API that forwards the values). The two methods that can lead to out‑of‑bounds writes are copyBytes(at:to:length:) — a crafted destination index causes bytes to be copied to an incorrect offset — and writeWithUnsafeMutableBytes(minimumWritableBytes:) — a crafted minimumWritableBytes provides a buffer pointer of incorrect length, which can easily be subsequently overflowed [1][2].
Impact
Successful exploitation leads to an out‑of‑bounds memory write. An attacker could overwrite adjacent heap memory, potentially causing a crash or achieving arbitrary code execution in the context of the affected process. The impact is limited to the two methods explicitly identified as exposing out‑of‑bounds writes; other affected methods (e.g., moveReaderIndex(forwardBy:), moveWriterIndex(forwardBy:), and the ByteBuffer(takingOwnershipOf:allocator:) initializer) exhibit only logic errors and do not directly expose out‑of‑bounds reads or writes [1][2].
Mitigation
The vulnerability is fixed in swift-nio version 2.100.0 and later releases [1][2]. Users should upgrade to at least 2.100.0. No official workaround is available; the only mitigation is to apply the patched version.
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: >= 1.0.0, <= 2.99.0
Patches
457c0a08a331aSpeed up the tests (#3601)
4 files changed · +42 −34
Sources/NIOHTTP1/HTTPHeaderValidator.swift+12 −13 modified@@ -31,7 +31,9 @@ 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 Self.uriOnlyContainsAllowedCharacters(head.uri), head.method.isValidToSend, 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 @@ -98,10 +100,9 @@ public final class NIOHTTPRequestHeadersValidator: ChannelOutboundHandler, Remov uri.utf8.allSatisfy { byte in switch byte { - case - // unreserved - // - ALPHA - UInt8(ascii: "A")...UInt8(ascii: "Z"), UInt8(ascii: "a")...UInt8(ascii: "z"), + case // unreserved + // - ALPHA + UInt8(ascii: "A")...UInt8(ascii: "Z"), UInt8(ascii: "a")...UInt8(ascii: "z"), // - DIGIT UInt8(ascii: "0")...UInt8(ascii: "9"), // - extra characters @@ -225,7 +226,7 @@ extension HTTPMethod { .SOURCE: true - case .RAW(value: let value): + case .RAW(let value): // The spec in [RFC 9110](https://httpwg.org/specs/rfc9110.html#method.overview) defines the valid // characters as the following: // @@ -241,9 +242,8 @@ extension HTTPMethod { value.utf8.allSatisfy { byte in switch byte { - case - // ALPHA - UInt8(ascii: "A")...UInt8(ascii: "Z"), UInt8(ascii: "a")...UInt8(ascii: "z"), + case // ALPHA + UInt8(ascii: "A")...UInt8(ascii: "Z"), UInt8(ascii: "a")...UInt8(ascii: "z"), // DIGIT UInt8(ascii: "0")...UInt8(ascii: "9"), // token @@ -337,11 +337,10 @@ extension HTTPResponseStatus { reasonPhrase.utf8.allSatisfy { byte in switch byte { - case - 9, // HTAB - 32, // SP + case 9, // HTAB + 32, // SP 33...126, // VCHAR - 128...255: // obs-text + 128...255: // obs-text return true default: return false
Sources/NIOHTTP1/HTTPPipelineSetup.swift+1 −1 modified@@ -156,6 +156,7 @@ extension ChannelPipeline { /// - enableOutboundHeaderValidation: Whether the pipeline should confirm that outbound headers are well-formed. /// Defaults to `true`. /// - encoderConfiguration: The configuration for the ``HTTPRequestEncoder``. + /// - decoderLimitConfiguration: The limit configuration for the ``HTTPDecoder``. /// - upgrade: Add a ``NIOHTTPClientUpgradeHandler`` to the pipeline, configured for /// HTTP upgrade. Should be a tuple of an array of ``NIOHTTPClientUpgradeHandler`` and /// the upgrade completion handler. See the documentation on ``NIOHTTPClientUpgradeHandler`` @@ -389,7 +390,6 @@ extension ChannelPipeline { ) } - private func _configureHTTPServerPipeline( position: ChannelPipeline.Position = .last, withPipeliningAssistance pipelining: Bool = true,
Tests/NIOCoreTests/ByteBufferCrashTests.swift+6 −8 modified@@ -12,9 +12,9 @@ // //===----------------------------------------------------------------------===// +import Foundation @_spi(CustomByteBufferAllocator) import NIOCore import Testing -import Foundation #if compiler(>=6.2) @Suite struct ByteBufferCrashTests { @@ -91,7 +91,11 @@ import Foundation } } - @Test func setBytesWithoutContigiousStorageMoreThanUInt32maxBytes() async { + @Test( + .disabled( + "This test is taking too long, as it needs to allocate 4GB of memory. It doesn't work on 32bit machines." + ) + ) func setBytesWithoutContigiousStorageMoreThanUInt32maxBytes() async { await #expect(processExitsWith: .failure) { let circularBuffer = CircularBuffer<UInt8>(repeating: 0, count: Int(UInt32.max) + 1) var bb = ByteBuffer() @@ -138,12 +142,6 @@ import Foundation let capacity = Int(UInt32.max) + 1 let ptr = malloc(capacity)! - // Initialize some data in the external memory - let boundPtr = ptr.bindMemory(to: UInt8.self, capacity: capacity) - for i in 0..<capacity { - boundPtr[i] = 1 - } - let allocator = ByteBufferCustomAllocatorTest.makeTrackedAllocator() // this should crash
Tests/NIOHTTP1Tests/HTTPHeaderValidationTests.swift+23 −12 modified@@ -613,7 +613,12 @@ import Testing 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 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" ) @@ -638,7 +643,12 @@ import Testing 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 badRequest = HTTPRequestHead( + version: .http1_1, + method: .RAW(value: forbiddenFieldName), + uri: "/", + headers: headers + ) let error = #expect( throws: HTTPParserError.self, @@ -662,9 +672,9 @@ import Testing // ``` let allowedRanges: [ClosedRange<UInt8>] = [ - 9...9, // HTAB - 32...32, // SP - 33...126, // VCHAR + 9...9, // HTAB + 32...32, // SP + 33...126, // VCHAR 128...255, // obs-text ] @@ -693,7 +703,9 @@ import Testing switch allowed { case true: - let goodRequestBytes = ByteBuffer(string: "HTTP/1.1 600 \(testReason)\r\ntransfer-encoding: chunked\r\n\r\n") + 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)) @@ -829,7 +841,7 @@ import Testing return String(Unicode.Scalar(byte)) case 0xC0, 0xC1: - return nil // forbidden in UTF-8 + return nil // forbidden in UTF-8 case 0xC2...0xDF: // Lead of 2-byte seq. Smallest scalar with this lead: @@ -844,7 +856,7 @@ import Testing 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 + if byte == 0xED { return String(Unicode.Scalar(0xD000 - 0x0800 + base)!) } // simple pick return Unicode.Scalar(scalar).map { String($0) } case 0xF0...0xF4: @@ -856,12 +868,12 @@ import Testing switch byte { case 0xF0: scalar = 0x10000 case 0xF4: scalar = 0x100000 - default: scalar = UInt32(byte & 0x07) << 18 + default: scalar = UInt32(byte & 0x07) << 18 } return Unicode.Scalar(scalar).map { String($0) } - default: // 0xF5...0xFF - return nil // forbidden in UTF-8 + default: // 0xF5...0xFF + return nil // forbidden in UTF-8 } } } @@ -872,4 +884,3 @@ extension EmbeddedChannel { try self.writeInbound(request) } } -
87f935b70c5eMerge commit from fork
3 files changed · +173 −10
Sources/NIOCore/ByteBuffer-core.swift+8 −4 modified@@ -175,11 +175,11 @@ public struct ByteBufferAllocator: Sendable { } @inlinable func _toCapacity(_ value: Int) -> ByteBuffer._Capacity { - ByteBuffer._Capacity(truncatingIfNeeded: value) + ByteBuffer._Capacity(value) } @inlinable func _toIndex(_ value: Int) -> ByteBuffer._Index { - ByteBuffer._Index(truncatingIfNeeded: value) + ByteBuffer._Index(value) } /// `ByteBuffer` stores contiguously allocated raw bytes. It is a random and sequential accessible sequence of zero or @@ -1061,8 +1061,12 @@ public struct ByteBuffer { else { return nil } - let index = _toIndex(index) - let length = _toCapacity(length) + // Thanks to the above bounds checks, we can translate into `_Index` and `_Capacity` with + // fewer branches, using `truncatingIfNeeded` initializers. + // - length <= self.writerIndex // this ensures length is in the UInt32 space. + // - index <= self.writerIndex &- length. This ensures index is in the UInt32 space. + let index = ByteBuffer._Index(truncatingIfNeeded: index) + let length = ByteBuffer._Capacity(truncatingIfNeeded: length) // The arithmetic below is safe because: // 1. maximum `writerIndex` <= self._slice.count (see `_moveWriterIndex`)
Tests/NIOCoreTests/ByteBufferCrashTests.swift+159 −0 added@@ -0,0 +1,159 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftNIO open source project +// +// Copyright (c) 2026 Apple Inc. and the SwiftNIO project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftNIO project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +@_spi(CustomByteBufferAllocator) import NIOCore +import Testing +import Foundation + +#if compiler(>=6.2) +@Suite struct ByteBufferCrashTests { + + @Test func copyBytesToIndexExceedingUInt32Max() async { + await #expect(processExitsWith: .failure) { + var buf = ByteBufferAllocator().buffer(capacity: 256) + buf.writeBytes([UInt8](repeating: 0x41, count: 64)) + let toIndex = Int(UInt32.max) + 1 + try buf.copyBytes(at: 0, to: toIndex, length: 64) + } + } + + @Test func writeWithUnsafeMutableBytesCrashesWhenWritingMoreThanUInt32maxBytes() async { + await #expect(processExitsWith: .failure) { + var buf = ByteBufferAllocator().buffer(capacity: 0) + // ask for more capacity then ByteBuffer can provide. + buf.writeWithUnsafeMutableBytes(minimumWritableBytes: Int(UInt32.max) + 2) { ptr in + Issue.record("This should not be called") + // in release without bounds checks, devs can write to + // larger than 1 here. + #expect(ptr.count == 1) + return 1 + } + } + } + + @Test func moveReaderIndexTooFar() async { + await #expect(processExitsWith: .failure) { + var bb = ByteBuffer() + bb.writeString("Hello World") + bb.moveReaderIndex(forwardBy: 32) + } + } + + @Test func moveReaderIndexWayTooFar() async { + await #expect(processExitsWith: .failure) { + var bb = ByteBuffer() + bb.writeString("Hello World") + bb.moveReaderIndex(forwardBy: Int(UInt32.max) + 1) + } + } + + @Test func moveWriterIndexTooFar() async { + await #expect(processExitsWith: .failure) { + var bb = ByteBuffer() + bb.reserveCapacity(64) + bb.moveWriterIndex(forwardBy: 70) + } + } + + @Test func moveWriterIndexWayTooFar() async { + await #expect(processExitsWith: .failure) { + var bb = ByteBuffer() + bb.reserveCapacity(64) + bb.moveWriterIndex(forwardBy: Int(UInt32.max) + 1) + } + } + + @Test func setBytesWithMoreThanUInt32maxBytes() async { + await #expect(processExitsWith: .failure) { + let sequence = [UInt8](repeating: 0, count: Int(UInt32.max) + 1) + var bb = ByteBuffer() + bb.reserveCapacity(64) + bb.setBytes(sequence, at: bb.writerIndex) + } + } + + @Test func setBytesAtIndexAfterUInt32max() async { + await #expect(processExitsWith: .failure) { + var bb = ByteBuffer() + bb.reserveCapacity(64) + bb.setBytes([1], at: Int(UInt32.max) + 1) + } + } + + @Test func setBytesWithoutContigiousStorageMoreThanUInt32maxBytes() async { + await #expect(processExitsWith: .failure) { + let circularBuffer = CircularBuffer<UInt8>(repeating: 0, count: Int(UInt32.max) + 1) + var bb = ByteBuffer() + bb.reserveCapacity(64) + bb.setBytes(circularBuffer, at: bb.writerIndex) + } + } + + @Test func setBytesWithoutContigiousStorageAfterUInt32max() async { + await #expect(processExitsWith: .failure) { + let circularBuffer = CircularBuffer<UInt8>(repeating: 0, count: 4) + var bb = ByteBuffer() + bb.reserveCapacity(64) + bb.setBytes(circularBuffer, at: Int(UInt32.max) + 1) + } + } + + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, *) + @Test func setRawSpanWithMoreThanUInt32maxBytes() async { + await #expect(processExitsWith: .failure) { + let count = Int(UInt32.max) + 1 + let sequence = [UInt8](repeating: 0, count: count) + var bb = ByteBuffer() + bb.reserveCapacity(64) + let rawSpan = sequence.span.bytes + #expect(rawSpan.byteCount == count) + bb.setBytes(sequence.span.bytes, at: bb.writerIndex) + } + } + + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, *) + @Test func setRawSpanAfterUInt32max() async { + await #expect(processExitsWith: .failure) { + let sequence = [UInt8](repeating: 0, count: 4) + var bb = ByteBuffer() + bb.reserveCapacity(64) + let rawSpan = sequence.span.bytes + bb.setBytes(rawSpan, at: Int(UInt32.max) + 1) + } + } + + @Test func takingOwnershipOfPointerThatsToLarge() async { + await #expect(processExitsWith: .failure) { + let capacity = Int(UInt32.max) + 1 + let ptr = malloc(capacity)! + + // Initialize some data in the external memory + let boundPtr = ptr.bindMemory(to: UInt8.self, capacity: capacity) + for i in 0..<capacity { + boundPtr[i] = 1 + } + + let allocator = ByteBufferCustomAllocatorTest.makeTrackedAllocator() + + // this should crash + let buffer = ByteBuffer( + takingOwnershipOf: UnsafeMutableRawBufferPointer(start: ptr, count: capacity), + allocator: allocator + ) + + _ = buffer + } + } +} +#endif
Tests/NIOCoreTests/ByteBufferCustomAllocatorTest.swift+6 −6 modified@@ -2,7 +2,7 @@ // // This source file is part of the SwiftNIO open source project // -// Copyright (c) 2017-2018 Apple Inc. and the SwiftNIO project authors +// Copyright (c) 2017-2026 Apple Inc. and the SwiftNIO project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -95,7 +95,7 @@ class ByteBufferCustomAllocatorTest: XCTestCase { testTracker.reset() } - private func makeTrackedAllocator() -> ByteBufferAllocator { + static func makeTrackedAllocator() -> ByteBufferAllocator { ByteBufferAllocator( allocate: testMallocHook, reallocate: testReallocHook, @@ -105,7 +105,7 @@ class ByteBufferCustomAllocatorTest: XCTestCase { } func testCustomAllocatorReceivesCorrectReallocSizes() { - let allocator = self.makeTrackedAllocator() + let allocator = Self.makeTrackedAllocator() var buffer = allocator.buffer(capacity: 64) XCTAssertEqual(testTracker.mallocCalls.count, 1) @@ -125,7 +125,7 @@ class ByteBufferCustomAllocatorTest: XCTestCase { } func testCustomAllocatorReallocReceivesOldSizeOnMultipleGrows() { - let allocator = self.makeTrackedAllocator() + let allocator = Self.makeTrackedAllocator() var buffer = allocator.buffer(capacity: 16) @@ -164,7 +164,7 @@ class ByteBufferCustomAllocatorTest: XCTestCase { boundPtr[i] = UInt8(i) } - let allocator = self.makeTrackedAllocator() + let allocator = Self.makeTrackedAllocator() var buffer = ByteBuffer( takingOwnershipOf: UnsafeMutableRawBufferPointer(start: ptr, count: capacity), @@ -241,7 +241,7 @@ class ByteBufferCustomAllocatorTest: XCTestCase { } func testCustomAllocatorFreeCalled() { - let allocator = self.makeTrackedAllocator() + let allocator = Self.makeTrackedAllocator() do { var buffer = allocator.buffer(capacity: 64)
b24872d3aa4aMerge commit from fork
5 files changed · +677 −75
Sources/NIOHTTP1/HTTPDecoder.swift+71 −7 modified@@ -39,16 +39,18 @@ private enum HTTPDecodingState { } private class BetterHTTPParser { - /// Maximum size of a HTTP header field name or value. - /// This number is derived largely from the historical behaviour of NIO. - private static let maximumHeaderFieldSize = 80 * 1024 + private let maximumHeaderFieldSize: Int + private let maximumTotalHeadersSize: Int + private let maximumHeaderFieldCount: Int var delegate: HTTPDecoderDelegate! = nil private var parser: llhttp_t? = llhttp_t() // nil if unaccessible because reference passed away exclusively private var settings: UnsafeMutablePointer<llhttp_settings_t> private var decodingState: HTTPDecodingState = .beforeMessageBegin private var firstNonDiscardableOffset: Int? = nil private var currentFieldByteLength = 0 + private var totalHeadersByteLength = 0 + private var headerFieldCount = 0 private var httpParserOffset = 0 private var rawBytesView: UnsafeRawBufferPointer = .init(start: UnsafeRawPointer(bitPattern: 0xcafbabe), count: 0) private var httpErrno: llhttp_errno_t? = nil @@ -66,8 +68,11 @@ private class BetterHTTPParser { Unmanaged<BetterHTTPParser>.fromOpaque(UnsafeRawPointer(opaque!.pointee.data)).takeUnretainedValue() } - init(kind: HTTPDecoderKind) { + init(kind: HTTPDecoderKind, configuration: NIOHTTPDecoderLimitConfiguration) { self.kind = kind + self.maximumHeaderFieldSize = configuration.maxHeaderFieldSize + self.maximumTotalHeadersSize = configuration.maxHeaderListSize + self.maximumHeaderFieldCount = configuration.maxHeaderFieldCount self.settings = UnsafeMutablePointer.allocate(capacity: 1) c_nio_llhttp_settings_init(self.settings) self.settings.pointee.on_body = { opaque, bytes, len in @@ -164,6 +169,7 @@ private class BetterHTTPParser { } private func didReceiveHeaderFieldData(_ bytes: UnsafeRawBufferPointer) -> CInt { + var isNewField = false switch self.decodingState { case .headerName, .trailerName: () @@ -172,25 +178,37 @@ private class BetterHTTPParser { delegate.didReceiveHeaderValue(bytes) } self.start(bytes: bytes, newState: .headerName) + isNewField = true case .trailerValue: self.finish { delegate, bytes in delegate.didReceiveTrailerValue(bytes) } self.start(bytes: bytes, newState: .trailerName) + isNewField = true case .url: self.finish { delegate, bytes in delegate.didReceiveURL(bytes) } self.start(bytes: bytes, newState: .headerName) + isNewField = true case .headersComplete: // these are trailers self.start(bytes: bytes, newState: .trailerName) + isNewField = true case .afterMessageBegin: // in case we're parsing responses self.start(bytes: bytes, newState: .headerName) + isNewField = true case .beforeMessageBegin: preconditionFailure() } + if isNewField { + self.headerFieldCount += 1 + if self.headerFieldCount > self.maximumHeaderFieldCount { + self.richerError = HTTPParserError.headerOverflow + return -1 + } + } return self.validateHeaderLength(bytes.count) } @@ -246,6 +264,8 @@ private class BetterHTTPParser { private func didReceiveMessageBeginNotification() { switch self.decodingState { case .beforeMessageBegin: + self.totalHeadersByteLength = 0 + self.headerFieldCount = 0 self.decodingState = .afterMessageBegin case .headersComplete, .headerName, .headerValue, .trailerName, .trailerValue, .afterMessageBegin, .url: preconditionFailure() @@ -369,7 +389,13 @@ private class BetterHTTPParser { private func validateHeaderLength(_ newLength: Int) -> CInt { self.currentFieldByteLength += newLength - if self.currentFieldByteLength > Self.maximumHeaderFieldSize { + if self.currentFieldByteLength > self.maximumHeaderFieldSize { + self.richerError = HTTPParserError.headerOverflow + return -1 + } + + self.totalHeadersByteLength += newLength + if self.totalHeadersByteLength > self.maximumTotalHeadersSize { self.richerError = HTTPParserError.headerOverflow return -1 } @@ -515,6 +541,24 @@ where In == HTTPClientResponsePart, Out == HTTPClientRequestPart { } } +/// Configuration for the HTTP/1 decoder's parsing limits. +public struct NIOHTTPDecoderLimitConfiguration: Sendable, Hashable { + /// Maximum size (in bytes) of a single header field (name + value). Default: 81,920 (80 KB). + public var maxHeaderFieldSize: Int + + /// Maximum total size (in bytes) of all header field names and values combined in a single message. Default: 2,097,152 (2 MB). + public var maxHeaderListSize: Int + + /// Maximum number of header fields allowed in a single message (including trailers). Default: 256. + public var maxHeaderFieldCount: Int + + public init() { + self.maxHeaderFieldSize = 80 * 1024 + self.maxHeaderListSize = 16384 * 128 + self.maxHeaderFieldCount = 256 + } +} + /// A `ChannelInboundHandler` that parses HTTP/1-style messages, converting them from /// unstructured bytes to a sequence of HTTP messages. /// @@ -553,16 +597,36 @@ public final class HTTPDecoder<In, Out>: ByteToMessageDecoder, HTTPDecoderDelega self.init(leftOverBytesStrategy: leftOverBytesStrategy, informationalResponseStrategy: .drop) } + /// Creates a new instance of `HTTPDecoder` with default parsing limits. + /// + /// - Parameters: + /// - leftOverBytesStrategy: The strategy to use when removing the decoder from the pipeline and an upgrade was, + /// detected. Note that this does not affect what happens on EOF. + /// - informationalResponseStrategy: Should informational responses (like http status 100) be forwarded or dropped. + /// Default is `.drop`. This property is only respected when decoding responses. + public convenience init( + leftOverBytesStrategy: RemoveAfterUpgradeStrategy = .dropBytes, + informationalResponseStrategy: NIOInformationalResponseStrategy = .drop + ) { + self.init( + leftOverBytesStrategy: leftOverBytesStrategy, + informationalResponseStrategy: informationalResponseStrategy, + limitConfiguration: .init() + ) + } + /// Creates a new instance of `HTTPDecoder`. /// /// - Parameters: /// - leftOverBytesStrategy: The strategy to use when removing the decoder from the pipeline and an upgrade was, /// detected. Note that this does not affect what happens on EOF. /// - informationalResponseStrategy: Should informational responses (like http status 100) be forwarded or dropped. /// Default is `.drop`. This property is only respected when decoding responses. + /// - limitConfiguration: The configuration for the decoder's parsing limits. public init( leftOverBytesStrategy: RemoveAfterUpgradeStrategy = .dropBytes, - informationalResponseStrategy: NIOInformationalResponseStrategy = .drop + informationalResponseStrategy: NIOInformationalResponseStrategy = .drop, + limitConfiguration: NIOHTTPDecoderLimitConfiguration = .init() ) { self.headers.reserveCapacity(16) if In.self == HTTPServerRequestPart.self { @@ -572,7 +636,7 @@ public final class HTTPDecoder<In, Out>: ByteToMessageDecoder, HTTPDecoderDelega } else { preconditionFailure("unknown HTTP message type \(In.self)") } - self.parser = BetterHTTPParser(kind: kind) + self.parser = BetterHTTPParser(kind: kind, configuration: limitConfiguration) self.leftOverBytesStrategy = leftOverBytesStrategy self.informationalResponseStrategy = informationalResponseStrategy }
Sources/NIOHTTP1/HTTPPipelineSetup.swift+205 −62 modified@@ -77,42 +77,42 @@ extension ChannelPipeline { leftOverBytesStrategy: RemoveAfterUpgradeStrategy = .dropBytes, withClientUpgrade upgrade: NIOHTTPClientUpgradeSendableConfiguration? ) -> EventLoopFuture<Void> { - self._addHTTPClientHandlers( + self.addHTTPClientHandlers( position: position, leftOverBytesStrategy: leftOverBytesStrategy, + enableOutboundHeaderValidation: true, withClientUpgrade: upgrade ) } - private func _addHTTPClientHandlers( + /// Configure a `ChannelPipeline` for use as a HTTP client. + /// + /// - Parameters: + /// - position: The position in the `ChannelPipeline` where to add the HTTP client handlers. Defaults to `.last`. + /// - leftOverBytesStrategy: The strategy to use when dealing with leftover bytes after removing the `HTTPDecoder` + /// from the pipeline. + /// - enableOutboundHeaderValidation: Whether the pipeline should confirm that outbound headers are well-formed. + /// Defaults to `true`. + /// - upgrade: Add a ``NIOHTTPClientUpgradeHandler`` to the pipeline, configured for + /// HTTP upgrade. Should be a tuple of an array of ``NIOHTTPClientUpgradeHandler`` and + /// the upgrade completion handler. See the documentation on ``NIOHTTPClientUpgradeHandler`` + /// for more details. + /// - Returns: An `EventLoopFuture` that will fire when the pipeline is configured. + @preconcurrency + public func addHTTPClientHandlers( position: Position = .last, leftOverBytesStrategy: RemoveAfterUpgradeStrategy = .dropBytes, - withClientUpgrade upgrade: NIOHTTPClientUpgradeSendableConfiguration? + enableOutboundHeaderValidation: Bool = true, + withClientUpgrade upgrade: NIOHTTPClientUpgradeSendableConfiguration? = nil ) -> EventLoopFuture<Void> { - let future: EventLoopFuture<Void> - - if self.eventLoop.inEventLoop { - let syncPosition = ChannelPipeline.SynchronousOperations.Position(position) - let result = Result<Void, Error> { - try self.syncOperations.addHTTPClientHandlers( - position: syncPosition, - leftOverBytesStrategy: leftOverBytesStrategy, - withClientUpgrade: upgrade - ) - } - future = self.eventLoop.makeCompletedFuture(result) - } else { - future = self.eventLoop.submit { - let syncPosition = ChannelPipeline.SynchronousOperations.Position(position) - try self.syncOperations.addHTTPClientHandlers( - position: syncPosition, - leftOverBytesStrategy: leftOverBytesStrategy, - withClientUpgrade: upgrade - ) - } - } - - return future + self.addHTTPClientHandlers( + position: position, + leftOverBytesStrategy: leftOverBytesStrategy, + enableOutboundHeaderValidation: enableOutboundHeaderValidation, + encoderConfiguration: .init(), + decoderLimitConfiguration: .init(), + withClientUpgrade: upgrade + ) } /// Configure a `ChannelPipeline` for use as a HTTP client. @@ -123,6 +123,7 @@ extension ChannelPipeline { /// from the pipeline. /// - enableOutboundHeaderValidation: Whether the pipeline should confirm that outbound headers are well-formed. /// Defaults to `true`. + /// - encoderConfiguration: The configuration for the ``HTTPRequestEncoder``. /// - upgrade: Add a ``NIOHTTPClientUpgradeHandler`` to the pipeline, configured for /// HTTP upgrade. Should be a tuple of an array of ``NIOHTTPClientUpgradeHandler`` and /// the upgrade completion handler. See the documentation on ``NIOHTTPClientUpgradeHandler`` @@ -133,34 +134,17 @@ extension ChannelPipeline { position: Position = .last, leftOverBytesStrategy: RemoveAfterUpgradeStrategy = .dropBytes, enableOutboundHeaderValidation: Bool = true, + encoderConfiguration: HTTPRequestEncoder.Configuration = .init(), withClientUpgrade upgrade: NIOHTTPClientUpgradeSendableConfiguration? = nil ) -> EventLoopFuture<Void> { - let future: EventLoopFuture<Void> - - if self.eventLoop.inEventLoop { - let syncPosition = ChannelPipeline.SynchronousOperations.Position(position) - let result = Result<Void, Error> { - try self.syncOperations.addHTTPClientHandlers( - position: syncPosition, - leftOverBytesStrategy: leftOverBytesStrategy, - enableOutboundHeaderValidation: enableOutboundHeaderValidation, - withClientUpgrade: upgrade - ) - } - future = self.eventLoop.makeCompletedFuture(result) - } else { - future = self.eventLoop.submit { - let syncPosition = ChannelPipeline.SynchronousOperations.Position(position) - try self.syncOperations.addHTTPClientHandlers( - position: syncPosition, - leftOverBytesStrategy: leftOverBytesStrategy, - enableOutboundHeaderValidation: enableOutboundHeaderValidation, - withClientUpgrade: upgrade - ) - } - } - - return future + self.addHTTPClientHandlers( + position: position, + leftOverBytesStrategy: leftOverBytesStrategy, + enableOutboundHeaderValidation: enableOutboundHeaderValidation, + encoderConfiguration: encoderConfiguration, + decoderLimitConfiguration: .init(), + withClientUpgrade: upgrade + ) } /// Configure a `ChannelPipeline` for use as a HTTP client. @@ -183,6 +167,7 @@ extension ChannelPipeline { leftOverBytesStrategy: RemoveAfterUpgradeStrategy = .dropBytes, enableOutboundHeaderValidation: Bool = true, encoderConfiguration: HTTPRequestEncoder.Configuration = .init(), + decoderLimitConfiguration: NIOHTTPDecoderLimitConfiguration = .init(), withClientUpgrade upgrade: NIOHTTPClientUpgradeSendableConfiguration? = nil ) -> EventLoopFuture<Void> { let future: EventLoopFuture<Void> @@ -195,6 +180,7 @@ extension ChannelPipeline { leftOverBytesStrategy: leftOverBytesStrategy, enableOutboundHeaderValidation: enableOutboundHeaderValidation, encoderConfiguration: encoderConfiguration, + decoderLimitConfiguration: decoderLimitConfiguration, withClientUpgrade: upgrade ) } @@ -207,6 +193,7 @@ extension ChannelPipeline { leftOverBytesStrategy: leftOverBytesStrategy, enableOutboundHeaderValidation: enableOutboundHeaderValidation, encoderConfiguration: encoderConfiguration, + decoderLimitConfiguration: decoderLimitConfiguration, withClientUpgrade: upgrade ) } @@ -350,13 +337,67 @@ extension ChannelPipeline { ) } + /// Configure a `ChannelPipeline` for use as a HTTP server. + /// + /// This function knows how to set up all first-party HTTP channel handlers appropriately + /// for server use. It supports the following features: + /// + /// 1. Providing assistance handling clients that pipeline HTTP requests, using the + /// ``HTTPServerPipelineHandler``. + /// 2. Supporting HTTP upgrade, using the ``HTTPServerUpgradeHandler``. + /// 3. Providing assistance handling protocol errors. + /// 4. Validating outbound header fields to protect against response splitting attacks. + /// + /// This method will likely be extended in future with more support for other first-party + /// features. + /// + /// - Parameters: + /// - position: Where in the pipeline to add the HTTP server handlers, defaults to `.last`. + /// - pipelining: Whether to provide assistance handling HTTP clients that pipeline + /// their requests. Defaults to `true`. If `false`, users will need to handle + /// clients that pipeline themselves. + /// - upgrade: Whether to add a `HTTPServerUpgradeHandler` to the pipeline, configured for + /// HTTP upgrade. Defaults to `nil`, which will not add the handler to the pipeline. If + /// provided should be a tuple of an array of `HTTPServerProtocolUpgrader` and the upgrade + /// completion handler. See the documentation on `HTTPServerUpgradeHandler` for more + /// details. + /// - errorHandling: Whether to provide assistance handling protocol errors (e.g. + /// failure to parse the HTTP request) by sending 400 errors. Defaults to `true`. + /// - headerValidation: Whether to validate outbound request headers to confirm that they meet + /// spec compliance. Defaults to `true`. + /// - encoderConfiguration: The configuration for the ``HTTPResponseEncoder``. + /// - decoderLimitConfiguration: The limit configuration for the ``HTTPDecoder``. + /// - Returns: An `EventLoopFuture` that will fire when the pipeline is configured. + @preconcurrency + public func configureHTTPServerPipeline( + position: ChannelPipeline.Position = .last, + withPipeliningAssistance pipelining: Bool = true, + withServerUpgrade upgrade: NIOHTTPServerUpgradeSendableConfiguration? = nil, + withErrorHandling errorHandling: Bool = true, + withOutboundHeaderValidation headerValidation: Bool = true, + withEncoderConfiguration encoderConfiguration: HTTPResponseEncoder.Configuration = .init(), + withDecoderLimitConfiguration decoderLimitConfiguration: NIOHTTPDecoderLimitConfiguration = .init() + ) -> EventLoopFuture<Void> { + self._configureHTTPServerPipeline( + position: position, + withPipeliningAssistance: pipelining, + withServerUpgrade: upgrade, + withErrorHandling: errorHandling, + withOutboundHeaderValidation: headerValidation, + withEncoderConfiguration: encoderConfiguration, + withDecoderLimitConfiguration: decoderLimitConfiguration + ) + } + + private func _configureHTTPServerPipeline( position: ChannelPipeline.Position = .last, withPipeliningAssistance pipelining: Bool = true, withServerUpgrade upgrade: NIOHTTPServerUpgradeSendableConfiguration? = nil, withErrorHandling errorHandling: Bool = true, withOutboundHeaderValidation headerValidation: Bool = true, - withEncoderConfiguration encoderConfiguration: HTTPResponseEncoder.Configuration = .init() + withEncoderConfiguration encoderConfiguration: HTTPResponseEncoder.Configuration = .init(), + withDecoderLimitConfiguration decoderLimitConfiguration: NIOHTTPDecoderLimitConfiguration = .init() ) -> EventLoopFuture<Void> { let future: EventLoopFuture<Void> @@ -369,7 +410,8 @@ extension ChannelPipeline { withServerUpgrade: upgrade, withErrorHandling: errorHandling, withOutboundHeaderValidation: headerValidation, - withEncoderConfiguration: encoderConfiguration + withEncoderConfiguration: encoderConfiguration, + withDecoderLimitConfiguration: decoderLimitConfiguration ) } future = self.eventLoop.makeCompletedFuture(result) @@ -382,7 +424,8 @@ extension ChannelPipeline { withServerUpgrade: upgrade, withErrorHandling: errorHandling, withOutboundHeaderValidation: headerValidation, - withEncoderConfiguration: encoderConfiguration + withEncoderConfiguration: encoderConfiguration, + withDecoderLimitConfiguration: decoderLimitConfiguration ) } } @@ -531,6 +574,39 @@ extension ChannelPipeline.SynchronousOperations { ) } + /// Configure a `ChannelPipeline` for use as a HTTP client. + /// + /// - important: This **must** be called on the Channel's event loop. + /// - Parameters: + /// - position: The position in the `ChannelPipeline` where to add the HTTP client handlers. Defaults to `.last`. + /// - leftOverBytesStrategy: The strategy to use when dealing with leftover bytes after removing the `HTTPDecoder` + /// from the pipeline. + /// - enableOutboundHeaderValidation: Whether or not request header validation is enforced. + /// - encoderConfiguration: The configuration for the ``HTTPRequestEncoder``. + /// - decoderLimitConfiguration: The limit configuration for the ``HTTPDecoder``. + /// - upgrade: Add a ``NIOHTTPClientUpgradeHandler`` to the pipeline, configured for + /// HTTP upgrade. Should be a tuple of an array of ``NIOHTTPClientProtocolUpgrader`` and + /// the upgrade completion handler. See the documentation on ``NIOHTTPClientUpgradeHandler`` + /// for more details. + /// - Throws: If the pipeline could not be configured. + public func addHTTPClientHandlers( + position: ChannelPipeline.SynchronousOperations.Position = .last, + leftOverBytesStrategy: RemoveAfterUpgradeStrategy = .dropBytes, + enableOutboundHeaderValidation: Bool = true, + encoderConfiguration: HTTPRequestEncoder.Configuration = .init(), + decoderLimitConfiguration: NIOHTTPDecoderLimitConfiguration = .init(), + withClientUpgrade upgrade: NIOHTTPClientUpgradeConfiguration? = nil + ) throws { + try self._addHTTPClientHandlers( + position: position, + leftOverBytesStrategy: leftOverBytesStrategy, + enableOutboundHeaderValidation: enableOutboundHeaderValidation, + encoderConfiguration: encoderConfiguration, + decoderLimitConfiguration: decoderLimitConfiguration, + withClientUpgrade: upgrade + ) + } + /// Configure a `ChannelPipeline` for use as a HTTP client. /// /// - important: This **must** be called on the Channel's event loop. @@ -569,6 +645,7 @@ extension ChannelPipeline.SynchronousOperations { leftOverBytesStrategy: RemoveAfterUpgradeStrategy = .dropBytes, enableOutboundHeaderValidation: Bool = true, encoderConfiguration: HTTPRequestEncoder.Configuration = .init(), + decoderLimitConfiguration: NIOHTTPDecoderLimitConfiguration = .init(), withClientUpgrade upgrade: NIOHTTPClientUpgradeConfiguration? = nil ) throws { // Why two separate functions? With the fast-path (no upgrader, yes header validator) we can promote the Array of handlers @@ -579,25 +656,31 @@ extension ChannelPipeline.SynchronousOperations { leftOverBytesStrategy: leftOverBytesStrategy, enableOutboundHeaderValidation: enableOutboundHeaderValidation, encoderConfiguration: encoderConfiguration, + decoderLimitConfiguration: decoderLimitConfiguration, withClientUpgrade: upgrade ) } else { try self._addHTTPClientHandlers( position: position, leftOverBytesStrategy: leftOverBytesStrategy, - encoderConfiguration: encoderConfiguration + encoderConfiguration: encoderConfiguration, + decoderLimitConfiguration: decoderLimitConfiguration ) } } private func _addHTTPClientHandlers( position: ChannelPipeline.SynchronousOperations.Position, leftOverBytesStrategy: RemoveAfterUpgradeStrategy, - encoderConfiguration: HTTPRequestEncoder.Configuration + encoderConfiguration: HTTPRequestEncoder.Configuration, + decoderLimitConfiguration: NIOHTTPDecoderLimitConfiguration ) throws { self.eventLoop.assertInEventLoop() let requestEncoder = HTTPRequestEncoder(configuration: encoderConfiguration) - let responseDecoder = HTTPResponseDecoder(leftOverBytesStrategy: leftOverBytesStrategy) + let responseDecoder = HTTPResponseDecoder( + leftOverBytesStrategy: leftOverBytesStrategy, + limitConfiguration: decoderLimitConfiguration + ) let requestHeaderValidator = NIOHTTPRequestHeadersValidator() let handlers: [ChannelHandler] = [ requestEncoder, ByteToMessageHandler(responseDecoder), requestHeaderValidator, @@ -610,11 +693,15 @@ extension ChannelPipeline.SynchronousOperations { leftOverBytesStrategy: RemoveAfterUpgradeStrategy, enableOutboundHeaderValidation: Bool, encoderConfiguration: HTTPRequestEncoder.Configuration, + decoderLimitConfiguration: NIOHTTPDecoderLimitConfiguration, withClientUpgrade upgrade: NIOHTTPClientUpgradeConfiguration? ) throws { self.eventLoop.assertInEventLoop() let requestEncoder = HTTPRequestEncoder(configuration: encoderConfiguration) - let responseDecoder = HTTPResponseDecoder(leftOverBytesStrategy: leftOverBytesStrategy) + let responseDecoder = HTTPResponseDecoder( + leftOverBytesStrategy: leftOverBytesStrategy, + limitConfiguration: decoderLimitConfiguration + ) var handlers: [RemovableChannelHandler] = [requestEncoder, ByteToMessageHandler(responseDecoder)] if enableOutboundHeaderValidation { @@ -861,6 +948,58 @@ extension ChannelPipeline.SynchronousOperations { ) } + /// Configure a `ChannelPipeline` for use as a HTTP server. + /// + /// This function knows how to set up all first-party HTTP channel handlers appropriately + /// for server use. It supports the following features: + /// + /// 1. Providing assistance handling clients that pipeline HTTP requests, using the + /// `HTTPServerPipelineHandler`. + /// 2. Supporting HTTP upgrade, using the `HTTPServerUpgradeHandler`. + /// 3. Providing assistance handling protocol errors. + /// 4. Validating outbound header fields to protect against response splitting attacks. + /// + /// This method will likely be extended in future with more support for other first-party + /// features. + /// + /// - important: This **must** be called on the Channel's event loop. + /// - Parameters: + /// - position: Where in the pipeline to add the HTTP server handlers, defaults to `.last`. + /// - pipelining: Whether to provide assistance handling HTTP clients that pipeline + /// their requests. Defaults to `true`. If `false`, users will need to handle + /// clients that pipeline themselves. + /// - upgrade: Whether to add a `HTTPServerUpgradeHandler` to the pipeline, configured for + /// HTTP upgrade. Defaults to `nil`, which will not add the handler to the pipeline. If + /// provided should be a tuple of an array of `HTTPServerProtocolUpgrader` and the upgrade + /// completion handler. See the documentation on `HTTPServerUpgradeHandler` for more + /// details. + /// - errorHandling: Whether to provide assistance handling protocol errors (e.g. + /// failure to parse the HTTP request) by sending 400 errors. Defaults to `true`. + /// - headerValidation: Whether to validate outbound request headers to confirm that they meet + /// spec compliance. Defaults to `true`. + /// - encoderConfiguration: The configuration for the ``HTTPRequestEncoder``. + /// - decoderLimitConfiguration: The limit configuration for the ``HTTPDecoder``. + /// - Throws: If the pipeline could not be configured. + public func configureHTTPServerPipeline( + position: ChannelPipeline.SynchronousOperations.Position = .last, + withPipeliningAssistance pipelining: Bool = true, + withServerUpgrade upgrade: NIOHTTPServerUpgradeConfiguration? = nil, + withErrorHandling errorHandling: Bool = true, + withOutboundHeaderValidation headerValidation: Bool = true, + withEncoderConfiguration encoderConfiguration: HTTPResponseEncoder.Configuration, + withDecoderLimitConfiguration decoderLimitConfiguration: NIOHTTPDecoderLimitConfiguration, + ) throws { + try self._configureHTTPServerPipeline( + position: position, + withPipeliningAssistance: pipelining, + withServerUpgrade: upgrade, + withErrorHandling: errorHandling, + withOutboundHeaderValidation: headerValidation, + withEncoderConfiguration: encoderConfiguration, + withDecoderLimitConfiguration: decoderLimitConfiguration + ) + } + /// Configure a `ChannelPipeline` for use as a HTTP server. /// /// This function knows how to set up all first-party HTTP channel handlers appropriately @@ -919,12 +1058,16 @@ extension ChannelPipeline.SynchronousOperations { withServerUpgrade upgrade: NIOHTTPServerUpgradeConfiguration? = nil, withErrorHandling errorHandling: Bool = true, withOutboundHeaderValidation headerValidation: Bool = true, - withEncoderConfiguration encoderConfiguration: HTTPResponseEncoder.Configuration = .init() + withEncoderConfiguration encoderConfiguration: HTTPResponseEncoder.Configuration = .init(), + withDecoderLimitConfiguration decoderLimitConfiguration: NIOHTTPDecoderLimitConfiguration = .init() ) throws { self.eventLoop.assertInEventLoop() let responseEncoder = HTTPResponseEncoder(configuration: encoderConfiguration) - let requestDecoder = HTTPRequestDecoder(leftOverBytesStrategy: upgrade == nil ? .dropBytes : .forwardBytes) + let requestDecoder = HTTPRequestDecoder( + leftOverBytesStrategy: upgrade == nil ? .dropBytes : .forwardBytes, + limitConfiguration: decoderLimitConfiguration + ) var handlers: [RemovableChannelHandler] = [responseEncoder, ByteToMessageHandler(requestDecoder)]
Sources/NIOHTTP1/HTTPTypedPipelineSetup.swift+16 −2 modified@@ -33,6 +33,9 @@ public struct NIOUpgradableHTTPServerPipelineConfiguration<UpgradeResult: Sendab /// The configuration for the ``HTTPResponseEncoder``. public var encoderConfiguration = HTTPResponseEncoder.Configuration() + /// The configuration for the ``HTTPRequestDecoder``'s parsing limits. + public var decoderConfiguration = NIOHTTPDecoderLimitConfiguration() + /// The configuration for the ``NIOTypedHTTPServerUpgradeHandler``. public var upgradeConfiguration: NIOTypedHTTPServerUpgradeConfiguration<UpgradeResult> @@ -103,7 +106,12 @@ extension ChannelPipeline.SynchronousOperations { self.eventLoop.assertInEventLoop() let responseEncoder = HTTPResponseEncoder(configuration: configuration.encoderConfiguration) - let requestDecoder = ByteToMessageHandler(HTTPRequestDecoder(leftOverBytesStrategy: .forwardBytes)) + let requestDecoder = ByteToMessageHandler( + HTTPRequestDecoder( + leftOverBytesStrategy: .forwardBytes, + limitConfiguration: configuration.decoderConfiguration + ) + ) var extraHTTPHandlers = [RemovableChannelHandler]() extraHTTPHandlers.reserveCapacity(4) @@ -156,6 +164,9 @@ public struct NIOUpgradableHTTPClientPipelineConfiguration<UpgradeResult: Sendab /// The configuration for the ``HTTPRequestEncoder``. public var encoderConfiguration = HTTPRequestEncoder.Configuration() + /// The configuration for the ``HTTPResponseDecoder``'s parsing limits. + public var decoderConfiguration = NIOHTTPDecoderLimitConfiguration() + /// The configuration for the ``NIOTypedHTTPClientUpgradeHandler``. public var upgradeConfiguration: NIOTypedHTTPClientUpgradeConfiguration<UpgradeResult> @@ -223,7 +234,10 @@ extension ChannelPipeline.SynchronousOperations { let requestEncoder = HTTPRequestEncoder(configuration: configuration.encoderConfiguration) let responseDecoder = ByteToMessageHandler( - HTTPResponseDecoder(leftOverBytesStrategy: configuration.leftOverBytesStrategy) + HTTPResponseDecoder( + leftOverBytesStrategy: configuration.leftOverBytesStrategy, + limitConfiguration: configuration.decoderConfiguration + ) ) var httpHandlers = [RemovableChannelHandler]() httpHandlers.reserveCapacity(3)
Tests/NIOHTTP1Tests/HTTPDecoderTesting.swift+362 −0 added@@ -0,0 +1,362 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftNIO open source project +// +// Copyright (c) 2026 Apple Inc. and the SwiftNIO project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftNIO project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import NIOCore +import NIOEmbedded +import NIOHTTP1 +import Testing + +@Suite struct HTTPDecoderTesting { + + // MARK: - Default limit tests + + @Test func requestWithExcessiveNumberOfHeadersErrors() throws { + let channel = EmbeddedChannel() + try channel.pipeline.syncOperations.addHandler(ByteToMessageHandler(HTTPRequestDecoder())) + + var buffer = channel.allocator.buffer(capacity: 64) + buffer.writeString("GET / HTTP/1.1\r\nHost: example.com\r\n") + try channel.writeInbound(buffer) + + var threwError = false + for i in 0..<1_000_000 { + let headerBuffer = ByteBuffer(string: "X-Hdr-\(i): v\r\n") + do { + try channel.writeInbound(headerBuffer) + } catch { + threwError = true + break + } + } + + #expect(threwError, "Expected the decoder to reject a request with an excessive number of headers") + _ = try? channel.finish() + } + + @Test func requestWithOversizedURIErrors() throws { + var config = NIOHTTPDecoderLimitConfiguration() + config.maxHeaderListSize = 100 * 1024 + let decoder = HTTPRequestDecoder(limitConfiguration: config) + let channel = EmbeddedChannel() + try channel.pipeline.syncOperations.addHandler(ByteToMessageHandler(decoder)) + + var buffer = channel.allocator.buffer(capacity: 8) + buffer.writeString("GET ") + try channel.writeInbound(buffer) + #expect(try channel.readInbound(as: HTTPServerRequestPart.self) == nil) + + var chunk = channel.allocator.buffer(capacity: 1024) + chunk.writeRepeatingByte(UInt8(ascii: "x"), count: 1024) + + for _ in 0..<80 { + try channel.writeInbound(chunk) + #expect(try channel.readInbound(as: HTTPServerRequestPart.self) == nil) + } + + let lastByte = chunk.getSlice(at: chunk.readerIndex, length: 1)! + let error = #expect(throws: HTTPParserError.self) { + try channel.writeInbound(lastByte) + } + #expect(error == .headerOverflow) + + _ = try? channel.finish() + } + + @Test func requestWithOversizedMethodErrors() throws { + let channel = EmbeddedChannel() + try channel.pipeline.syncOperations.addHandler(ByteToMessageHandler(HTTPRequestDecoder())) + + var chunk = channel.allocator.buffer(capacity: 3) + chunk.writeRepeatingByte(UInt8(ascii: "X"), count: 3) + + var threwError = false + var caughtError: (any Error)? + for _ in 0..<81 { + do { + try channel.writeInbound(chunk) + } catch { + threwError = true + caughtError = error + break + } + } + + #expect(threwError, "Expected the decoder to reject a request with an oversized method") + if let parserError = caughtError as? HTTPParserError { + #expect(parserError == .invalidMethod) + } + + _ = try? channel.finish() + } + + // MARK: - Field count limit tests + + @Test func requestAtFieldCountLimitSucceeds() throws { + var config = NIOHTTPDecoderLimitConfiguration() + config.maxHeaderFieldCount = 10 + let decoder = HTTPRequestDecoder(limitConfiguration: config) + let channel = EmbeddedChannel() + try channel.pipeline.syncOperations.addHandler(ByteToMessageHandler(decoder)) + + var buffer = channel.allocator.buffer(capacity: 256) + buffer.writeString("GET / HTTP/1.1\r\n") + // Write exactly 10 headers (at the limit) + for i in 0..<10 { + buffer.writeString("X-Hdr-\(i): value\r\n") + } + buffer.writeString("\r\n") + try channel.writeInbound(buffer) + + let head = try channel.readInbound(as: HTTPServerRequestPart.self) + guard case .head(let requestHead) = head else { + Issue.record("Expected .head, got \(String(describing: head))") + return + } + #expect(requestHead.headers.count == 10) + + _ = try? channel.finish() + } + + @Test func requestExceedingFieldCountLimitErrors() throws { + var config = NIOHTTPDecoderLimitConfiguration() + config.maxHeaderFieldCount = 10 + let decoder = HTTPRequestDecoder(limitConfiguration: config) + let channel = EmbeddedChannel() + try channel.pipeline.syncOperations.addHandler(ByteToMessageHandler(decoder)) + + var buffer = channel.allocator.buffer(capacity: 256) + buffer.writeString("GET / HTTP/1.1\r\n") + // Write 11 headers (one over the limit) + for i in 0..<11 { + buffer.writeString("X-Hdr-\(i): value\r\n") + } + buffer.writeString("\r\n") + + let error = #expect(throws: HTTPParserError.self) { + try channel.writeInbound(buffer) + } + #expect(error == .headerOverflow) + + _ = try? channel.finish() + } + + @Test func defaultFieldCountLimitRejectsExcessiveHeaders() throws { + let channel = EmbeddedChannel() + try channel.pipeline.syncOperations.addHandler(ByteToMessageHandler(HTTPRequestDecoder())) + + var buffer = channel.allocator.buffer(capacity: 64) + buffer.writeString("GET / HTTP/1.1\r\nHost: example.com\r\n") + try channel.writeInbound(buffer) + + // Default limit is 256 fields. Sending 257 should fail. + var threwError = false + for i in 0..<257 { + let headerBuffer = ByteBuffer(string: "X-H-\(i): v\r\n") + do { + try channel.writeInbound(headerBuffer) + } catch { + threwError = true + break + } + } + + #expect(threwError, "Expected default field count limit to reject request with 257+ headers") + _ = try? channel.finish() + } + + // MARK: - Max header list size tests + + @Test func requestExceedingMaxHeaderListSizeErrors() throws { + var config = NIOHTTPDecoderLimitConfiguration() + config.maxHeaderListSize = 256 + let decoder = HTTPRequestDecoder(limitConfiguration: config) + let channel = EmbeddedChannel() + try channel.pipeline.syncOperations.addHandler(ByteToMessageHandler(decoder)) + + var buffer = channel.allocator.buffer(capacity: 512) + buffer.writeString("GET / HTTP/1.1\r\n") + // Create a header that pushes total over 256 bytes + let bigValue = String(repeating: "x", count: 300) + buffer.writeString("X-Big: \(bigValue)\r\n\r\n") + + let error = #expect(throws: HTTPParserError.self) { + try channel.writeInbound(buffer) + } + #expect(error == .headerOverflow) + + _ = try? channel.finish() + } + + @Test func requestWithinMaxHeaderListSizeSucceeds() throws { + var config = NIOHTTPDecoderLimitConfiguration() + config.maxHeaderListSize = 1024 + let decoder = HTTPRequestDecoder(limitConfiguration: config) + let channel = EmbeddedChannel() + try channel.pipeline.syncOperations.addHandler(ByteToMessageHandler(decoder)) + + var buffer = channel.allocator.buffer(capacity: 256) + buffer.writeString("GET / HTTP/1.1\r\n") + let value = String(repeating: "x", count: 100) + buffer.writeString("X-Hdr: \(value)\r\n\r\n") + try channel.writeInbound(buffer) + + let head = try channel.readInbound(as: HTTPServerRequestPart.self) + guard case .head = head else { + Issue.record("Expected .head, got \(String(describing: head))") + return + } + + _ = try? channel.finish() + } + + @Test func defaultMaxHeaderListSizeIs2MB() throws { + var config = NIOHTTPDecoderLimitConfiguration() + config.maxHeaderFieldCount = 10_000 + let decoder = HTTPRequestDecoder(limitConfiguration: config) + let channel = EmbeddedChannel() + try channel.pipeline.syncOperations.addHandler(ByteToMessageHandler(decoder)) + + var buffer = channel.allocator.buffer(capacity: 64) + buffer.writeString("GET / HTTP/1.1\r\n") + try channel.writeInbound(buffer) + + // Default maxHeaderListSize is 16384 * 128 = 2 MB. Send headers totaling > 2 MB. + // Use values just under the default 16 KB field-size limit so the field-size + // limit doesn't trigger first. + let valueSize = 16000 + var totalBytes = 0 + var threwError = false + for i in 0..<200 { + let name = "X-H-\(i)" + let value = String(repeating: "a", count: valueSize) + let headerBuffer = ByteBuffer(string: "\(name): \(value)\r\n") + totalBytes += name.utf8.count + value.utf8.count + do { + try channel.writeInbound(headerBuffer) + } catch { + threwError = true + break + } + } + + #expect(threwError, "Expected default 2 MB header list size limit to reject request") + #expect(totalBytes > 16384 * 128, "Test should have exceeded 2 MB before error") + _ = try? channel.finish() + } + + // MARK: - Max header field size tests + + @Test func requestExceedingMaxHeaderFieldSizeErrors() throws { + var config = NIOHTTPDecoderLimitConfiguration() + config.maxHeaderFieldSize = 128 + let decoder = HTTPRequestDecoder(limitConfiguration: config) + let channel = EmbeddedChannel() + try channel.pipeline.syncOperations.addHandler(ByteToMessageHandler(decoder)) + + var buffer = channel.allocator.buffer(capacity: 256) + buffer.writeString("GET / HTTP/1.1\r\n") + let bigValue = String(repeating: "x", count: 200) + buffer.writeString("X-Hdr: \(bigValue)\r\n\r\n") + + let error = #expect(throws: HTTPParserError.self) { + try channel.writeInbound(buffer) + } + #expect(error == .headerOverflow) + + _ = try? channel.finish() + } + + @Test func requestWithinMaxHeaderFieldSizeSucceeds() throws { + var config = NIOHTTPDecoderLimitConfiguration() + config.maxHeaderFieldSize = 256 + let decoder = HTTPRequestDecoder(limitConfiguration: config) + let channel = EmbeddedChannel() + try channel.pipeline.syncOperations.addHandler(ByteToMessageHandler(decoder)) + + var buffer = channel.allocator.buffer(capacity: 256) + buffer.writeString("GET / HTTP/1.1\r\n") + let value = String(repeating: "x", count: 100) + buffer.writeString("X-Hdr: \(value)\r\n\r\n") + try channel.writeInbound(buffer) + + let head = try channel.readInbound(as: HTTPServerRequestPart.self) + guard case .head = head else { + Issue.record("Expected .head, got \(String(describing: head))") + return + } + + _ = try? channel.finish() + } + + // MARK: - Custom configuration allows larger traffic + + @Test func customLargerLimitsAllowPreviouslyRejectedTraffic() throws { + var config = NIOHTTPDecoderLimitConfiguration() + config.maxHeaderFieldSize = 200 * 1024 // 200 KB per field + config.maxHeaderListSize = 10 * 1024 * 1024 // 10 MB total + config.maxHeaderFieldCount = 10_000 + let decoder = HTTPRequestDecoder(limitConfiguration: config) + let channel = EmbeddedChannel() + try channel.pipeline.syncOperations.addHandler(ByteToMessageHandler(decoder)) + + var buffer = channel.allocator.buffer(capacity: 256) + buffer.writeString("GET / HTTP/1.1\r\n") + // Write a header value larger than the default 80KB limit + let bigValue = String(repeating: "x", count: 100 * 1024) + buffer.writeString("X-Big: \(bigValue)\r\n\r\n") + try channel.writeInbound(buffer) + + let head = try channel.readInbound(as: HTTPServerRequestPart.self) + guard case .head(let requestHead) = head else { + Issue.record("Expected .head, got \(String(describing: head))") + return + } + #expect(requestHead.headers["X-Big"].first?.count == 100 * 1024) + + _ = try? channel.finish() + } + + // MARK: - Trailer field count tests + + @Test func trailersCountTowardFieldCountLimit() throws { + var config = NIOHTTPDecoderLimitConfiguration() + config.maxHeaderFieldCount = 5 + config.maxHeaderListSize = 1024 * 1024 + let decoder = HTTPRequestDecoder(limitConfiguration: config) + let channel = EmbeddedChannel() + try channel.pipeline.syncOperations.addHandler(ByteToMessageHandler(decoder)) + + var buffer = channel.allocator.buffer(capacity: 512) + // 4 headers (within limit of 5) + buffer.writeString("GET / HTTP/1.1\r\n") + buffer.writeString("Host: example.com\r\n") + buffer.writeString("Transfer-Encoding: chunked\r\n") + buffer.writeString("X-A: a\r\n") + buffer.writeString("X-B: b\r\n") + buffer.writeString("\r\n") + // chunked body + buffer.writeString("5\r\nhello\r\n0\r\n") + // 2 trailers (pushing total fields to 6, over limit of 5) + buffer.writeString("X-Trailer-1: t1\r\n") + buffer.writeString("X-Trailer-2: t2\r\n") + buffer.writeString("\r\n") + + let error = #expect(throws: HTTPParserError.self) { + try channel.writeInbound(buffer) + } + #expect(error == .headerOverflow) + + _ = try? channel.finish() + } +}
Tests/NIOHTTP1Tests/HTTPDecoderTest.swift+23 −4 modified@@ -157,7 +157,14 @@ class HTTPDecoderTest: XCTestCase { } } - XCTAssertNoThrow(try channel.pipeline.syncOperations.addHandler(ByteToMessageHandler(HTTPRequestDecoder()))) + var config = NIOHTTPDecoderLimitConfiguration() + config.maxHeaderFieldCount = 10_000 + config.maxHeaderListSize = .max + XCTAssertNoThrow( + try channel.pipeline.syncOperations.addHandler( + ByteToMessageHandler(HTTPRequestDecoder(limitConfiguration: config)) + ) + ) XCTAssertNoThrow(try channel.pipeline.syncOperations.addHandler(Receiver())) // This is a hypothetical HTTP/2.0 protocol response, assuming it is @@ -1072,8 +1079,12 @@ class HTTPDecoderTest: XCTestCase { func testDecodingLongHeaderFieldNames() { // Our maximum field size is 80kB, so we're going to write an 80kB + 1 byte field name to confirm it fails. + var config = NIOHTTPDecoderLimitConfiguration() + config.maxHeaderListSize = .max XCTAssertNoThrow( - try self.channel.pipeline.syncOperations.addHandler(ByteToMessageHandler(HTTPRequestDecoder())) + try self.channel.pipeline.syncOperations.addHandler( + ByteToMessageHandler(HTTPRequestDecoder(limitConfiguration: config)) + ) ) var buffer = ByteBuffer(string: "GET / HTTP/1.1\r\nHost: example.com\r\n") @@ -1100,8 +1111,12 @@ class HTTPDecoderTest: XCTestCase { func testDecodingLongHeaderFieldValues() { // Our maximum field size is 80kB, so we're going to write an 80kB + 1 byte field value to confirm it fails. + var config = NIOHTTPDecoderLimitConfiguration() + config.maxHeaderListSize = .max XCTAssertNoThrow( - try self.channel.pipeline.syncOperations.addHandler(ByteToMessageHandler(HTTPRequestDecoder())) + try self.channel.pipeline.syncOperations.addHandler( + ByteToMessageHandler(HTTPRequestDecoder(limitConfiguration: config)) + ) ) var buffer = ByteBuffer(string: "GET / HTTP/1.1\r\nHost: example.com\r\nx: ") @@ -1128,8 +1143,12 @@ class HTTPDecoderTest: XCTestCase { func testDecodingLongURLs() { // Our maximum field size is 80kB, so we're going to write an 80kB + 1 byte URL to confirm it fails. + var config = NIOHTTPDecoderLimitConfiguration() + config.maxHeaderListSize = .max XCTAssertNoThrow( - try self.channel.pipeline.syncOperations.addHandler(ByteToMessageHandler(HTTPRequestDecoder())) + try self.channel.pipeline.syncOperations.addHandler( + ByteToMessageHandler(HTTPRequestDecoder(limitConfiguration: config)) + ) ) var buffer = ByteBuffer(string: "GET ")
dd16365724d5Merge 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.