Skip to content

Commit 7f05a8d

Browse files
authored
Merge pull request from GHSA-v3r5-pjpm-mwgq
Motivation Allowing arbitrary data in outbound header field values allows for the possibility that users of AHC will accidentally pass untrusted data into those values. That untrusted data can substantially alter the parsing and content of the HTTP requests, which is extremely dangerous. The result of this is vulnerability to CRLF injection. Modifications Add validation of outbound header field values. Result No longer vulnerable to CRLF injection
1 parent 49abfc3 commit 7f05a8d

File tree

4 files changed

+188
-0
lines changed

4 files changed

+188
-0
lines changed

Sources/AsyncHTTPClient/HTTPClient.swift

+5
Original file line numberDiff line numberDiff line change
@@ -1032,6 +1032,7 @@ public struct HTTPClientError: Error, Equatable, CustomStringConvertible {
10321032
case uncleanShutdown
10331033
case traceRequestWithBody
10341034
case invalidHeaderFieldNames([String])
1035+
case invalidHeaderFieldValues([String])
10351036
case bodyLengthMismatch
10361037
case writeAfterRequestSent
10371038
@available(*, deprecated, message: "AsyncHTTPClient now silently corrects invalid headers.")
@@ -1100,6 +1101,8 @@ public struct HTTPClientError: Error, Equatable, CustomStringConvertible {
11001101
return "Trace request with body"
11011102
case .invalidHeaderFieldNames:
11021103
return "Invalid header field names"
1104+
case .invalidHeaderFieldValues:
1105+
return "Invalid header field values"
11031106
case .bodyLengthMismatch:
11041107
return "Body length mismatch"
11051108
case .writeAfterRequestSent:
@@ -1166,6 +1169,8 @@ public struct HTTPClientError: Error, Equatable, CustomStringConvertible {
11661169
public static let traceRequestWithBody = HTTPClientError(code: .traceRequestWithBody)
11671170
/// Header field names contain invalid characters.
11681171
public static func invalidHeaderFieldNames(_ names: [String]) -> HTTPClientError { return HTTPClientError(code: .invalidHeaderFieldNames(names)) }
1172+
/// Header field values contain invalid characters.
1173+
public static func invalidHeaderFieldValues(_ values: [String]) -> HTTPClientError { return HTTPClientError(code: .invalidHeaderFieldValues(values)) }
11691174
/// Body length is not equal to `Content-Length`.
11701175
public static let bodyLengthMismatch = HTTPClientError(code: .bodyLengthMismatch)
11711176
/// Body part was written after request was fully sent.

Sources/AsyncHTTPClient/RequestValidation.swift

+51
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ extension HTTPHeaders {
2121
bodyLength: RequestBodyLength
2222
) throws -> RequestFramingMetadata {
2323
try self.validateFieldNames()
24+
try self.validateFieldValues()
2425

2526
if case .TRACE = method {
2627
switch bodyLength {
@@ -80,6 +81,56 @@ extension HTTPHeaders {
8081
}
8182
}
8283

84+
private func validateFieldValues() throws {
85+
let invalidValues = self.compactMap { _, value -> String? in
86+
let satisfy = value.utf8.allSatisfy { char -> Bool in
87+
/// Validates a byte of a given header field value against the definition in RFC 9110.
88+
///
89+
/// The spec in [RFC 9110](https://httpwg.org/specs/rfc9110.html#fields.values) defines the valid
90+
/// characters as the following:
91+
///
92+
/// ```
93+
/// field-value = *field-content
94+
/// field-content = field-vchar
95+
/// [ 1*( SP / HTAB / field-vchar ) field-vchar ]
96+
/// field-vchar = VCHAR / obs-text
97+
/// obs-text = %x80-FF
98+
/// ```
99+
///
100+
/// Additionally, it makes the following note:
101+
///
102+
/// "Field values containing CR, LF, or NUL characters are invalid and dangerous, due to the
103+
/// varying ways that implementations might parse and interpret those characters; a recipient
104+
/// of CR, LF, or NUL within a field value MUST either reject the message or replace each of
105+
/// those characters with SP before further processing or forwarding of that message. Field
106+
/// values containing other CTL characters are also invalid; however, recipients MAY retain
107+
/// such characters for the sake of robustness when they appear within a safe context (e.g.,
108+
/// an application-specific quoted string that will not be processed by any downstream HTTP
109+
/// parser)."
110+
///
111+
/// As we cannot guarantee the context is safe, this code will reject all ASCII control characters
112+
/// directly _except_ for HTAB, which is explicitly allowed.
113+
switch char {
114+
case UInt8(ascii: "\t"):
115+
// HTAB, explicitly allowed.
116+
return true
117+
case 0...0x1f, 0x7F:
118+
// ASCII control character, forbidden.
119+
return false
120+
default:
121+
// Printable or non-ASCII, allowed.
122+
return true
123+
}
124+
}
125+
126+
return satisfy ? nil : value
127+
}
128+
129+
guard invalidValues.count == 0 else {
130+
throw HTTPClientError.invalidHeaderFieldValues(invalidValues)
131+
}
132+
}
133+
83134
private mutating func setTransportFraming(
84135
method: HTTPMethod,
85136
bodyLength: RequestBodyLength

Tests/AsyncHTTPClientTests/AsyncAwaitEndToEndTests+XCTest.swift

+4
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ extension AsyncAwaitEndToEndTests {
4545
("testShutdown", testShutdown),
4646
("testCancelingBodyDoesNotCrash", testCancelingBodyDoesNotCrash),
4747
("testAsyncSequenceReuse", testAsyncSequenceReuse),
48+
("testRejectsInvalidCharactersInHeaderFieldNames_http1", testRejectsInvalidCharactersInHeaderFieldNames_http1),
49+
("testRejectsInvalidCharactersInHeaderFieldNames_http2", testRejectsInvalidCharactersInHeaderFieldNames_http2),
50+
("testRejectsInvalidCharactersInHeaderFieldValues_http1", testRejectsInvalidCharactersInHeaderFieldValues_http1),
51+
("testRejectsInvalidCharactersInHeaderFieldValues_http2", testRejectsInvalidCharactersInHeaderFieldValues_http2),
4852
]
4953
}
5054
}

Tests/AsyncHTTPClientTests/AsyncAwaitEndToEndTests.swift

+128
Original file line numberDiff line numberDiff line change
@@ -597,6 +597,134 @@ final class AsyncAwaitEndToEndTests: XCTestCase {
597597
XCTAssertEqual(body, ByteBuffer(string: "1234"))
598598
}
599599
}
600+
601+
func testRejectsInvalidCharactersInHeaderFieldNames_http1() {
602+
self._rejectsInvalidCharactersInHeaderFieldNames(mode: .http1_1(ssl: true))
603+
}
604+
605+
func testRejectsInvalidCharactersInHeaderFieldNames_http2() {
606+
self._rejectsInvalidCharactersInHeaderFieldNames(mode: .http2(compress: false))
607+
}
608+
609+
private func _rejectsInvalidCharactersInHeaderFieldNames(mode: HTTPBin<HTTPBinHandler>.Mode) {
610+
guard #available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) else { return }
611+
XCTAsyncTest {
612+
let bin = HTTPBin(mode)
613+
defer { XCTAssertNoThrow(try bin.shutdown()) }
614+
let client = makeDefaultHTTPClient()
615+
defer { XCTAssertNoThrow(try client.syncShutdown()) }
616+
let logger = Logger(label: "HTTPClient", factory: StreamLogHandler.standardOutput(label:))
617+
618+
// The spec in [RFC 9110](https://httpwg.org/specs/rfc9110.html#fields.values) defines the valid
619+
// characters as the following:
620+
//
621+
// ```
622+
// field-name = token
623+
//
624+
// token = 1*tchar
625+
//
626+
// tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*"
627+
// / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~"
628+
// / DIGIT / ALPHA
629+
// ; any VCHAR, except delimiters
630+
let weirdAllowedFieldName = "!#$%&'*+-.^_`|~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
631+
632+
var request = HTTPClientRequest(url: "https://localhost:\(bin.port)/get")
633+
request.headers.add(name: weirdAllowedFieldName, value: "present")
634+
635+
// This should work fine.
636+
guard let response = await XCTAssertNoThrowWithResult(
637+
try await client.execute(request, deadline: .now() + .seconds(10), logger: logger)
638+
) else {
639+
return
640+
}
641+
642+
XCTAssertEqual(response.status, .ok)
643+
644+
// Now, let's confirm all other bytes are rejected. We want to stay within the ASCII space as the HTTPHeaders type will forbid anything else.
645+
for byte in UInt8(0)...UInt8(127) {
646+
// Skip bytes that we already believe are allowed.
647+
if weirdAllowedFieldName.utf8.contains(byte) {
648+
continue
649+
}
650+
let forbiddenFieldName = weirdAllowedFieldName + String(decoding: [byte], as: UTF8.self)
651+
652+
var request = HTTPClientRequest(url: "https://localhost:\(bin.port)/get")
653+
request.headers.add(name: forbiddenFieldName, value: "present")
654+
655+
await XCTAssertThrowsError(try await client.execute(request, deadline: .now() + .seconds(10), logger: logger)) { error in
656+
XCTAssertEqual(error as? HTTPClientError, .invalidHeaderFieldNames([forbiddenFieldName]))
657+
}
658+
}
659+
}
660+
}
661+
662+
func testRejectsInvalidCharactersInHeaderFieldValues_http1() {
663+
self._rejectsInvalidCharactersInHeaderFieldValues(mode: .http1_1(ssl: true))
664+
}
665+
666+
func testRejectsInvalidCharactersInHeaderFieldValues_http2() {
667+
self._rejectsInvalidCharactersInHeaderFieldValues(mode: .http2(compress: false))
668+
}
669+
670+
private func _rejectsInvalidCharactersInHeaderFieldValues(mode: HTTPBin<HTTPBinHandler>.Mode) {
671+
guard #available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) else { return }
672+
XCTAsyncTest {
673+
let bin = HTTPBin(mode)
674+
defer { XCTAssertNoThrow(try bin.shutdown()) }
675+
let client = makeDefaultHTTPClient()
676+
defer { XCTAssertNoThrow(try client.syncShutdown()) }
677+
let logger = Logger(label: "HTTPClient", factory: StreamLogHandler.standardOutput(label:))
678+
679+
// We reject all ASCII control characters except HTAB and tolerate everything else.
680+
let weirdAllowedFieldValue = "!\" \t#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~"
681+
682+
var request = HTTPClientRequest(url: "https://localhost:\(bin.port)/get")
683+
request.headers.add(name: "Weird-Value", value: weirdAllowedFieldValue)
684+
685+
// This should work fine.
686+
guard let response = await XCTAssertNoThrowWithResult(
687+
try await client.execute(request, deadline: .now() + .seconds(10), logger: logger)
688+
) else {
689+
return
690+
}
691+
692+
XCTAssertEqual(response.status, .ok)
693+
694+
// Now, let's confirm all other bytes in the ASCII range ar rejected
695+
for byte in UInt8(0)...UInt8(127) {
696+
// Skip bytes that we already believe are allowed.
697+
if weirdAllowedFieldValue.utf8.contains(byte) {
698+
continue
699+
}
700+
let forbiddenFieldValue = weirdAllowedFieldValue + String(decoding: [byte], as: UTF8.self)
701+
702+
var request = HTTPClientRequest(url: "https://localhost:\(bin.port)/get")
703+
request.headers.add(name: "Weird-Value", value: forbiddenFieldValue)
704+
705+
await XCTAssertThrowsError(try await client.execute(request, deadline: .now() + .seconds(10), logger: logger)) { error in
706+
XCTAssertEqual(error as? HTTPClientError, .invalidHeaderFieldValues([forbiddenFieldValue]))
707+
}
708+
}
709+
710+
// All the bytes outside the ASCII range are fine though.
711+
for byte in UInt8(128)...UInt8(255) {
712+
let evenWeirderAllowedValue = weirdAllowedFieldValue + String(decoding: [byte], as: UTF8.self)
713+
714+
var request = HTTPClientRequest(url: "https://localhost:\(bin.port)/get")
715+
request.headers.add(name: "Weird-Value", value: evenWeirderAllowedValue)
716+
717+
// This should work fine.
718+
guard let response = await XCTAssertNoThrowWithResult(
719+
try await client.execute(request, deadline: .now() + .seconds(10), logger: logger)
720+
) else {
721+
return
722+
}
723+
724+
XCTAssertEqual(response.status, .ok)
725+
}
726+
}
727+
}
600728
}
601729

602730
extension AsyncSequence where Element == ByteBuffer {

0 commit comments

Comments
 (0)