SwiftNIO NIOHTTP1: HTTPDecoder accepts unbounded HTTP/1 header blocks, enabling remote DoS
Description
Unbounded header block in NIOHTTP1's HTTPDecoder allows remote denial of service via memory exhaustion or crash.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Unbounded header block in NIOHTTP1's HTTPDecoder allows remote denial of service via memory exhaustion or crash.
Vulnerability
The HTTPDecoder in NIOHTTP1 prior to swift-nio 2.100.0 enforced only a single parsing limit: 80 KB per individual header field (name + value). It placed no cap on the cumulative size of the HTTP/1 header block or on the total number of header fields per message [1][2]. A remote peer can send hundreds of thousands of small, valid headers in a single request, all of which are accumulated into the resulting HTTPHeaders value before any application code runs.
Exploitation
An unauthenticated remote attacker with network access to an HTTP/1 server (or client) using NIOHTTP1 can submit a request containing an arbitrary number of header fields, each individually below the 80 KB limit [1][2]. No special privileges, user interaction, or write access are required. The headers are processed and exposed via standard HTTPServerRequestPart.head / HTTPClientResponsePart.head events.
Impact
A successful attack causes a denial of service. For consumers that bridge HTTPHeaders into swift-http-types' HTTPFields (such as Hummingbird 2), the process crashes via a precondition failure once the configured field count is exceeded [1]. For other consumers like Vapor 4, the per-request memory footprint scales linearly with the number of headers, allowing a single connection to inflate server memory to arbitrary sizes, potentially exhausting available memory [1]. The impact applies to both server and client direction.
Mitigation
The issue is patched in swift-nio version 2.100.0 and later [1][2]. The fix introduces three new parsing limits exposed through the NIOHTTPDecoderLimitConfiguration type, with conservative defaults that cap the cumulative header block size and the number of header fields per message. Users should upgrade to the patched version. There are no known workarounds that do not involve code modifications.
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.99.0
Patches
3b24872d3aa4aMerge 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 ")
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)
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
Root cause
"Missing limits on total header-block size and header-field count in HTTPDecoder allows unbounded memory accumulation from a single request."
Attack vector
An unauthenticated remote peer sends a single HTTP/1 request containing hundreds of thousands of small, valid header fields, each well below the previous 80 KB per-field limit. Because the decoder had no cap on total header-list size or field count, all headers are accumulated into the resulting `HTTPHeaders` value before any application code runs [patch_id=5722251]. This can exhaust server memory, or — when the headers are subsequently bridged into `swift-http-types`' `HTTPFields` (e.g. by Hummingbird 2) — trigger a precondition failure that crashes the process.
Affected code
The `HTTPDecoder` in `NIOHTTP1` (specifically `Sources/NIOHTTP1/HTTPDecoder.swift` and `Sources/NIOHTTP1/BetterHTTPParser.swift`) previously enforced only a single hardcoded limit of 80 KB per individual header field, with no cap on the total header-block size or the number of header fields per message. The patch introduces `NIOHTTPDecoderLimitConfiguration` and plumbs it through `HTTPRequestDecoder`, `HTTPResponseDecoder`, and the pipeline setup helpers in `HTTPPipelineSetup.swift`.
What the fix does
The patch introduces `NIOHTTPDecoderLimitConfiguration`, a public struct exposing three configurable limits: `maxHeaderFieldSize` (default 80 KB), `maxHeaderListSize` (default 2 MB), and `maxHeaderFieldCount` (default 256). The `HTTPDecoder` now tracks per-message totals in `BetterHTTPParser` and rejects a request with `HTTPParserError.headerOverflow` as soon as any limit is exceeded; counters reset at each new message so HTTP/1 pipelining works correctly. The configuration is plumbed through `HTTPRequestDecoder`/`HTTPResponseDecoder` initializers and exposed on `NIOUpgradableHTTPServerPipelineConfiguration` and `NIOUpgradableHTTPClientPipelineConfiguration` so users can override defaults.
Preconditions
- networkThe attacker must be able to open a TCP connection to an HTTP/1 server (or client) built on NIOHTTP1.
- authNo authentication is required; the attack is unauthenticated.
- inputThe attacker sends a single HTTP/1 request with an arbitrary number of small, valid header fields.
Generated on Jun 12, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
2News mentions
0No linked articles in our index yet.