Skip to content

Commit 312b3ff

Browse files
committed
refactor request validation
1 parent ec2e080 commit 312b3ff

File tree

6 files changed

+206
-104
lines changed

6 files changed

+206
-104
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the AsyncHTTPClient open source project
4+
//
5+
// Copyright (c) 2021 Apple Inc. and the AsyncHTTPClient 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 AsyncHTTPClient project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
enum RequestBodyLength: Equatable {
16+
/// size of the reuqest body is not known before starting the request
17+
case dynamic
18+
/// size of the request body is fixed and exactly `length` bytes
19+
case fixed(length: Int)
20+
}

Sources/AsyncHTTPClient/HTTPClient.swift

+3
Original file line numberDiff line numberDiff line change
@@ -884,6 +884,7 @@ public struct HTTPClientError: Error, Equatable, CustomStringConvertible {
884884
case cancelled
885885
case identityCodingIncorrectlyPresent
886886
case chunkedSpecifiedMultipleTimes
887+
case transferEncodingSpecifiedButChunkedIsNotTheFinalEncoding
887888
case invalidProxyResponse
888889
case contentLengthMissing
889890
case proxyAuthenticationRequired
@@ -938,6 +939,8 @@ public struct HTTPClientError: Error, Equatable, CustomStringConvertible {
938939
public static let identityCodingIncorrectlyPresent = HTTPClientError(code: .identityCodingIncorrectlyPresent)
939940
/// Request contains multiple chunks definitions.
940941
public static let chunkedSpecifiedMultipleTimes = HTTPClientError(code: .chunkedSpecifiedMultipleTimes)
942+
/// Request specifies `Transfer-Encoding` but `chunked` is not the final encoding
943+
public static let transferEncodingSpecifiedButChunkedIsNotTheFinalEncoding = HTTPClientError(code: .transferEncodingSpecifiedButChunkedIsNotTheFinalEncoding)
941944
/// Proxy response was invalid.
942945
public static let invalidProxyResponse = HTTPClientError(code: .invalidProxyResponse)
943946
/// Request does not contain `Content-Length` header.

Sources/AsyncHTTPClient/HTTPHandler.swift

+16-3
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,7 @@ extension HTTPClient {
4343
}
4444
}
4545

46-
/// Body size. Request validation will be failed with `HTTPClientErrors.contentLengthMissing` if nil,
47-
/// unless `Trasfer-Encoding: chunked` header is set.
46+
/// Body size. if nil and `Transfer-Encoding` header is **not** set, `Transfer-Encoding` will be automatically be set to `chunked`
4847
public var length: Int?
4948
/// Body chunk provider.
5049
public var stream: (StreamWriter) -> EventLoopFuture<Void>
@@ -309,7 +308,7 @@ extension HTTPClient {
309308
head.headers.add(name: "host", value: host)
310309
}
311310

312-
let metadata = try head.headers.validate(method: self.method, body: self.body)
311+
let metadata = try head.headers.validateAndFixTransportFraming(method: self.method, bodyLength: .init(self.body))
313312

314313
return (head, metadata)
315314
}
@@ -820,3 +819,17 @@ internal struct RedirectHandler<ResponseType> {
820819
}
821820
}
822821
}
822+
823+
extension RequestBodyLength {
824+
init(_ body: HTTPClient.Body?) {
825+
guard let body = body else {
826+
self = .fixed(length: 0)
827+
return
828+
}
829+
guard let length = body.length else {
830+
self = .dynamic
831+
return
832+
}
833+
self = .fixed(length: length)
834+
}
835+
}

Sources/AsyncHTTPClient/RequestValidation.swift

+75-60
Original file line numberDiff line numberDiff line change
@@ -16,93 +16,79 @@ import NIOCore
1616
import NIOHTTP1
1717

