From a16e2f54a25b2af217044e5168997009a505930f Mon Sep 17 00:00:00 2001 From: Cory Benfield Date: Tue, 27 Sep 2022 13:09:52 +0100 Subject: [PATCH] Merge pull request from GHSA-7fj7-39wj-c64f Motivation HTTP headers are prevented from containing certain characters that can potentially affect parsing or interpretation. Inadequately policing this can lead to vulnerabilities in web applications, most notably HTTP Response Splitting. NIO was insufficiently policing the correctness of the header fields we emit in HTTP/1.1. We've therefore added a new handler that is automatically added to channel pipelines that will police the validity of header fields. For projects that are already running the validation themselves, this can be easily disabled. Note that by default NIO does not validate content length is correctly calculated, so applications can have their framing fall out of sync unless they appropriately calculate this themselves or use chunked transfer encoding. Modifications - Add thorough unit testing to confirm we will not emit invalid header fields. - Error if a user attempts to send an invalid header field. Result NIO applications are no longer vulnerable to response splitting by CRLF injection by default. --- Package.swift | 5 +- Package@swift-5.4.swift | 5 +- Package@swift-5.5.swift | 5 +- Sources/NIOHTTP1/HTTPHeaderValidator.swift | 97 +++ Sources/NIOHTTP1/HTTPHeaders+Validation.swift | 205 +++++++ Sources/NIOHTTP1/HTTPPipelineSetup.swift | 207 ++++++- Tests/LinuxMain.swift | 1 + .../HTTPDecoderTest+XCTest.swift | 4 + Tests/NIOHTTP1Tests/HTTPDecoderTest.swift | 229 +++++++ .../HTTPHeaderValidationTests+XCTest.swift | 43 ++ .../HTTPHeaderValidationTests.swift | 566 ++++++++++++++++++ .../HTTPRequestEncoderTest+XCTest.swift | 2 +- .../HTTPResponseEncoderTest+XCTest.swift | 2 +- 13 files changed, 1345 insertions(+), 26 deletions(-) create mode 100644 Sources/NIOHTTP1/HTTPHeaderValidator.swift create mode 100644 Sources/NIOHTTP1/HTTPHeaders+Validation.swift create mode 100644 Tests/NIOHTTP1Tests/HTTPHeaderValidationTests+XCTest.swift create mode 100644 Tests/NIOHTTP1Tests/HTTPHeaderValidationTests.swift diff --git a/Package.swift b/Package.swift index 65170ef0e5..40ba04af3b 100644 --- a/Package.swift +++ b/Package.swift @@ -65,7 +65,10 @@ var targets: [PackageDescription.Target] = [ .executableTarget(name: "NIOHTTP1Client", dependencies: ["NIOPosix", "NIOCore", "NIOHTTP1", "NIOConcurrencyHelpers"], exclude: ["README.md"]), - .target(name: "CNIOLLHTTP"), + .target( + name: "CNIOLLHTTP", + cSettings: [.define("LLHTTP_STRICT_MODE")] + ), .target(name: "NIOTLS", dependencies: ["NIO", "NIOCore"]), .executableTarget(name: "NIOChatServer", dependencies: ["NIOPosix", "NIOCore", "NIOConcurrencyHelpers"], diff --git a/Package@swift-5.4.swift b/Package@swift-5.4.swift index b2f7461923..73c1eeae2c 100644 --- a/Package@swift-5.4.swift +++ b/Package@swift-5.4.swift @@ -65,7 +65,10 @@ var targets: [PackageDescription.Target] = [ .executableTarget(name: "NIOHTTP1Client", dependencies: ["NIOPosix", "NIOCore", "NIOHTTP1", "NIOConcurrencyHelpers"], exclude: ["README.md"]), - .target(name: "CNIOLLHTTP"), + .target( + name: "CNIOLLHTTP", + cSettings: [.define("LLHTTP_STRICT_MODE")] + ), .target(name: "NIOTLS", dependencies: ["NIO", "NIOCore"]), .executableTarget(name: "NIOChatServer", dependencies: ["NIOPosix", "NIOCore", "NIOConcurrencyHelpers"], diff --git a/Package@swift-5.5.swift b/Package@swift-5.5.swift index cd42c87dbb..e6a5d619d0 100644 --- a/Package@swift-5.5.swift +++ b/Package@swift-5.5.swift @@ -65,7 +65,10 @@ var targets: [PackageDescription.Target] = [ .executableTarget(name: "NIOHTTP1Client", dependencies: ["NIOPosix", "NIOCore", "NIOHTTP1", "NIOConcurrencyHelpers"], exclude: ["README.md"]), - .target(name: "CNIOLLHTTP"), + .target( + name: "CNIOLLHTTP", + cSettings: [.define("LLHTTP_STRICT_MODE")] + ), .target(name: "NIOTLS", dependencies: ["NIO", "NIOCore"]), .executableTarget(name: "NIOChatServer", dependencies: ["NIOPosix", "NIOCore", "NIOConcurrencyHelpers"], diff --git a/Sources/NIOHTTP1/HTTPHeaderValidator.swift b/Sources/NIOHTTP1/HTTPHeaderValidator.swift new file mode 100644 index 0000000000..e6123ebae2 --- /dev/null +++ b/Sources/NIOHTTP1/HTTPHeaderValidator.swift @@ -0,0 +1,97 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftNIO open source project +// +// Copyright (c) 2022 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 + +/// A ChannelHandler to validate that outbound request headers are spec-compliant. +/// +/// The HTTP RFCs constrain the bytes that are validly present within a HTTP/1.1 header block. +/// ``NIOHTTPRequestHeadersValidator`` polices this constraint and ensures that only valid header blocks +/// 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, +/// if they are present. +public final class NIOHTTPRequestHeadersValidator: ChannelOutboundHandler, RemovableChannelHandler { + public typealias OutboundIn = HTTPClientRequestPart + public typealias OutboundOut = HTTPClientRequestPart + + public init() { } + + public func write(context: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise?) { + switch self.unwrapOutboundIn(data) { + case .head(let head): + guard head.headers.areValidToSend else { + promise?.fail(HTTPParserError.invalidHeaderToken) + context.fireErrorCaught(HTTPParserError.invalidHeaderToken) + return + } + case .body, .end(.none): + () + case .end(.some(let trailers)): + guard trailers.areValidToSend else { + promise?.fail(HTTPParserError.invalidHeaderToken) + context.fireErrorCaught(HTTPParserError.invalidHeaderToken) + return + } + } + + context.write(data, promise: promise) + } +} + + +/// A ChannelHandler to validate that outbound response headers are spec-compliant. +/// +/// The HTTP RFCs constrain the bytes that are validly present within a HTTP/1.1 header block. +/// ``NIOHTTPResponseHeadersValidator`` polices this constraint and ensures that only valid header blocks +/// 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, +/// if they are present. +public final class NIOHTTPResponseHeadersValidator: ChannelOutboundHandler, RemovableChannelHandler { + public typealias OutboundIn = HTTPServerResponsePart + public typealias OutboundOut = HTTPServerResponsePart + + public init() { } + + public func write(context: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise?) { + switch self.unwrapOutboundIn(data) { + case .head(let head): + guard head.headers.areValidToSend else { + promise?.fail(HTTPParserError.invalidHeaderToken) + context.fireErrorCaught(HTTPParserError.invalidHeaderToken) + return + } + case .body, .end(.none): + () + case .end(.some(let trailers)): + guard trailers.areValidToSend else { + promise?.fail(HTTPParserError.invalidHeaderToken) + context.fireErrorCaught(HTTPParserError.invalidHeaderToken) + return + } + } + + context.write(data, promise: promise) + } +} + +#if swift(>=5.6) +@available(*, unavailable) +extension NIOHTTPRequestHeadersValidator: Sendable {} + +@available(*, unavailable) +extension NIOHTTPResponseHeadersValidator: Sendable {} +#endif diff --git a/Sources/NIOHTTP1/HTTPHeaders+Validation.swift b/Sources/NIOHTTP1/HTTPHeaders+Validation.swift new file mode 100644 index 0000000000..d154875c32 --- /dev/null +++ b/Sources/NIOHTTP1/HTTPHeaders+Validation.swift @@ -0,0 +1,205 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftNIO open source project +// +// Copyright (c) 2022 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 +// +//===----------------------------------------------------------------------===// + +extension String { + /// Validates a given header field value against the definition in RFC 9110. + /// + /// The spec in [RFC 9110](https://httpwg.org/specs/rfc9110.html#fields.values) defines the valid + /// characters as the following: + /// + /// ``` + /// field-value = *field-content + /// field-content = field-vchar + /// [ 1*( SP / HTAB / field-vchar ) field-vchar ] + /// field-vchar = VCHAR / obs-text + /// obs-text = %x80-FF + /// ``` + /// + /// Additionally, it makes the following note: + /// + /// "Field values containing CR, LF, or NUL characters are invalid and dangerous, due to the + /// varying ways that implementations might parse and interpret those characters; a recipient + /// of CR, LF, or NUL within a field value MUST either reject the message or replace each of + /// those characters with SP before further processing or forwarding of that message. Field + /// values containing other CTL characters are also invalid; however, recipients MAY retain + /// such characters for the sake of robustness when they appear within a safe context (e.g., + /// an application-specific quoted string that will not be processed by any downstream HTTP + /// parser)." + /// + /// As we cannot guarantee the context is safe, this code will reject all ASCII control characters + /// directly _except_ for HTAB, which is explicitly allowed. + fileprivate var isValidHeaderFieldValue: Bool { + let fastResult = self.utf8.withContiguousStorageIfAvailable { ptr in + ptr.allSatisfy { $0.isValidHeaderFieldValueByte } + } + if let fastResult = fastResult { + return fastResult + } else { + return self.utf8._isValidHeaderFieldValue_slowPath + } + } + + /// Validates a given header field name against the definition in RFC 9110. + /// + /// The spec in [RFC 9110](https://httpwg.org/specs/rfc9110.html#fields.values) defines the valid + /// characters as the following: + /// + /// ``` + /// field-name = token + /// + /// token = 1*tchar + /// + /// tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" + /// / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~" + /// / DIGIT / ALPHA + /// ; any VCHAR, except delimiters + /// ``` + /// + /// We implement this check directly. + fileprivate var isValidHeaderFieldName: Bool { + let fastResult = self.utf8.withContiguousStorageIfAvailable { ptr in + ptr.allSatisfy { $0.isValidHeaderFieldNameByte } + } + if let fastResult = fastResult { + return fastResult + } else { + return self.utf8._isValidHeaderFieldName_slowPath + } + } +} + +extension String.UTF8View { + /// The equivalent of `String.isValidHeaderFieldName`, but a slow-path when a + /// contiguous UTF8 storage is not available. + /// + /// This is deliberately `@inline(never)`, as slow paths should be forcibly outlined + /// to encourage inlining the fast-path. + @inline(never) + fileprivate var _isValidHeaderFieldName_slowPath: Bool { + self.allSatisfy { $0.isValidHeaderFieldNameByte } + } + + /// The equivalent of `String.isValidHeaderFieldValue`, but a slow-path when a + /// contiguous UTF8 storage is not available. + /// + /// This is deliberately `@inline(never)`, as slow paths should be forcibly outlined + /// to encourage inlining the fast-path. + @inline(never) + fileprivate var _isValidHeaderFieldValue_slowPath: Bool { + self.allSatisfy { $0.isValidHeaderFieldValueByte } + } +} + +extension UInt8 { + /// Validates a byte of a given header field name against the definition in RFC 9110. + /// + /// The spec in [RFC 9110](https://httpwg.org/specs/rfc9110.html#fields.values) defines the valid + /// characters as the following: + /// + /// ``` + /// field-name = token + /// + /// token = 1*tchar + /// + /// tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" + /// / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~" + /// / DIGIT / ALPHA + /// ; any VCHAR, except delimiters + /// ``` + /// + /// We implement this check directly. + /// + /// We use inline always here to force the check to be inlined, which it isn't always, leading to less optimal code. + @inline(__always) + fileprivate var isValidHeaderFieldNameByte: Bool { + switch self { + case UInt8(ascii: "0")...UInt8(ascii: "9"), // DIGIT + UInt8(ascii: "a")...UInt8(ascii: "z"), + UInt8(ascii: "A")...UInt8(ascii: "Z"), // ALPHA + 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: "~"): + + return true + + default: + return false + } + } + + /// Validates a byte of a given header field value against the definition in RFC 9110. + /// + /// The spec in [RFC 9110](https://httpwg.org/specs/rfc9110.html#fields.values) defines the valid + /// characters as the following: + /// + /// ``` + /// field-value = *field-content + /// field-content = field-vchar + /// [ 1*( SP / HTAB / field-vchar ) field-vchar ] + /// field-vchar = VCHAR / obs-text + /// obs-text = %x80-FF + /// ``` + /// + /// Additionally, it makes the following note: + /// + /// "Field values containing CR, LF, or NUL characters are invalid and dangerous, due to the + /// varying ways that implementations might parse and interpret those characters; a recipient + /// of CR, LF, or NUL within a field value MUST either reject the message or replace each of + /// those characters with SP before further processing or forwarding of that message. Field + /// values containing other CTL characters are also invalid; however, recipients MAY retain + /// such characters for the sake of robustness when they appear within a safe context (e.g., + /// an application-specific quoted string that will not be processed by any downstream HTTP + /// parser)." + /// + /// As we cannot guarantee the context is safe, this code will reject all ASCII control characters + /// directly _except_ for HTAB, which is explicitly allowed. + /// + /// We use inline always here to force the check to be inlined, which it isn't always, leading to less optimal code. + @inline(__always) + fileprivate var isValidHeaderFieldValueByte: Bool { + switch self { + case UInt8(ascii: "\t"): + // HTAB, explicitly allowed. + return true + case 0...0x1f, 0x7F: + // ASCII control character, forbidden. + return false + default: + // Printable or non-ASCII, allowed. + return true + } + } +} + +extension HTTPHeaders { + /// Whether these HTTPHeaders are valid to send on the wire. + var areValidToSend: Bool { + for (name, value) in self.headers { + if !name.isValidHeaderFieldName { + return false + } + + if !value.isValidHeaderFieldValue { + return false + } + } + + return true + } +} diff --git a/Sources/NIOHTTP1/HTTPPipelineSetup.swift b/Sources/NIOHTTP1/HTTPPipelineSetup.swift index 54e5e97f9e..d65fe9bd5b 100644 --- a/Sources/NIOHTTP1/HTTPPipelineSetup.swift +++ b/Sources/NIOHTTP1/HTTPPipelineSetup.swift @@ -123,6 +123,45 @@ extension ChannelPipeline { return future } + + /// 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. + public func addHTTPClientHandlers(position: Position = .last, + leftOverBytesStrategy: RemoveAfterUpgradeStrategy = .dropBytes, + enableOutboundHeaderValidation: Bool = true, + withClientUpgrade upgrade: NIOHTTPClientUpgradeConfiguration? = nil) -> EventLoopFuture { + let future: EventLoopFuture + + if self.eventLoop.inEventLoop { + let result = Result { + try self.syncOperations.addHTTPClientHandlers(position: position, + leftOverBytesStrategy: leftOverBytesStrategy, + enableOutboundHeaderValidation: enableOutboundHeaderValidation, + withClientUpgrade: upgrade) + } + future = self.eventLoop.makeCompletedFuture(result) + } else { + future = self.eventLoop.submit { + return try self.syncOperations.addHTTPClientHandlers(position: position, + leftOverBytesStrategy: leftOverBytesStrategy, + enableOutboundHeaderValidation: enableOutboundHeaderValidation, + withClientUpgrade: upgrade) + } + } + + return future + } #if swift(>=5.7) /// Configure a `ChannelPipeline` for use as a HTTP server. @@ -200,11 +239,55 @@ extension ChannelPipeline { ) } #endif + + /// 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`. + /// - returns: An `EventLoopFuture` that will fire when the pipeline is configured. + public func configureHTTPServerPipeline(position: ChannelPipeline.Position = .last, + withPipeliningAssistance pipelining: Bool = true, + withServerUpgrade upgrade: NIOHTTPServerUpgradeConfiguration? = nil, + withErrorHandling errorHandling: Bool = true, + withOutboundHeaderValidation headerValidation: Bool = true) -> EventLoopFuture { + self._configureHTTPServerPipeline( + position: position, + withPipeliningAssistance: pipelining, + withServerUpgrade: upgrade, + withErrorHandling: errorHandling, + withOutboundHeaderValidation: headerValidation + ) + } private func _configureHTTPServerPipeline(position: ChannelPipeline.Position = .last, withPipeliningAssistance pipelining: Bool = true, withServerUpgrade upgrade: NIOHTTPServerUpgradeConfiguration? = nil, - withErrorHandling errorHandling: Bool = true) -> EventLoopFuture { + withErrorHandling errorHandling: Bool = true, + withOutboundHeaderValidation headerValidation: Bool = true) -> EventLoopFuture { let future: EventLoopFuture if self.eventLoop.inEventLoop { @@ -212,7 +295,8 @@ extension ChannelPipeline { try self.syncOperations.configureHTTPServerPipeline(position: position, withPipeliningAssistance: pipelining, withServerUpgrade: upgrade, - withErrorHandling: errorHandling) + withErrorHandling: errorHandling, + withOutboundHeaderValidation: headerValidation) } future = self.eventLoop.makeCompletedFuture(result) } else { @@ -220,7 +304,8 @@ extension ChannelPipeline { try self.syncOperations.configureHTTPServerPipeline(position: position, withPipeliningAssistance: pipelining, withServerUpgrade: upgrade, - withErrorHandling: errorHandling) + withErrorHandling: errorHandling, + withOutboundHeaderValidation: headerValidation) } } @@ -275,17 +360,40 @@ extension ChannelPipeline.SynchronousOperations { ) } #endif - - private func _addHTTPClientHandlers(position: ChannelPipeline.Position = .last, + + /// 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. + /// - 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.Position = .last, leftOverBytesStrategy: RemoveAfterUpgradeStrategy = .dropBytes, + enableOutboundHeaderValidation: Bool = true, withClientUpgrade upgrade: NIOHTTPClientUpgradeConfiguration? = nil) throws { - // Why two separate functions? When creating the array of handlers to add to the pipeline, when we don't have - // an upgrade handler -- i.e. just an array literal -- the compiler is able to promote the array to the stack - // which saves an allocation. That's not the case when the upgrade handler is present. - if let upgrade = upgrade { - try self._addHTTPClientHandlers(position: position, - leftOverBytesStrategy: leftOverBytesStrategy, - withClientUpgrade: upgrade) + try self._addHTTPClientHandlers(position: position, + leftOverBytesStrategy: leftOverBytesStrategy, + enableOutboundHeaderValidation: enableOutboundHeaderValidation, + withClientUpgrade: upgrade) + } + + private func _addHTTPClientHandlers(position: ChannelPipeline.Position = .last, + leftOverBytesStrategy: RemoveAfterUpgradeStrategy = .dropBytes, + enableOutboundHeaderValidation: Bool = true, + 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 + // to the stack and skip an allocation. + if upgrade != nil || enableOutboundHeaderValidation != true { + try self._addHTTPClientHandlersFallback(position: position, + leftOverBytesStrategy: leftOverBytesStrategy, + enableOutboundHeaderValidation: enableOutboundHeaderValidation, + withClientUpgrade: upgrade) } else { try self._addHTTPClientHandlers(position: position, leftOverBytesStrategy: leftOverBytesStrategy) @@ -297,22 +405,30 @@ extension ChannelPipeline.SynchronousOperations { self.eventLoop.assertInEventLoop() let requestEncoder = HTTPRequestEncoder() let responseDecoder = HTTPResponseDecoder(leftOverBytesStrategy: leftOverBytesStrategy) - let handlers: [ChannelHandler] = [requestEncoder, ByteToMessageHandler(responseDecoder)] + let requestHeaderValidator = NIOHTTPRequestHeadersValidator() + let handlers: [ChannelHandler] = [requestEncoder, ByteToMessageHandler(responseDecoder), requestHeaderValidator] try self.addHandlers(handlers, position: position) } - private func _addHTTPClientHandlers(position: ChannelPipeline.Position, - leftOverBytesStrategy: RemoveAfterUpgradeStrategy, - withClientUpgrade upgrade: NIOHTTPClientUpgradeConfiguration) throws { + private func _addHTTPClientHandlersFallback(position: ChannelPipeline.Position, + leftOverBytesStrategy: RemoveAfterUpgradeStrategy, + enableOutboundHeaderValidation: Bool, + withClientUpgrade upgrade: NIOHTTPClientUpgradeConfiguration?) throws { self.eventLoop.assertInEventLoop() let requestEncoder = HTTPRequestEncoder() let responseDecoder = HTTPResponseDecoder(leftOverBytesStrategy: leftOverBytesStrategy) var handlers: [RemovableChannelHandler] = [requestEncoder, ByteToMessageHandler(responseDecoder)] - let upgrader = NIOHTTPClientUpgradeHandler(upgraders: upgrade.upgraders, - httpHandlers: handlers, - upgradeCompletionHandler: upgrade.completionHandler) - handlers.append(upgrader) + if enableOutboundHeaderValidation { + handlers.append(NIOHTTPRequestHeadersValidator()) + } + + if let upgrade = upgrade { + let upgrader = NIOHTTPClientUpgradeHandler(upgraders: upgrade.upgraders, + httpHandlers: handlers, + upgradeCompletionHandler: upgrade.completionHandler) + handlers.append(upgrader) + } try self.addHandlers(handlers, position: position) } @@ -394,11 +510,56 @@ extension ChannelPipeline.SynchronousOperations { ) } #endif + + /// 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`. + /// - throws: If the pipeline could not be configured. + public func configureHTTPServerPipeline(position: ChannelPipeline.Position = .last, + withPipeliningAssistance pipelining: Bool = true, + withServerUpgrade upgrade: NIOHTTPServerUpgradeConfiguration? = nil, + withErrorHandling errorHandling: Bool = true, + withOutboundHeaderValidation headerValidation: Bool = true) throws { + try self._configureHTTPServerPipeline( + position: position, + withPipeliningAssistance: pipelining, + withServerUpgrade: upgrade, + withErrorHandling: errorHandling, + withOutboundHeaderValidation: headerValidation + ) + } private func _configureHTTPServerPipeline(position: ChannelPipeline.Position = .last, withPipeliningAssistance pipelining: Bool = true, withServerUpgrade upgrade: NIOHTTPServerUpgradeConfiguration? = nil, - withErrorHandling errorHandling: Bool = true) throws { + withErrorHandling errorHandling: Bool = true, + withOutboundHeaderValidation headerValidation: Bool = true) throws { self.eventLoop.assertInEventLoop() let responseEncoder = HTTPResponseEncoder() @@ -410,6 +571,10 @@ extension ChannelPipeline.SynchronousOperations { handlers.append(HTTPServerPipelineHandler()) } + if headerValidation { + handlers.append(NIOHTTPResponseHeadersValidator()) + } + if errorHandling { handlers.append(HTTPServerProtocolErrorHandler()) } diff --git a/Tests/LinuxMain.swift b/Tests/LinuxMain.swift index c7a396c04e..66629248c4 100644 --- a/Tests/LinuxMain.swift +++ b/Tests/LinuxMain.swift @@ -84,6 +84,7 @@ class LinuxMainRunnerImpl: LinuxMainRunner { testCase(HTTPClientUpgradeTestCase.allTests), testCase(HTTPDecoderLengthTest.allTests), testCase(HTTPDecoderTest.allTests), + testCase(HTTPHeaderValidationTests.allTests), testCase(HTTPHeadersTest.allTests), testCase(HTTPRequestEncoderTests.allTests), testCase(HTTPResponseEncoderTests.allTests), diff --git a/Tests/NIOHTTP1Tests/HTTPDecoderTest+XCTest.swift b/Tests/NIOHTTP1Tests/HTTPDecoderTest+XCTest.swift index e530124a7e..3c29b19848 100644 --- a/Tests/NIOHTTP1Tests/HTTPDecoderTest+XCTest.swift +++ b/Tests/NIOHTTP1Tests/HTTPDecoderTest+XCTest.swift @@ -62,6 +62,10 @@ extension HTTPDecoderTest { ("testDecodingLongHeaderFieldValues", testDecodingLongHeaderFieldValues), ("testDecodingLongURLs", testDecodingLongURLs), ("testDecodingRTSPQueries", testDecodingRTSPQueries), + ("testDecodingInvalidHeaderFieldNames", testDecodingInvalidHeaderFieldNames), + ("testDecodingInvalidTrailerFieldNames", testDecodingInvalidTrailerFieldNames), + ("testDecodingInvalidHeaderFieldValues", testDecodingInvalidHeaderFieldValues), + ("testDecodingInvalidTrailerFieldValues", testDecodingInvalidTrailerFieldValues), ] } } diff --git a/Tests/NIOHTTP1Tests/HTTPDecoderTest.swift b/Tests/NIOHTTP1Tests/HTTPDecoderTest.swift index 86ec95c569..bb960394eb 100644 --- a/Tests/NIOHTTP1Tests/HTTPDecoderTest.swift +++ b/Tests/NIOHTTP1Tests/HTTPDecoderTest.swift @@ -1026,4 +1026,233 @@ class HTTPDecoderTest: XCTestCase { XCTAssertEqual(head.method, .RAW(value: query)) } } + + func testDecodingInvalidHeaderFieldNames() throws { + // The spec in [RFC 9110](https://httpwg.org/specs/rfc9110.html#fields.values) defines the valid + // characters as the following: + // + // ``` + // field-name = token + // + // token = 1*tchar + // + // tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" + // / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~" + // / DIGIT / ALPHA + // ; any VCHAR, except delimiters + let weirdAllowedFieldName = "!#$%&'*+-.^_`|~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + + XCTAssertNoThrow(try self.channel.pipeline.addHandler(ByteToMessageHandler(HTTPRequestDecoder())).wait()) + let goodRequest = ByteBuffer(string: "GET / HTTP/1.1\r\nHost: example.com\r\n\(weirdAllowedFieldName): present\r\n\r\n") + + XCTAssertNoThrow(try self.channel.writeInbound(goodRequest)) + + var maybeHead: HTTPServerRequestPart? + var maybeEnd: HTTPServerRequestPart? + + XCTAssertNoThrow(maybeHead = try self.channel.readInbound()) + XCTAssertNoThrow(maybeEnd = try self.channel.readInbound()) + guard case .some(.head(let head)) = maybeHead, case .some(.end) = maybeEnd else { + XCTFail("didn't receive head & end") + return + } + XCTAssertEqual(head.headers[weirdAllowedFieldName], ["present"]) + + // Now confirm all other bytes are rejected. + for byte in UInt8(0)...UInt8(255) { + // Skip bytes that we already believe are allowed, or that will affect the parse. + if weirdAllowedFieldName.utf8.contains(byte) || byte == UInt8(ascii: ":") { + continue + } + let forbiddenFieldName = weirdAllowedFieldName + String(decoding: [byte], as: UTF8.self) + let channel = EmbeddedChannel(handler: ByteToMessageHandler(HTTPRequestDecoder())) + let badRequest = ByteBuffer(string: "GET / HTTP/1.1\r\nHost: example.com\r\n\(forbiddenFieldName): present\r\n\r\n") + + XCTAssertThrowsError( + try channel.writeInbound(badRequest), + "Incorrectly tolerated character in header field name: \(String(decoding: [byte], as: UTF8.self))" + ) { error in + XCTAssertEqual(error as? HTTPParserError, .invalidHeaderToken) + } + _ = try? channel.finish() + } + } + + func testDecodingInvalidTrailerFieldNames() throws { + // The spec in [RFC 9110](https://httpwg.org/specs/rfc9110.html#fields.values) defines the valid + // characters as the following: + // + // ``` + // field-name = token + // + // token = 1*tchar + // + // tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" + // / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~" + // / DIGIT / ALPHA + // ; any VCHAR, except delimiters + let weirdAllowedFieldName = "!#$%&'*+-.^_`|~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + + let request = ByteBuffer(string: "POST / HTTP/1.1\r\nHost: example.com\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n") + + XCTAssertNoThrow(try self.channel.pipeline.addHandler(ByteToMessageHandler(HTTPRequestDecoder())).wait()) + let goodTrailers = ByteBuffer(string: "\(weirdAllowedFieldName): present\r\n\r\n") + + XCTAssertNoThrow(try self.channel.writeInbound(request)) + XCTAssertNoThrow(try self.channel.writeInbound(goodTrailers)) + + var maybeHead: HTTPServerRequestPart? + var maybeEnd: HTTPServerRequestPart? + + XCTAssertNoThrow(maybeHead = try self.channel.readInbound()) + XCTAssertNoThrow(maybeEnd = try self.channel.readInbound()) + guard case .some(.head) = maybeHead, case .some(.end(.some(let trailers))) = maybeEnd else { + XCTFail("didn't receive head & end") + return + } + XCTAssertEqual(trailers[weirdAllowedFieldName], ["present"]) + + // Now confirm all other bytes are rejected. + for byte in UInt8(0)...UInt8(255) { + // Skip bytes that we already believe are allowed, or that will affect the parse. + if weirdAllowedFieldName.utf8.contains(byte) || byte == UInt8(ascii: ":") { + continue + } + let forbiddenFieldName = weirdAllowedFieldName + String(decoding: [byte], as: UTF8.self) + let channel = EmbeddedChannel(handler: ByteToMessageHandler(HTTPRequestDecoder())) + let badTrailers = ByteBuffer(string: "\(forbiddenFieldName): present\r\n\r\n") + + XCTAssertNoThrow(try channel.writeInbound(request)) + + XCTAssertThrowsError( + try channel.writeInbound(badTrailers), + "Incorrectly tolerated character in trailer field name: \(String(decoding: [byte], as: UTF8.self))" + ) { error in + XCTAssertEqual(error as? HTTPParserError, .invalidHeaderToken) + } + _ = try? channel.finish() + } + } + + func testDecodingInvalidHeaderFieldValues() throws { + // We reject all ASCII control characters except HTAB and tolerate everything else. + let weirdAllowedFieldValue = "!\" \t#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~" + + XCTAssertNoThrow(try self.channel.pipeline.addHandler(ByteToMessageHandler(HTTPRequestDecoder())).wait()) + let goodRequest = ByteBuffer(string: "GET / HTTP/1.1\r\nHost: example.com\r\nWeird-Field: \(weirdAllowedFieldValue)\r\n\r\n") + + XCTAssertNoThrow(try self.channel.writeInbound(goodRequest)) + + var maybeHead: HTTPServerRequestPart? + var maybeEnd: HTTPServerRequestPart? + + XCTAssertNoThrow(maybeHead = try self.channel.readInbound()) + XCTAssertNoThrow(maybeEnd = try self.channel.readInbound()) + guard case .some(.head(let head)) = maybeHead, case .some(.end) = maybeEnd else { + XCTFail("didn't receive head & end") + return + } + XCTAssertEqual(head.headers["Weird-Field"], [weirdAllowedFieldValue]) + + // Now confirm all other bytes in the ASCII range are rejected. + for byte in UInt8(0)..?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~" + + let request = ByteBuffer(string: "POST / HTTP/1.1\r\nHost: example.com\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n") + + XCTAssertNoThrow(try self.channel.pipeline.addHandler(ByteToMessageHandler(HTTPRequestDecoder())).wait()) + let goodTrailers = ByteBuffer(string: "Weird-Field: \(weirdAllowedFieldValue)\r\n\r\n") + + XCTAssertNoThrow(try self.channel.writeInbound(request)) + XCTAssertNoThrow(try self.channel.writeInbound(goodTrailers)) + + var maybeHead: HTTPServerRequestPart? + var maybeEnd: HTTPServerRequestPart? + + XCTAssertNoThrow(maybeHead = try self.channel.readInbound()) + XCTAssertNoThrow(maybeEnd = try self.channel.readInbound()) + guard case .some(.head) = maybeHead, case .some(.end(.some(let trailers))) = maybeEnd else { + XCTFail("didn't receive head & end") + return + } + XCTAssertEqual(trailers["Weird-Field"], [weirdAllowedFieldValue]) + + // Now confirm all other bytes in the ASCII range are rejected. + for byte in UInt8(0).. () throws -> Void)] { + return [ + ("testEncodingInvalidHeaderFieldNamesInRequests", testEncodingInvalidHeaderFieldNamesInRequests), + ("testEncodingInvalidTrailerFieldNamesInRequests", testEncodingInvalidTrailerFieldNamesInRequests), + ("testEncodingInvalidHeaderFieldValuesInRequests", testEncodingInvalidHeaderFieldValuesInRequests), + ("testEncodingInvalidTrailerFieldValuesInRequests", testEncodingInvalidTrailerFieldValuesInRequests), + ("testEncodingInvalidHeaderFieldNamesInResponses", testEncodingInvalidHeaderFieldNamesInResponses), + ("testEncodingInvalidTrailerFieldNamesInResponses", testEncodingInvalidTrailerFieldNamesInResponses), + ("testEncodingInvalidHeaderFieldValuesInResponses", testEncodingInvalidHeaderFieldValuesInResponses), + ("testEncodingInvalidTrailerFieldValuesInResponses", testEncodingInvalidTrailerFieldValuesInResponses), + ("testDisablingValidationClientSide", testDisablingValidationClientSide), + ("testDisablingValidationServerSide", testDisablingValidationServerSide), + ] + } +} + diff --git a/Tests/NIOHTTP1Tests/HTTPHeaderValidationTests.swift b/Tests/NIOHTTP1Tests/HTTPHeaderValidationTests.swift new file mode 100644 index 0000000000..e8f6b92ce9 --- /dev/null +++ b/Tests/NIOHTTP1Tests/HTTPHeaderValidationTests.swift @@ -0,0 +1,566 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftNIO open source project +// +// Copyright (c) 2022 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 XCTest +import Dispatch +import NIOCore +import NIOEmbedded +import NIOHTTP1 + +final class HTTPHeaderValidationTests: XCTestCase { + func testEncodingInvalidHeaderFieldNamesInRequests() throws { + // The spec in [RFC 9110](https://httpwg.org/specs/rfc9110.html#fields.values) defines the valid + // characters as the following: + // + // ``` + // field-name = token + // + // token = 1*tchar + // + // tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" + // / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~" + // / DIGIT / ALPHA + // ; any VCHAR, except delimiters + let weirdAllowedFieldName = "!#$%&'*+-.^_`|~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + + let channel = EmbeddedChannel() + try channel.pipeline.syncOperations.addHTTPClientHandlers() + + let headers = HTTPHeaders([("Host", "example.com"), (weirdAllowedFieldName, "present")]) + let goodRequest = HTTPRequestHead(version: .http1_1, method: .GET, uri: "/", headers: headers) + let goodRequestBytes = ByteBuffer(string: "GET / HTTP/1.1\r\nHost: example.com\r\n\(weirdAllowedFieldName): present\r\n\r\n") + + XCTAssertNoThrow(try channel.writeOutbound(HTTPClientRequestPart.head(goodRequest))) + XCTAssertNoThrow(try channel.writeOutbound(HTTPClientRequestPart.end(nil))) + + var maybeReceivedBytes: ByteBuffer? + + XCTAssertNoThrow(maybeReceivedBytes = try channel.readOutbound()) + XCTAssertEqual(maybeReceivedBytes, goodRequestBytes) + + // Now confirm all other bytes are rejected. + for byte in UInt8(0)...UInt8(255) { + // Skip bytes that we already believe are allowed. + if weirdAllowedFieldName.utf8.contains(byte) { + continue + } + let forbiddenFieldName = weirdAllowedFieldName + String(decoding: [byte], as: UTF8.self) + + let channel = EmbeddedChannel() + try channel.pipeline.syncOperations.addHTTPClientHandlers() + + let headers = HTTPHeaders([("Host", "example.com"), (forbiddenFieldName, "present")]) + let badRequest = HTTPRequestHead(version: .http1_1, method: .GET, uri: "/", headers: headers) + + XCTAssertThrowsError( + try channel.writeOutbound(HTTPClientRequestPart.head(badRequest)), + "Incorrectly tolerated character in header field name: \(String(decoding: [byte], as: UTF8.self))" + ) { error in + XCTAssertEqual(error as? HTTPParserError, .invalidHeaderToken) + } + _ = try? channel.finish() + } + } + + func testEncodingInvalidTrailerFieldNamesInRequests() throws { + // The spec in [RFC 9110](https://httpwg.org/specs/rfc9110.html#fields.values) defines the valid + // characters as the following: + // + // ``` + // field-name = token + // + // token = 1*tchar + // + // tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" + // / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~" + // / DIGIT / ALPHA + // ; any VCHAR, except delimiters + let weirdAllowedFieldName = "!#$%&'*+-.^_`|~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + + let channel = EmbeddedChannel() + try channel.pipeline.syncOperations.addHTTPClientHandlers() + + let headers = HTTPHeaders([("Host", "example.com"), ("Transfer-Encoding", "chunked")]) + let goodRequest = HTTPRequestHead(version: .http1_1, method: .POST, uri: "/", headers: headers) + let goodRequestBytes = ByteBuffer(string: "POST / HTTP/1.1\r\nHost: example.com\r\ntransfer-encoding: chunked\r\n\r\n") + let goodTrailers = ByteBuffer(string: "0\r\n\(weirdAllowedFieldName): present\r\n\r\n") + + XCTAssertNoThrow(try channel.writeOutbound(HTTPClientRequestPart.head(goodRequest))) + XCTAssertNoThrow(try channel.writeOutbound(HTTPClientRequestPart.end([weirdAllowedFieldName: "present"]))) + + var maybeRequestHeadBytes: ByteBuffer? + var maybeRequestEndBytes: ByteBuffer? + + XCTAssertNoThrow(maybeRequestHeadBytes = try channel.readOutbound()) + XCTAssertNoThrow(maybeRequestEndBytes = try channel.readOutbound()) + XCTAssertEqual(maybeRequestHeadBytes, goodRequestBytes) + XCTAssertEqual(maybeRequestEndBytes, goodTrailers) + + // Now confirm all other bytes are rejected. + for byte in UInt8(0)...UInt8(255) { + // Skip bytes that we already believe are allowed. + if weirdAllowedFieldName.utf8.contains(byte) { + continue + } + let forbiddenFieldName = weirdAllowedFieldName + String(decoding: [byte], as: UTF8.self) + + let channel = EmbeddedChannel() + try channel.pipeline.syncOperations.addHTTPClientHandlers() + + XCTAssertNoThrow(try channel.writeOutbound(HTTPClientRequestPart.head(goodRequest))) + + XCTAssertThrowsError( + try channel.writeOutbound(HTTPClientRequestPart.end([forbiddenFieldName: "present"])), + "Incorrectly tolerated character in trailer field name: \(String(decoding: [byte], as: UTF8.self))" + ) { error in + XCTAssertEqual(error as? HTTPParserError, .invalidHeaderToken) + } + _ = try? channel.finish() + } + } + + func testEncodingInvalidHeaderFieldValuesInRequests() throws { + // We reject all ASCII control characters except HTAB and tolerate everything else. + let weirdAllowedFieldValue = "!\" \t#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~" + + let channel = EmbeddedChannel() + try channel.pipeline.syncOperations.addHTTPClientHandlers() + + let headers = HTTPHeaders([("Host", "example.com"), ("Weird-Value", weirdAllowedFieldValue)]) + let goodRequest = HTTPRequestHead(version: .http1_1, method: .GET, uri: "/", headers: headers) + let goodRequestBytes = ByteBuffer(string: "GET / HTTP/1.1\r\nHost: example.com\r\nWeird-Value: \(weirdAllowedFieldValue)\r\n\r\n") + + XCTAssertNoThrow(try channel.writeOutbound(HTTPClientRequestPart.head(goodRequest))) + + var maybeBytes: ByteBuffer? + + XCTAssertNoThrow(maybeBytes = try channel.readOutbound()) + XCTAssertEqual(maybeBytes, goodRequestBytes) + + // Now confirm all other bytes in the ASCII range are rejected. + for byte in UInt8(0)..?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~" + + let channel = EmbeddedChannel() + try channel.pipeline.syncOperations.addHTTPClientHandlers() + + let headers = HTTPHeaders([("Host", "example.com"), ("Transfer-Encoding", "chunked")]) + let goodRequest = HTTPRequestHead(version: .http1_1, method: .POST, uri: "/", headers: headers) + let goodRequestBytes = ByteBuffer(string: "POST / HTTP/1.1\r\nHost: example.com\r\ntransfer-encoding: chunked\r\n\r\n") + let goodTrailers = ByteBuffer(string: "0\r\nWeird-Value: \(weirdAllowedFieldValue)\r\n\r\n") + + XCTAssertNoThrow(try channel.writeOutbound(HTTPClientRequestPart.head(goodRequest))) + XCTAssertNoThrow(try channel.writeOutbound(HTTPClientRequestPart.end(["Weird-Value": weirdAllowedFieldValue]))) + + var maybeRequestHeadBytes: ByteBuffer? + var maybeRequestEndBytes: ByteBuffer? + + XCTAssertNoThrow(maybeRequestHeadBytes = try channel.readOutbound()) + XCTAssertNoThrow(maybeRequestEndBytes = try channel.readOutbound()) + XCTAssertEqual(maybeRequestHeadBytes, goodRequestBytes) + XCTAssertEqual(maybeRequestEndBytes, goodTrailers) + + // Now confirm all other bytes in the ASCII range are rejected. + for byte in UInt8(0)..?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~" + + let channel = EmbeddedChannel() + try channel.pipeline.syncOperations.configureHTTPServerPipeline(withErrorHandling: false) + try channel.primeForResponse() + + let headers = HTTPHeaders([("Content-Length", "0"), ("Weird-Value", weirdAllowedFieldValue)]) + let goodResponse = HTTPResponseHead(version: .http1_1, status: .ok, headers: headers) + let goodResponseBytes = ByteBuffer(string: "HTTP/1.1 200 OK\r\nContent-Length: 0\r\nWeird-Value: \(weirdAllowedFieldValue)\r\n\r\n") + + XCTAssertNoThrow(try channel.writeOutbound(HTTPServerResponsePart.head(goodResponse))) + + var maybeBytes: ByteBuffer? + + XCTAssertNoThrow(maybeBytes = try channel.readOutbound()) + XCTAssertEqual(maybeBytes, goodResponseBytes) + + // Now confirm all other bytes in the ASCII range are rejected. + for byte in UInt8(0)..?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~" + + let channel = EmbeddedChannel() + try channel.pipeline.syncOperations.configureHTTPServerPipeline(withErrorHandling: false) + try channel.primeForResponse() + + let headers = HTTPHeaders([("Transfer-Encoding", "chunked")]) + let goodResponse = HTTPResponseHead(version: .http1_1, status: .ok, headers: headers) + let goodResponseBytes = ByteBuffer(string: "HTTP/1.1 200 OK\r\ntransfer-encoding: chunked\r\n\r\n") + let goodTrailers = ByteBuffer(string: "0\r\nWeird-Value: \(weirdAllowedFieldValue)\r\n\r\n") + + XCTAssertNoThrow(try channel.writeOutbound(HTTPServerResponsePart.head(goodResponse))) + XCTAssertNoThrow(try channel.writeOutbound(HTTPServerResponsePart.end(["Weird-Value": weirdAllowedFieldValue]))) + + var maybeResponseHeadBytes: ByteBuffer? + var maybeResponseEndBytes: ByteBuffer? + + XCTAssertNoThrow(maybeResponseHeadBytes = try channel.readOutbound()) + XCTAssertNoThrow(maybeResponseEndBytes = try channel.readOutbound()) + XCTAssertEqual(maybeResponseHeadBytes, goodResponseBytes) + XCTAssertEqual(maybeResponseEndBytes, goodTrailers) + + // Now confirm all other bytes in the ASCII range are rejected. + for byte in UInt8(0)..