Skip to content

Commit 3f06fc1

Browse files
committed
Add support for automatic HTTP error reporting.
Motivation: Currently the HTTP decoders can throw errors, but they will be ignored and lead to a simple EOF. That's not ideal: in most cases we should make a best-effort attempt to send a 4XX error code before we shut the client down. Modifications: Provided a new ChannelHandler that generates 400 errors when the HTTP decoder fails. Added a flag to automatically add that handler to the channel pipeline. Added the handler to the HTTP sample server. Enabled integration test 12. Result: Easier error handling for HTTP servers.
1 parent 2a684fc commit 3f06fc1

File tree

9 files changed

+176
-9
lines changed

9 files changed

+176
-9
lines changed

IntegrationTests/tests_01_http/SKIP_test_12_headers_too_large.sh renamed to IntegrationTests/tests_01_http/test_12_headers_too_large.sh

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,22 +21,21 @@ htdocs=$(get_htdocs "$token")
2121
touch "$tmp/empty"
2222
cr=$(echo -e '\r')
2323
cat > "$tmp/headers_expected" <<EOF
24-
HTTP/1.0 431 Request Header Fields Too Large$cr
25-
Content-Length: 0$cr
26-
Connection: Close$cr
27-
X-HTTPServer-Error: too many header bytes seen; overflow detected$cr
24+
HTTP/1.1 400 Bad Request$cr
25+
content-length: 0$cr
26+
connection: close$cr
2827
$cr
2928
EOF
3029
echo "FOO BAR" > "$htdocs/some_file.txt"
3130
# headers have acceptable size
3231
do_curl "$token" -H "$(python -c 'print "x"*80000'): x" \
33-
"http://foobar.com/some_file.txt" > "$tmp/out"
32+
"http://foobar.com/fileio/some_file.txt" > "$tmp/out"
3433
assert_equal_files "$htdocs/some_file.txt" "$tmp/out"
3534

3635
# headers too large
3736
do_curl "$token" -H "$(python -c 'print "x"*90000'): x" \
3837
-D "$tmp/headers_actual" \
39-
"http://foobar.com/some_file.txt" > "$tmp/out"
38+
"http://foobar.com/fileio/some_file.txt" > "$tmp/out"
4039
assert_equal_files "$tmp/empty" "$tmp/out"
4140
assert_equal_files "$tmp/headers_expected" "$tmp/headers_actual"
4241
stop_server "$token"

Sources/NIOHTTP1/HTTPDecoder.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,13 @@ public class HTTPDecoder<HTTPMessageT>: ByteToMessageDecoder, AnyHTTPDecoder {
412412
ctx.fireChannelReadComplete()
413413
}
414414