1818
extension HTTPHeaders {
19-
mutating func validate(method: HTTPMethod, body: HTTPClient.Body?) throws -> RequestFramingMetadata {
20-
var metadata = RequestFramingMetadata(connectionClose: false, body: .none)
21-
22-
if self[canonicalForm: "connection"].lazy.map({ $0.lowercased() }).contains("close") {
23-
metadata.connectionClose = true
24-
}
25-
26-
// validate transfer encoding and content length (https://tools.ietf.org/html/rfc7230#section-3.3.1)
27-
if self.contains(name: "Transfer-Encoding"), self.contains(name: "Content-Length") {
19+
mutating func validateAndFixTransportFraming(
20+
method: HTTPMethod,
21+
bodyLength: RequestBodyLength
22+
) throws -> RequestFramingMetadata {
23+
let contentLength = self.first(name: "Content-Length")
24+
let encodings = self[canonicalForm: "Transfer-Encoding"]
25+
26+
// "Transfer-Encoding" and "Content-Length" are not allowed to present at the same time (https://tools.ietf.org/html/rfc7230#section-3.3.1)
27+
guard encodings.isEmpty || contentLength == nil else {
2828
throw HTTPClientError.incompatibleHeaders
2929
}
3030

31-
var transferEncoding: String?
32-
var contentLength: Int?
33-
let encodings = self[canonicalForm: "Transfer-Encoding"].map { $0.lowercased() }
31+
try self.validateFieldNames()
32+
try Self.validateTransferEncoding(encodings)
3433

35-
guard !encodings.contains("identity") else {
36-
throw HTTPClientError.identityCodingIncorrectlyPresent
34+
if contentLength != nil {
35+
self.remove(name: "Content-Length")
36+
}
37+
if !encodings.isEmpty {
38+
self.remove(name: "Transfer-Encoding")
3739
}
3840

39-
self.remove(name: "Transfer-Encoding")
40-
41-
try self.validateFieldNames()
41+
let connectionClose = self[canonicalForm: "connection"].lazy.map { $0.lowercased() }.contains("close")
4242

43-
guard let body = body else {
44-
self.remove(name: "Content-Length")
43+
switch bodyLength {
44+
case .fixed(0):
4545
// if we don't have a body we might not need to send the Content-Length field
4646
// https://tools.ietf.org/html/rfc7230#section-3.3.2
4747
switch method {
4848
case .GET, .HEAD, .DELETE, .CONNECT, .TRACE:
4949
// A user agent SHOULD NOT send a Content-Length header field when the request
5050
// message does not contain a payload body and the method semantics do not
5151
// anticipate such a body.
52-
return metadata
52+
break
5353
default:
5454
// A user agent SHOULD send a Content-Length in a request message when
5555
// no Transfer-Encoding is sent and the request method defines a meaning
5656
// for an enclosed payload body.
5757
self.add(name: "Content-Length", value: "0")
58-
return metadata
5958
}
60-
}
61-
62-
if case .TRACE = method {
63-
// A client MUST NOT send a message body in a TRACE request.
64-
// https://tools.ietf.org/html/rfc7230#section-4.3.8
65-
throw HTTPClientError.traceRequestWithBody
66-
}
67-
68-
guard (encodings.lazy.filter { $0 == "chunked" }.count <= 1) else {
69-
throw HTTPClientError.chunkedSpecifiedMultipleTimes
70-
}
71-
72-
if encodings.isEmpty {
73-
if let length = body.length {
74-
self.remove(name: "Content-Length")
75-
contentLength = length
76-
} else if !self.contains(name: "Content-Length") {
77-
transferEncoding = "chunked"
59+
return .init(connectionClose: connectionClose, body: .fixedSize(0))
60+
case .fixed(let length):
61+
if case .TRACE = method {
62+
// A client MUST NOT send a message body in a TRACE request.
63+
// https://tools.ietf.org/html/rfc7230#section-4.3.8
64+
throw HTTPClientError.traceRequestWithBody
65+
}
66+
if encodings.isEmpty {
67+
self.add(name: "Content-Length", value: String(length))
68+
return .init(connectionClose: connectionClose, body: .fixedSize(length))
69+
} else {
70+
self.add(name: "Transfer-Encoding", value: encodings.joined(separator: ", "))
71+
return .init(connectionClose: connectionClose, body: .stream)
72+
}
73+
case .dynamic:
74+
if case .TRACE = method {
75+
// A client MUST NOT send a message body in a TRACE request.
76+
// https://tools.ietf.org/html/rfc7230#section-4.3.8
77+
throw HTTPClientError.traceRequestWithBody
7878
}
79-
} else {
80-
self.remove(name: "Content-Length")
8179

82-
transferEncoding = encodings.joined(separator: ", ")
83-
if !encodings.contains("chunked") {
84-
guard let length = body.length else {
85-
throw HTTPClientError.contentLengthMissing
86-
}
87-
contentLength = length
80+
if encodings.isEmpty && contentLength == nil {
81+
// if a user forgot to specify a Content-Length and Transfer-Encoding, we will set it for them
82+
self.add(name: "Transfer-Encoding", value: "chunked")
83+
} else {
84+
self.add(name: "Transfer-Encoding", value: encodings.joined(separator: ", "))
8885
}
89-
}
9086

91-
// add headers if required
92-
if let enc = transferEncoding {
93-
self.add(name: "Transfer-Encoding", value: enc)
94-
metadata.body = .stream
95-
} else if let length = contentLength {
96-
// A sender MUST NOT send a Content-Length header field in any message
97-
// that contains a Transfer-Encoding header field.
98-
self.add(name: "Content-Length", value: String(length))
99-
metadata.body = .fixedSize(length)
87+
return .init(connectionClose: connectionClose, body: .stream)
10088
}
101-
102-
return metadata
10389
}
10490

105-
private func validateFieldNames() throws {
91+
func validateFieldNames() throws {
10692
let invalidFieldNames = self.compactMap { (name, _) -> String? in
10793
let satisfy = name.utf8.allSatisfy { (char) -> Bool in
10894
switch char {
@@ -137,4 +123,33 @@ extension HTTPHeaders {
137123
throw HTTPClientError.invalidHeaderFieldNames(invalidFieldNames)
138124
}
139125
}
126+
127+
static func validateTransferEncoding<Encodings>(
128+
_ encodings: Encodings
129+
) throws where Encodings: Sequence, Encodings.Element: StringProtocol {
130+
let encodings = encodings.map { $0.lowercased() }
131+
132+
guard !encodings.contains("identity") else {
133+
throw HTTPClientError.identityCodingIncorrectlyPresent
134+
}
135+
136+
// If `Transfer-Encoding` is specified, `chunked` needs to be the last encoding and should not be specified multiple times
137+
// https://datatracker.ietf.org/doc/html/rfc7230#section-3.3.1
138+
let chunkedEncodingCount = encodings.lazy.filter { $0 == "chunked" }.count
139+
switch chunkedEncodingCount {
140+
case 0:
141+
if !encodings.isEmpty {
142+
throw HTTPClientError.transferEncodingSpecifiedButChunkedIsNotTheFinalEncoding
143+
}
144+
case 1:
145+
guard encodings.last == "chunked" else {
146+
throw HTTPClientError.transferEncodingSpecifiedButChunkedIsNotTheFinalEncoding
147+
}
148+
case 2...:
149+
throw HTTPClientError.chunkedSpecifiedMultipleTimes
150+
default:
151+
// unreachable because `chunkedEncodingCount` is guaranteed to be positive
152+
preconditionFailure()
153+
}
154+
}
140155
}

Tests/AsyncHTTPClientTests/RequestValidationTests+XCTest.swift

+5
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ extension RequestValidationTests {
4343
("testBothHeadersNoBody", testBothHeadersNoBody),
4444
("testBothHeadersHasBody", testBothHeadersHasBody),
4545
("testHostHeaderIsSetCorrectlyInCreateRequestHead", testHostHeaderIsSetCorrectlyInCreateRequestHead),
46+
("testTraceMethodIsNotAllowedToHaveAFixedLengthBody", testTraceMethodIsNotAllowedToHaveAFixedLengthBody),
47+
("testTraceMethodIsNotAllowedToHaveADynamicLengthBody", testTraceMethodIsNotAllowedToHaveADynamicLengthBody),
48+
("testTransferEncodingsArePreservedIfBodyLengthIsFixed", testTransferEncodingsArePreservedIfBodyLengthIsFixed),
49+
("testTransferEncodingsArePreservedIfBodyLengthIsDynamic", testTransferEncodingsArePreservedIfBodyLengthIsDynamic),
50+
("testTransferEncodingValidation", testTransferEncodingValidation),
4651
]
4752
}
4853
}

0 commit comments

Comments
 (0)