diff --git a/Sources/NIOHTTP1/HTTPHeaderValidator.swift b/Sources/NIOHTTP1/HTTPHeaderValidator.swift new file mode 100644 index 0000000000..d068c30c6f --- /dev/null +++ b/Sources/NIOHTTP1/HTTPHeaderValidator.swift @@ -0,0 +1,90 @@ +//===----------------------------------------------------------------------===// +// +// 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) + } +} + 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 cf48863081..3deddeccfa 100644 --- a/Sources/NIOHTTP1/HTTPPipelineSetup.swift +++ b/Sources/NIOHTTP1/HTTPPipelineSetup.swift @@ -78,6 +78,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 + } + /// Configure a `ChannelPipeline` for use as a HTTP server. /// /// This function knows how to set up all first-party HTTP channel handlers appropriately @@ -107,6 +146,62 @@ extension ChannelPipeline { withPipeliningAssistance pipelining: Bool = true, withServerUpgrade upgrade: NIOHTTPServerUpgradeConfiguration? = nil, withErrorHandling errorHandling: Bool = true) -> EventLoopFuture { + self._configureHTTPServerPipeline( + position: position, + withPipeliningAssistance: pipelining, + withServerUpgrade: upgrade, + withErrorHandling: errorHandling + ) + } + + /// 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, + withOutboundHeaderValidation headerValidation: Bool = true) -> EventLoopFuture { let future: EventLoopFuture if self.eventLoop.inEventLoop { @@ -114,7 +209,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 { @@ -122,7 +218,8 @@ extension ChannelPipeline { try self.syncOperations.configureHTTPServerPipeline(position: position, withPipeliningAssistance: pipelining, withServerUpgrade: upgrade, - withErrorHandling: errorHandling) + withErrorHandling: errorHandling, + withOutboundHeaderValidation: headerValidation) } } @@ -146,13 +243,46 @@ extension ChannelPipeline.SynchronousOperations { public func addHTTPClientHandlers(position: ChannelPipeline.Position = .last, leftOverBytesStrategy: RemoveAfterUpgradeStrategy = .dropBytes, 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, + withClientUpgrade: upgrade + ) + } + + /// 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 { + 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) @@ -164,22 +294,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) } @@ -214,6 +352,63 @@ extension ChannelPipeline.SynchronousOperations { withPipeliningAssistance pipelining: Bool = true, withServerUpgrade upgrade: NIOHTTPServerUpgradeConfiguration? = nil, withErrorHandling errorHandling: Bool = true) throws { + try self._configureHTTPServerPipeline( + position: position, + withPipeliningAssistance: pipelining, + withServerUpgrade: upgrade, + withErrorHandling: errorHandling + ) + } + + /// 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, + withOutboundHeaderValidation headerValidation: Bool = true) throws { self.eventLoop.assertInEventLoop() let responseEncoder = HTTPResponseEncoder() @@ -225,6 +420,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 cba806ff58..49e6e0b310 100644 --- a/Tests/LinuxMain.swift +++ b/Tests/LinuxMain.swift @@ -81,6 +81,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 22d73cd777..2f9b4a48fa 100644 --- a/Tests/NIOHTTP1Tests/HTTPDecoderTest+XCTest.swift +++ b/Tests/NIOHTTP1Tests/HTTPDecoderTest+XCTest.swift @@ -2,7 +2,7 @@ // // This source file is part of the SwiftNIO open source project // -// Copyright (c) 2018-2021 Apple Inc. and the SwiftNIO project authors +// Copyright (c) 2018-2022 Apple Inc. and the SwiftNIO project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -60,6 +60,10 @@ extension HTTPDecoderTest { ("testRefusesRequestSmugglingAttempt", testRefusesRequestSmugglingAttempt), ("testTrimsTrailingOWS", testTrimsTrailingOWS), ("testMassiveChunkDoesNotBufferAndGivesUsHoweverMuchIsAvailable", testMassiveChunkDoesNotBufferAndGivesUsHoweverMuchIsAvailable), + ("testDecodingInvalidHeaderFieldNames", testDecodingInvalidHeaderFieldNames), + ("testDecodingInvalidTrailerFieldNames", testDecodingInvalidTrailerFieldNames), + ("testDecodingInvalidHeaderFieldValues", testDecodingInvalidHeaderFieldValues), + ("testDecodingInvalidTrailerFieldValues", testDecodingInvalidTrailerFieldValues), ] } } diff --git a/Tests/NIOHTTP1Tests/HTTPDecoderTest.swift b/Tests/NIOHTTP1Tests/HTTPDecoderTest.swift index b3bb0c335b..0e3dd02286 100644 --- a/Tests/NIOHTTP1Tests/HTTPDecoderTest.swift +++ b/Tests/NIOHTTP1Tests/HTTPDecoderTest.swift @@ -945,4 +945,233 @@ class HTTPDecoderTest: XCTestCase { XCTAssertEqual(.invalidEOFState, error as? HTTPParserError) } } + + 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)..