415+
public func decodeLast(ctx: ChannelHandlerContext, buffer: inout ByteBuffer) throws -> DecodingState {
416+
// This handler parses data eagerly, so re-parsing for decodeLast is not useful.
417+
// TODO(cory): We should actually add EOF handling here, because EOF can be meaningful in HTTP.
418+
// See https://github.com/apple/swift-nio/issues/254
419+
return .needMoreData
420+
}
421+
415422
public func errorCaught(ctx: ChannelHandlerContext, error: Error) {
416423
ctx.fireErrorCaught(error)
417424
if error is HTTPParserError {

Sources/NIOHTTP1/HTTPPipelineSetup.swift

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,10 +89,14 @@ public extension ChannelPipeline {
8989
/// provided should be a tuple of an array of `HTTPProtocolUpgrader` and the upgrade
9090
/// completion handler. See the documentation on `HTTPServerUpgradeHandler` for more
9191
/// details.
92+
/// - errorHandling: Whether to provide assistance handling protocol errors (e.g.
93+
/// failure to parse the HTTP request) by sending 400 errors. Defaults to `false` for
94+
/// backward-compatibility reasons.
9295
/// - returns: An `EventLoopFuture` that will fire when the pipeline is configured.
9396
public func configureHTTPServerPipeline(first: Bool = false,
9497
withPipeliningAssistance pipelining: Bool = true,
95-
withServerUpgrade upgrade: HTTPUpgradeConfiguration? = nil) -> EventLoopFuture<Void> {
98+
withServerUpgrade upgrade: HTTPUpgradeConfiguration? = nil,
99+
withErrorHandling errorHandling: Bool = false) -> EventLoopFuture<Void> {
96100
let responseEncoder = HTTPResponseEncoder()
97101
let requestDecoder = HTTPRequestDecoder()
98102

@@ -102,6 +106,10 @@ public extension ChannelPipeline {
102106
handlers.append(HTTPServerPipelineHandler())
103107
}
104108

109+
if errorHandling {
110+
handlers.append(HTTPServerProtocolErrorHandler())
111+
}
112+
105113
if let (upgraders, completionHandler) = upgrade {
106114
let upgrader = HTTPServerUpgradeHandler(upgraders: upgraders,
107115
httpEncoder: responseEncoder,
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the SwiftNIO open source project
4+
//
5+
// Copyright (c) 2017-2018 Apple Inc. and the SwiftNIO project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of SwiftNIO project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
import NIO
16+
17+
/// A simple channel handler that catches errors emitted by parsing HTTP requests
18+
/// and sends 400 Bad Request responses.
19+
///
20+
/// This channel handler provides the basic behaviour that the majority of simple HTTP
21+
/// servers want. This handler does not suppress the parser errors: it allows them to
22+
/// continue to pass through the pipeline so that other handlers (e.g. logging ones) can
23+
/// deal with the error.
24+
public final class HTTPServerProtocolErrorHandler: ChannelInboundHandler {
25+
public typealias InboundIn = HTTPServerRequestPart
26+
public typealias InboundOut = HTTPServerRequestPart
27+
public typealias OutboundOut = HTTPServerResponsePart
28+
29+
public func errorCaught(ctx: ChannelHandlerContext, error: Error) {
30+
guard error is HTTPParserError else {
31+
ctx.fireErrorCaught(error)
32+
return
33+
}
34+
35+
// Any HTTPParserError is automatically fatal, and we don't actually need (or want) to
36+
// provide that error to the client: we just want to tell it that it screwed up and then
37+
// let the rest of the pipeline shut the door in its face.
38+
//
39+
// A side note here: we cannot block or do any delayed work. ByteToMessageDecoder is going
40+
// to come along and close the channel right after we return from this function.
41+
let headers = HTTPHeaders([("Connection", "close"), ("Content-Length", "0")])
42+
let head = HTTPResponseHead(version: .init(major: 1, minor: 1), status: .badRequest, headers: headers)
43+
ctx.write(self.wrapOutboundOut(.head(head)), promise: nil)
44+
ctx.writeAndFlush(self.wrapOutboundOut(.end(nil)), promise: nil)
45+
46+
// Now pass the error on in case someone else wants to see it.
47+
ctx.fireErrorCaught(error)
48+
}
49+
}

Sources/NIOHTTP1Server/main.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -471,7 +471,7 @@ let bootstrap = ServerBootstrap(group: group)
471471

472472
// Set the handlers that are applied to the accepted Channels
473473
.childChannelInitializer { channel in
474-
channel.pipeline.configureHTTPServerPipeline().then {
474+
channel.pipeline.configureHTTPServerPipeline(withErrorHandling: true).then {
475475
channel.pipeline.add(handler: HTTPHandler(fileIO: fileIO, htdocsPath: htdocs))
476476
}
477477
}

Tests/LinuxMain.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ import XCTest
5858
testCase(HTTPResponseEncoderTests.allTests),
5959
testCase(HTTPServerClientTest.allTests),
6060
testCase(HTTPServerPipelineHandlerTest.allTests),
61+
testCase(HTTPServerProtocolErrorHandlerTest.allTests),
6162
testCase(HTTPTest.allTests),
6263
testCase(HTTPUpgradeTestCase.allTests),
6364
testCase(HappyEyeballsTest.allTests),
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the SwiftNIO open source project
4+
//
5+
// Copyright (c) 2017-2018 Apple Inc. and the SwiftNIO project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of SwiftNIO project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
//
15+
// HTTPServerProtocolErrorHandlerTest+XCTest.swift
16+
//
17+
import XCTest
18+
19+
///
20+
/// NOTE: This file was generated by generate_linux_tests.rb
21+
///
22+
/// Do NOT edit this file directly as it will be regenerated automatically when needed.
23+
///
24+
25+
extension HTTPServerProtocolErrorHandlerTest {
26+
27+
static var allTests : [(String, (HTTPServerProtocolErrorHandlerTest) -> () throws -> Void)] {
28+
return [
29+
("testHandlesBasicErrors", testHandlesBasicErrors),
30+
("testIgnoresNonParserErrors", testIgnoresNonParserErrors),
31+
]
32+
}
33+
}
34+
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the SwiftNIO open source project
4+
//
5+
// Copyright (c) 2017-2018 Apple Inc. and the SwiftNIO project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of SwiftNIO project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
import XCTest
16+
import NIO
17+
import NIOHTTP1
18+
19+
class HTTPServerProtocolErrorHandlerTest: XCTestCase {
20+
func testHandlesBasicErrors() throws {
21+
let channel = EmbeddedChannel()
22+
XCTAssertNoThrow(try channel.pipeline.configureHTTPServerPipeline(withErrorHandling: true).wait())
23+
24+
var buffer = channel.allocator.buffer(capacity: 1024)
25+
buffer.write(staticString: "GET / HTTP/1.1\r\nContent-Length: -4\r\n\r\n")
26+
do {
27+
try channel.writeInbound(buffer)
28+
} catch HTTPParserError.invalidContentLength {
29+
// This error is expected
30+
}
31+
(channel.eventLoop as! EmbeddedEventLoop).run()
32+
33+
// The channel should be closed at this stage.
34+
XCTAssertNoThrow(try channel.closeFuture.wait())
35+
36+
// We expect exactly one ByteBuffer in the output.
37+
guard case .some(.byteBuffer(var written)) = channel.readOutbound() else {
38+
XCTFail("No writes")
39+
return
40+
}
41+
42+
XCTAssertNil(channel.readOutbound())
43+
44+
// Check the response.
45+
assertResponseIs(response: written.readString(length: written.readableBytes)!,
46+
expectedResponseLine: "HTTP/1.1 400 Bad Request",
47+
expectedResponseHeaders: ["connection: close", "content-length: 0"])
48+
}
49+
50+
func testIgnoresNonParserErrors() throws {
51+
enum DummyError: Error {
52+
case error
53+
}
54+
let channel = EmbeddedChannel()
55+
XCTAssertNoThrow(try channel.pipeline.configureHTTPServerPipeline(withErrorHandling: true).wait())
56+
57+
channel.pipeline.fireErrorCaught(DummyError.error)
58+
do {
59+
try channel.throwIfErrorCaught()
60+
XCTFail("No error caught")
61+
} catch DummyError.error {
62+
// ok
63+
} catch {
64+
XCTFail("Unexpected error: \(error)")
65+
}
66+
67+
XCTAssertNoThrow(try channel.finish())
68+
}
69+
}

Tests/NIOHTTP1Tests/HTTPUpgradeTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ private func setUpTestWithAutoremoval(pipelining: Bool = false,
138138
return (group, serverChannel, clientChannel, try connectedServerChannelFuture.wait())
139139
}
140140

141-
private func assertResponseIs(response: String, expectedResponseLine: String, expectedResponseHeaders: [String]) {
141+
internal func assertResponseIs(response: String, expectedResponseLine: String, expectedResponseHeaders: [String]) {
142142
var lines = response.split(separator: "\r\n", omittingEmptySubsequences: false).map { String($0) }
143143

144144
// We never expect a response body here. This means we need the last two entries to be empty strings.

0 commit comments

Comments
 (0)