Skip to content

Commit bfde40c

Browse files
authored
Update http-parser for CVE. (#1387)
Motivation: http-parser shipped a patche for node.js CVE-2019-15605, which allowed HTTP request smuggling. This affected SwiftNIO as well, and so we need to immediately ship an update to help protect affected users. A CVE for SwiftNIO will follow, but as this patch is in the wild and SwiftNIO is known to be affected we should not delay shipping this fix. Modifications: - Update http-parser. - Add regression tests to validate this behaviour. Result: Close request smugging vector.
1 parent ddb7109 commit bfde40c

File tree

7 files changed

+190
-32
lines changed

7 files changed

+190
-32
lines changed

Sources/CNIOHTTPParser/c_nio_http_parser.c

Lines changed: 76 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -384,7 +384,10 @@ enum header_states
384384
, h_transfer_encoding
385385
, h_upgrade
386386

387+
, h_matching_transfer_encoding_token_start
387388
, h_matching_transfer_encoding_chunked
389+
, h_matching_transfer_encoding_token
390+
388391
, h_matching_connection_token_start
389392
, h_matching_connection_keep_alive
390393
, h_matching_connection_close
@@ -1338,6 +1341,7 @@ size_t c_nio_http_parser_execute (http_parser *parser,
13381341
parser->header_state = h_general;
13391342
} else if (parser->index == sizeof(TRANSFER_ENCODING)-2) {
13401343
parser->header_state = h_transfer_encoding;
1344+
parser->flags |= F_TRANSFER_ENCODING;
13411345
}
13421346
break;
13431347

@@ -1419,10 +1423,14 @@ size_t c_nio_http_parser_execute (http_parser *parser,
14191423
if ('c' == c) {
14201424
parser->header_state = h_matching_transfer_encoding_chunked;
14211425
} else {
1422-
parser->header_state = h_general;
1426+
parser->header_state = h_matching_transfer_encoding_token;
14231427
}
14241428
break;
14251429

1430+
/* Multi-value `Transfer-Encoding` header */
1431+
case h_matching_transfer_encoding_token_start:
1432+
break;
1433+
14261434
case h_content_length:
14271435
if (UNLIKELY(!IS_NUM(ch))) {
14281436
SET_ERRNO(HPE_INVALID_CONTENT_LENGTH);
@@ -1566,16 +1574,41 @@ size_t c_nio_http_parser_execute (http_parser *parser,
15661574
goto error;
15671575

15681576
/* Transfer-Encoding: chunked */
1577+
case h_matching_transfer_encoding_token_start:
1578+
/* looking for 'Transfer-Encoding: chunked' */
1579+
if ('c' == c) {
1580+
h_state = h_matching_transfer_encoding_chunked;
1581+
} else if (STRICT_TOKEN(c)) {
1582+
/* TODO(indutny): similar code below does this, but why?
1583+
* At the very least it seems to be inconsistent given that
1584+
* h_matching_transfer_encoding_token does not check for
1585+
* `STRICT_TOKEN`
1586+
*/
1587+
h_state = h_matching_transfer_encoding_token;
1588+
} else if (c == ' ' || c == '\t') {
1589+
/* Skip lws */
1590+
} else {
1591+
h_state = h_general;
1592+
}
1593+
break;
1594+
15691595
case h_matching_transfer_encoding_chunked:
15701596
parser->index++;
15711597
if (parser->index > sizeof(CHUNKED)-1
15721598
|| c != CHUNKED[parser->index]) {
1573-
h_state = h_general;
1599+
h_state = h_matching_transfer_encoding_token;
15741600
} else if (parser->index == sizeof(CHUNKED)-2) {
15751601
h_state = h_transfer_encoding_chunked;
15761602
}
15771603
break;
15781604

1605+
case h_matching_transfer_encoding_token:
1606+
if (ch == ',') {
1607+
h_state = h_matching_transfer_encoding_token_start;
1608+
parser->index = 0;
1609+
}
1610+
break;
1611+
15791612
case h_matching_connection_token_start:
15801613
/* looking for 'Connection: keep-alive' */
15811614
if (c == 'k') {
@@ -1634,7 +1667,7 @@ size_t c_nio_http_parser_execute (http_parser *parser,
16341667
break;
16351668

16361669
case h_transfer_encoding_chunked:
1637-
if (ch != ' ') h_state = h_general;
1670+
if (ch != ' ') h_state = h_matching_transfer_encoding_token;
16381671
break;
16391672

16401673
case h_connection_keep_alive:
@@ -1768,12 +1801,17 @@ size_t c_nio_http_parser_execute (http_parser *parser,
17681801
REEXECUTE();
17691802
}
17701803

1771-
/* Cannot use chunked encoding and a content-length header together
1772-
per the HTTP specification. */
1773-
if ((parser->flags & F_CHUNKED) &&
1804+
/* Cannot us transfer-encoding and a content-length header together
1805+
per the HTTP specification. (RFC 7230 Section 3.3.3) */
1806+
if ((parser->flags & F_TRANSFER_ENCODING) &&
17741807
(parser->flags & F_CONTENTLENGTH)) {
1775-
SET_ERRNO(HPE_UNEXPECTED_CONTENT_LENGTH);
1776-
goto error;
1808+
/* Allow it for lenient parsing as long as `Transfer-Encoding` is
1809+
* not `chunked`
1810+
*/
1811+
if (!lenient || (parser->flags & F_CHUNKED)) {
1812+
SET_ERRNO(HPE_UNEXPECTED_CONTENT_LENGTH);
1813+
goto error;
1814+
}
17771815
}
17781816

17791817
UPDATE_STATE(s_headers_done);
@@ -1848,8 +1886,31 @@ size_t c_nio_http_parser_execute (http_parser *parser,
18481886
UPDATE_STATE(NEW_MESSAGE());
18491887
CALLBACK_NOTIFY(message_complete);
18501888
} else if (parser->flags & F_CHUNKED) {
1851-
/* chunked encoding - ignore Content-Length header */
1889+
/* chunked encoding - ignore Content-Length header,
1890+
* prepare for a chunk */
18521891
UPDATE_STATE(s_chunk_size_start);
1892+
} else if (parser->flags & F_TRANSFER_ENCODING) {
1893+
if (parser->type == HTTP_REQUEST && !lenient) {
1894+
/* RFC 7230 3.3.3 */
1895+
1896+
/* If a Transfer-Encoding header field
1897+
* is present in a request and the chunked transfer coding is not
1898+
* the final encoding, the message body length cannot be determined
1899+
* reliably; the server MUST respond with the 400 (Bad Request)
1900+
* status code and then close the connection.
1901+
*/
1902+
SET_ERRNO(HPE_INVALID_TRANSFER_ENCODING);
1903+
RETURN(p - data); /* Error */
1904+
} else {
1905+
/* RFC 7230 3.3.3 */
1906+
1907+
/* If a Transfer-Encoding header field is present in a response and
1908+
* the chunked transfer coding is not the final encoding, the
1909+
* message body length is determined by reading the connection until
1910+
* it is closed by the server.
1911+
*/
1912+
UPDATE_STATE(s_body_identity_eof);
1913+
}
18531914
} else {
18541915
if (parser->content_length == 0) {
18551916
/* Content-Length header given but zero: Content-Length: 0\r\n */
@@ -2103,6 +2164,12 @@ c_nio_http_message_needs_eof (const http_parser *parser)
21032164
return 0;
21042165
}
21052166

2167+
/* RFC 7230 3.3.3, see `s_headers_almost_done` */
2168+
if ((parser->flags & F_TRANSFER_ENCODING) &&
2169+
(parser->flags & F_CHUNKED) == 0) {
2170+
return 1;
2171+
}
2172+
21062173
if ((parser->flags & F_CHUNKED) || parser->content_length != ULLONG_MAX) {
21072174
return 0;
21082175
}

Sources/CNIOHTTPParser/include/c_nio_http_parser.h

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ extern "C" {
3030
/* Also update SONAME in the Makefile whenever you change these. */
3131
#define HTTP_PARSER_VERSION_MAJOR 2
3232
#define HTTP_PARSER_VERSION_MINOR 9
33-
#define HTTP_PARSER_VERSION_PATCH 2
33+
#define HTTP_PARSER_VERSION_PATCH 3
3434

3535
#include <stddef.h>
3636
#if defined(_WIN32) && !defined(__MINGW32__) && \
@@ -228,6 +228,7 @@ enum flags
228228
, F_UPGRADE = 1 << 5
229229
, F_SKIPBODY = 1 << 6
230230
, F_CONTENTLENGTH = 1 << 7
231+
, F_TRANSFER_ENCODING = 1 << 8
231232
};
232233

233234

@@ -274,6 +275,8 @@ enum flags
274275
"unexpected content-length header") \
275276
XX(INVALID_CHUNK_SIZE, \
276277
"invalid character in chunk size header") \
278+
XX(INVALID_TRANSFER_ENCODING, \
279+
"request has invalid transfer-encoding") \
277280
XX(INVALID_CONSTANT, "invalid constant string") \
278281
XX(INVALID_INTERNAL_STATE, "encountered unexpected internal state")\
279282
XX(STRICT, "strict mode assertion failed") \
@@ -296,11 +299,11 @@ enum http_errno {
296299
struct http_parser {
297300
/** PRIVATE **/
298301
unsigned int type : 2; /* enum http_parser_type */
299-
unsigned int flags : 8; /* F_* values from 'flags' enum; semi-public */
300302
unsigned int state : 7; /* enum state from http_parser.c */
301303
unsigned int header_state : 7; /* enum header_state from http_parser.c */
302304
unsigned int index : 7; /* index into current matcher */
303305
unsigned int lenient_http_headers : 1;
306+
unsigned int flags : 16; /* F_* values from 'flags' enum; semi-public */
304307

305308
uint32_t nread; /* # bytes read in various scenarios */
306309
uint64_t content_length; /* # bytes in body (0 if no Content-Length header) */

Sources/NIOHTTP1/HTTPDecoder.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -706,6 +706,9 @@ extension HTTPParserError {
706706
return .paused
707707
case HPE_UNKNOWN:
708708
return .unknown
709+
case HPE_INVALID_TRANSFER_ENCODING:
710+
// The downside of enums here, we don't have a case for this. Map it to .unknown for now.
711+
return .unknown
709712
default:
710713
return nil
711714
}

Tests/NIOHTTP1Tests/HTTPDecoderLengthTest+XCTest.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ extension HTTPDecoderLengthTest {
4646
("testIgnoresTransferEncodingFieldOn304Responses", testIgnoresTransferEncodingFieldOn304Responses),
4747
("testIgnoresContentLengthFieldOn304Responses", testIgnoresContentLengthFieldOn304Responses),
4848
("testEarlyFinishWithoutLengthAtAllOn304Responses", testEarlyFinishWithoutLengthAtAllOn304Responses),
49-
("testMultipleTEWithChunkedLastHasNoBodyOnRequest", testMultipleTEWithChunkedLastHasNoBodyOnRequest),
49+
("testMultipleTEWithChunkedLastWorksFine", testMultipleTEWithChunkedLastWorksFine),
5050
("testMultipleTEWithChunkedFirstHasNoBodyOnRequest", testMultipleTEWithChunkedFirstHasNoBodyOnRequest),
5151
("testMultipleTEWithChunkedInTheMiddleHasNoBodyOnRequest", testMultipleTEWithChunkedInTheMiddleHasNoBodyOnRequest),
5252
("testMultipleTEWithChunkedLastHasEOFBodyOnResponseWithChannelInactive", testMultipleTEWithChunkedLastHasEOFBodyOnResponseWithChannelInactive),

Tests/NIOHTTP1Tests/HTTPDecoderLengthTest.swift

Lines changed: 61 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -292,14 +292,26 @@ class HTTPDecoderLengthTest: XCTestCase {
292292
try assertIgnoresLengthFields(requestMethod: .GET, responseStatus: .notModified, responseFramingField: .neither)
293293
}
294294

295-
private func assertRequestTransferEncodingHasNoBody(transferEncodingHeader: String) throws {
295+
private func assertRequestTransferEncodingInError(transferEncodingHeader: String) throws {
296296
XCTAssertNoThrow(try channel.pipeline.addHandler(ByteToMessageHandler(HTTPRequestDecoder())).wait())
297297

298298
let handler = MessageEndHandler<HTTPRequestHead, ByteBuffer>()
299299
XCTAssertNoThrow(try channel.pipeline.addHandler(handler).wait())
300300

301301
// Send a GET with the appropriate Transfer Encoding header.
302-
XCTAssertNoThrow(try channel.writeInbound(ByteBuffer(string: "POST / HTTP/1.1\r\nTransfer-Encoding: \(transferEncodingHeader)\r\n\r\n")))
302+
XCTAssertThrowsError(try channel.writeInbound(ByteBuffer(string: "POST / HTTP/1.1\r\nTransfer-Encoding: \(transferEncodingHeader)\r\n\r\n"))) { error in
303+
XCTAssertEqual(error as? HTTPParserError, .unknown)
304+
}
305+
}
306+
307+
func testMultipleTEWithChunkedLastWorksFine() throws {
308+
XCTAssertNoThrow(try channel.pipeline.addHandler(ByteToMessageHandler(HTTPRequestDecoder())).wait())
309+
310+
let handler = MessageEndHandler<HTTPRequestHead, ByteBuffer>()
311+
XCTAssertNoThrow(try channel.pipeline.addHandler(handler).wait())
312+
313+
// Send a GET with the appropriate Transfer Encoding header.
314+
XCTAssertNoThrow(try channel.writeInbound(ByteBuffer(string: "POST / HTTP/1.1\r\nTransfer-Encoding: gzip, chunked\r\n\r\n0\r\n\r\n")))
303315

304316
// We should have a request, no body, and immediately see end of request.
305317
XCTAssert(handler.seenHead)
@@ -309,22 +321,12 @@ class HTTPDecoderLengthTest: XCTestCase {
309321
XCTAssertTrue(try channel.finish().isClean)
310322
}
311323

312-
func testMultipleTEWithChunkedLastHasNoBodyOnRequest() throws {
313-
// This is not quite right: RFC 7230 should allow this as chunked. However, http_parser
314-
// does not, so we don't either.
315-
try assertRequestTransferEncodingHasNoBody(transferEncodingHeader: "gzip, chunked")
316-
}
317-
318324
func testMultipleTEWithChunkedFirstHasNoBodyOnRequest() throws {
319-
// Here http_parser is again wrong: strictly this should 400. Again, though,
320-
// if http_parser doesn't support this neither do we.
321-
try assertRequestTransferEncodingHasNoBody(transferEncodingHeader: "chunked, gzip")
325+
try assertRequestTransferEncodingInError(transferEncodingHeader: "chunked, gzip")
322326
}
323327

324328
func testMultipleTEWithChunkedInTheMiddleHasNoBodyOnRequest() throws {
325-
// Here http_parser is again wrong: strictly this should 400. Again, though,
326-
// if http_parser doesn't support this neither do we.
327-
try assertRequestTransferEncodingHasNoBody(transferEncodingHeader: "gzip, chunked, deflate")
329+
try assertRequestTransferEncodingInError(transferEncodingHeader: "gzip, chunked, deflate")
328330
}
329331

330332
private func assertResponseTransferEncodingHasBodyTerminatedByEOF(transferEncodingHeader: String, eofMechanism: EOFMechanism) throws {
@@ -366,10 +368,51 @@ class HTTPDecoderLengthTest: XCTestCase {
366368
XCTAssertTrue(try channel.finish().isClean)
367369
}
368370

371+
private func assertResponseTransferEncodingHasBodyTerminatedByEndOfChunk(transferEncodingHeader: String, eofMechanism: EOFMechanism) throws {
372+
XCTAssertNoThrow(try channel.pipeline.addHandler(HTTPRequestEncoder()).wait())
373+
XCTAssertNoThrow(try channel.pipeline.addHandler(ByteToMessageHandler(HTTPResponseDecoder())).wait())
374+
375+
let handler = MessageEndHandler<HTTPResponseHead, ByteBuffer>()
376+
XCTAssertNoThrow(try channel.pipeline.addHandler(handler).wait())
377+
378+
// Prime the decoder with a request and consume it.
379+
XCTAssertTrue(try channel.writeOutbound(HTTPClientRequestPart.head(HTTPRequestHead(version: .init(major: 1, minor: 1),
380+
method: .GET,
381+
uri: "/"))).isFull)
382+
XCTAssertNoThrow(XCTAssertNotNil(try channel.readOutbound(as: ByteBuffer.self)))
383+
384+
// Send a 200 with the appropriate Transfer Encoding header. We should see the request.
385+
XCTAssertNoThrow(try channel.writeInbound(ByteBuffer(string: "HTTP/1.1 200 OK\r\nTransfer-Encoding: \(transferEncodingHeader)\r\n\r\n")))
386+
XCTAssert(handler.seenHead)
387+
XCTAssertFalse(handler.seenBody)
388+
XCTAssertFalse(handler.seenEnd)
389+
390+
// Now send body. Note that this *is* chunk encoded. We should also see a body.
391+
XCTAssertNoThrow(try channel.writeInbound(ByteBuffer(string: "9\r\ncaribbean\r\n")))
392+
XCTAssert(handler.seenHead)
393+
XCTAssert(handler.seenBody)
394+
XCTAssertFalse(handler.seenEnd)
395+
396+
// Now send EOF. This should error, as we're expecting the end chunk.
397+
if case .halfClosure = eofMechanism {
398+
channel.pipeline.fireUserInboundEventTriggered(ChannelEvent.inputClosed)
399+
} else {
400+
channel.pipeline.fireChannelInactive()
401+
}
402+
403+
XCTAssert(handler.seenHead)
404+
XCTAssert(handler.seenBody)
405+
XCTAssertFalse(handler.seenEnd)
406+
407+
XCTAssertThrowsError(try channel.throwIfErrorCaught()) { error in
408+
XCTAssertEqual(error as? HTTPParserError, .invalidEOFState)
409+
}
410+
411+
XCTAssertTrue(try channel.finish().isClean)
412+
}
413+
369414
func testMultipleTEWithChunkedLastHasEOFBodyOnResponseWithChannelInactive() throws {
370-
// This is not right: RFC 7230 should allow this as chunked, but http_parser instead parses it as
371-
// EOF-terminated. We can't easily override that, so we don't.
372-
try assertResponseTransferEncodingHasBodyTerminatedByEOF(transferEncodingHeader: "gzip, chunked", eofMechanism: .channelInactive)
415+
try assertResponseTransferEncodingHasBodyTerminatedByEndOfChunk(transferEncodingHeader: "gzip, chunked", eofMechanism: .channelInactive)
373416
}
374417

375418
func testMultipleTEWithChunkedFirstHasEOFBodyOnResponseWithChannelInactive() throws {
@@ -383,9 +426,7 @@ class HTTPDecoderLengthTest: XCTestCase {
383426
}
384427

385428
func testMultipleTEWithChunkedLastHasEOFBodyOnResponseWithHalfClosure() throws {
386-
// This is not right: RFC 7230 should allow this as chunked, but http_parser instead parses it as
387-
// EOF-terminated. We can't easily override that, so we don't.
388-
try assertResponseTransferEncodingHasBodyTerminatedByEOF(transferEncodingHeader: "gzip, chunked", eofMechanism: .halfClosure)
429+
try assertResponseTransferEncodingHasBodyTerminatedByEndOfChunk(transferEncodingHeader: "gzip, chunked", eofMechanism: .halfClosure)
389430
}
390431

391432
func testMultipleTEWithChunkedFirstHasEOFBodyOnResponseWithHalfClosure() throws {

Tests/NIOHTTP1Tests/HTTPDecoderTest+XCTest.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ extension HTTPDecoderTest {
5454
("testAppropriateErrorWhenReceivingUnsolicitedResponse", testAppropriateErrorWhenReceivingUnsolicitedResponse),
5555
("testAppropriateErrorWhenReceivingUnsolicitedResponseDoesNotRecover", testAppropriateErrorWhenReceivingUnsolicitedResponseDoesNotRecover),
5656
("testOneRequestTwoResponses", testOneRequestTwoResponses),
57+
("testRefusesRequestSmugglingAttempt", testRefusesRequestSmugglingAttempt),
58+
("testTrimsTrailingOWS", testTrimsTrailingOWS),
5759
]
5860
}
5961
}

Tests/NIOHTTP1Tests/HTTPDecoderTest.swift

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -832,4 +832,46 @@ class HTTPDecoderTest: XCTestCase {
832832
XCTAssertEqual(["channelReadComplete", "write", "flush", "channelRead", "errorCaught"], eventCounter.allTriggeredEvents())
833833
XCTAssertNoThrow(XCTAssertTrue(try channel.finish().isClean))
834834
}
835+
836+
func testRefusesRequestSmugglingAttempt() throws {
837+
XCTAssertNoThrow(try channel.pipeline.addHandler(ByteToMessageHandler(HTTPRequestDecoder())).wait())
838+
839+
// This is a request smuggling attempt caused by duplicating the Transfer-Encoding and Content-Length headers.
840+
var buffer = channel.allocator.buffer(capacity: 256)
841+
buffer.writeString("POST /foo HTTP/1.1\r\n" +
842+
"Host: localhost\r\n" +
843+
"Content-length: 1\r\n" +
844+
"Transfer-Encoding: gzip, chunked\r\n\r\n" +
845+
"3\r\na=1\r\n0\r\n\r\n")
846+
847+
do {
848+
try channel.writeInbound(buffer)
849+
XCTFail("Did not error")
850+
} catch HTTPParserError.unexpectedContentLength {
851+
// ok
852+
} catch {
853+
XCTFail("Unexpected error: \(error)")
854+
}
855+
856+
loop.run()
857+
}
858+
859+
func testTrimsTrailingOWS() throws {
860+
XCTAssertNoThrow(try channel.pipeline.addHandler(ByteToMessageHandler(HTTPRequestDecoder())).wait())
861+
862+
var buffer = channel.allocator.buffer(capacity: 64)
863+
buffer.writeStaticString("GET / HTTP/1.1\r\nHost: localhost\r\nFoo: bar \r\nBaz: Boz\r\n\r\n")
864+
865+
XCTAssertNoThrow(try channel.writeInbound(buffer))
866+
let request = try assertNoThrowWithValue(channel.readInbound(as: HTTPServerRequestPart.self))
867+
guard case .some(.head(let head)) = request else {
868+
XCTFail("Unexpected first message: \(String(describing: request))")
869+
return
870+
}
871+
XCTAssertEqual(head.headers[canonicalForm: "Foo"], ["bar"])
872+
guard case .some(.end) = try assertNoThrowWithValue(channel.readInbound(as: HTTPServerRequestPart.self)) else {
873+
XCTFail("Unexpected last message")
874+
return
875+
}
876+
}
835877
}

0 commit comments

Comments
 (0)