Skip to content

Commit 972bcdd

Browse files
authored
Redo HTTP cookie parsing (#510)
* Redo HTTP cookie parsing using strptime * Make String(utf8Slice:from:) less ugly * Adjust cookie component parsing to better match RFC-6562
1 parent 19e83a3 commit 972bcdd

11 files changed

+743
-96
lines changed

Diff for: Package.swift

+2
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,11 @@ let package = Package(
2929
.package(url: "https://github.com/apple/swift-log.git", from: "1.4.0"),
3030
],
3131
targets: [
32+
.target(name: "CAsyncHTTPClient"),
3233
.target(
3334
name: "AsyncHTTPClient",
3435
dependencies: [
36+
.target(name: "CAsyncHTTPClient"),
3537
.product(name: "NIO", package: "swift-nio"),
3638
.product(name: "NIOCore", package: "swift-nio"),
3739
.product(name: "NIOPosix", package: "swift-nio"),

Diff for: Sources/AsyncHTTPClient/FoundationExtensions.swift

+56
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the AsyncHTTPClient open source project
4+
//
5+
// Copyright (c) 2018-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+
// Extensions which provide better ergonomics when using Foundation types,
16+
// or by using Foundation APIs.
17+
18+
import Foundation
19+
20+
extension HTTPClient.Cookie {
21+
/// The cookie's expiration date.
22+
public var expires: Date? {
23+
get {
24+
expires_timestamp.map { Date(timeIntervalSince1970: TimeInterval($0)) }
25+
}
26+
set {
27+
expires_timestamp = newValue.map { Int64($0.timeIntervalSince1970) }
28+
}
29+
}
30+
31+
/// Create HTTP cookie.
32+
///
33+
/// - parameters:
34+
/// - name: The name of the cookie.
35+
/// - value: The cookie's string value.
36+
/// - path: The cookie's path.
37+
/// - domain: The domain of the cookie, defaults to nil.
38+
/// - expires: The cookie's expiration date, defaults to nil.
39+
/// - maxAge: The cookie's age in seconds, defaults to nil.
40+
/// - httpOnly: Whether this cookie should be used by HTTP servers only, defaults to false.
41+
/// - secure: Whether this cookie should only be sent using secure channels, defaults to false.
42+
public init(name: String, value: String, path: String = "/", domain: String? = nil, expires: Date? = nil, maxAge: Int? = nil, httpOnly: Bool = false, secure: Bool = false) {
43+
// FIXME: This should be failable and validate the inputs
44+
// (for example, checking that the strings are ASCII, path begins with "/", domain is not empty, etc).
45+
self.init(
46+
name: name,
47+
value: value,
48+
path: path,
49+
domain: domain,
50+
expires_timestamp: expires.map { Int64($0.timeIntervalSince1970) },
51+
maxAge: maxAge,
52+
httpOnly: httpOnly,
53+
secure: secure
54+
)
55+
}
56+
}

Diff for: Sources/AsyncHTTPClient/HTTPClient+HTTPCookie.swift

+138-77
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,13 @@
1212
//
1313
//===----------------------------------------------------------------------===//
1414

15-
import Foundation
1615
import NIOHTTP1
16+
#if canImport(Darwin)
17+
import Darwin
18+
#elseif canImport(Glibc)
19+
import Glibc
20+
#endif
21+
import CAsyncHTTPClient
1722

1823
extension HTTPClient {
1924
/// A representation of an HTTP cookie.
@@ -26,8 +31,8 @@ extension HTTPClient {
2631
public var path: String
2732
/// The domain of the cookie.
2833
public var domain: String?
29-
/// The cookie's expiration date.
30-
public var expires: Date?
34+
/// The cookie's expiration date, as a number of seconds since the Unix epoch.
35+
var expires_timestamp: Int64?
3136
/// The cookie's age in seconds.
3237
public var maxAge: Int?
3338
/// Whether the cookie should only be sent to HTTP servers.
@@ -42,79 +47,72 @@ extension HTTPClient {
4247
/// - defaultDomain: Default domain to use if cookie was sent without one.
4348
/// - returns: nil if the header is invalid.
4449
public init?(header: String, defaultDomain: String) {
45-
let components = header.components(separatedBy: ";").map {
46-
$0.trimmingCharacters(in: .whitespaces)
47-
}
48-
49-
if components.isEmpty {
50+
// The parsing of "Set-Cookie" headers is defined by Section 5.2, RFC-6265:
51+
// https://datatracker.ietf.org/doc/html/rfc6265#section-5.2
52+
var components = header.utf8.split(separator: UInt8(ascii: ";"), omittingEmptySubsequences: false)[...]
53+
guard let keyValuePair = components.popFirst()?.trimmingASCIISpaces() else {
5054
return nil
5155
}
52-
53-
let nameAndValue = components[0].split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false).map {
54-
$0.trimmingCharacters(in: .whitespaces)
55-
}
56-
57-
guard nameAndValue.count == 2 else {
56+
guard let (trimmedName, trimmedValue) = keyValuePair.parseKeyValuePair() else {
5857
return nil
5958
}
60-
61-
self.name = nameAndValue[0]
62-
self.value = nameAndValue[1].omittingQuotes()
63-
64-
guard !self.name.isEmpty else {
59+
guard !trimmedName.isEmpty else {
6560
return nil
6661
}
6762

68-
self.path = "/"
69-
self.domain = defaultDomain
70-
self.expires = nil
63+
self.name = String(aligningUTF8: trimmedName)
64+
self.value = String(aligningUTF8: trimmedValue.trimmingPairedASCIIQuote())
65+
self.expires_timestamp = nil
7166
self.maxAge = nil
7267
self.httpOnly = false
7368
self.secure = false
7469

75-
for component in components[1...] {
76-
switch self.parseComponent(component) {
77-
case (nil, nil):
78-
continue
79-
case ("path", .some(let value)):
80-
self.path = value
81-
case ("domain", .some(let value)):
82-
self.domain = value
83-
case ("expires", let value):
84-
guard let value = value else {
70+
var parsedPath: String.UTF8View.SubSequence?
71+
var parsedDomain: String.UTF8View.SubSequence?
72+
73+
for component in components {
74+
switch component.parseCookieComponent() {
75+
case ("path", let value)?:
76+
// Unlike other values, unspecified, empty, and invalid paths reset to the default path.
77+
// https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.4
78+
guard let value = value, value.first == UInt8(ascii: "/") else {
79+
parsedPath = nil
8580
continue
8681
}
87-
88-
let formatter = DateFormatter()
89-
formatter.locale = Locale(identifier: "en_US")
90-
formatter.timeZone = TimeZone(identifier: "GMT")
91-
92-
formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss z"
93-
if let date = formatter.date(from: value) {
94-
self.expires = date
82+
parsedPath = value
83+
case ("domain", let value)?:
84+
guard var value = value, !value.isEmpty else {
9585
continue
9686
}
97-
98-
formatter.dateFormat = "EEE, dd-MMM-yy HH:mm:ss z"
99-
if let date = formatter.date(from: value) {
100-
self.expires = date
87+
if value.first == UInt8(ascii: ".") {
88+
value.removeFirst()
89+
}
90+
guard !value.isEmpty else {
91+
parsedDomain = nil
10192
continue
10293
}
103-
104-
formatter.dateFormat = "EEE MMM d hh:mm:s yyyy"
105-
if let date = formatter.date(from: value) {
106-
self.expires = date
94+
parsedDomain = value
95+
case ("expires", let value)?:
96+
guard let value = value, let timestamp = parseCookieTime(value) else {
97+
continue
10798
}
108-
case ("max-age", let value):
109-
self.maxAge = value.flatMap(Int.init)
110-
case ("secure", nil):
99+
self.expires_timestamp = timestamp
100+
case ("max-age", let value)?:
101+
guard let value = value, let age = Int(Substring(value)) else {
102+
continue
103+
}
104+
self.maxAge = age
105+
case ("secure", _)?:
111106
self.secure = true
112-
case ("httponly", nil):
107+
case ("httponly", _)?:
113108
self.httpOnly = true
114109
default:
115110
continue
116111
}
117112
}
113+
114+
self.domain = parsedDomain.map { Substring($0).lowercased() } ?? defaultDomain.lowercased()
115+
self.path = parsedPath.map { String(aligningUTF8: $0) } ?? "/"
118116
}
119117

120118
/// Create HTTP cookie.
@@ -124,51 +122,114 @@ extension HTTPClient {
124122
/// - value: The cookie's string value.
125123
/// - path: The cookie's path.
126124
/// - domain: The domain of the cookie, defaults to nil.
127-
/// - expires: The cookie's expiration date, defaults to nil.
125+
/// - expires_timestamp: The cookie's expiration date, as a number of seconds since the Unix epoch. defaults to nil.
128126
/// - maxAge: The cookie's age in seconds, defaults to nil.
129127
/// - httpOnly: Whether this cookie should be used by HTTP servers only, defaults to false.
130128
/// - secure: Whether this cookie should only be sent using secure channels, defaults to false.
131-
public init(name: String, value: String, path: String = "/", domain: String? = nil, expires: Date? = nil, maxAge: Int? = nil, httpOnly: Bool = false, secure: Bool = false) {
129+
internal init(name: String, value: String, path: String = "/", domain: String? = nil, expires_timestamp: Int64? = nil, maxAge: Int? = nil, httpOnly: Bool = false, secure: Bool = false) {
132130
self.name = name
133131
self.value = value
134132
self.path = path
135133
self.domain = domain
136-
self.expires = expires
134+
self.expires_timestamp = expires_timestamp
137135
self.maxAge = maxAge
138136
self.httpOnly = httpOnly
139137
self.secure = secure
140138
}
139+
}
140+
}
141141

142-
func parseComponent(_ component: String) -> (String?, String?) {
143-
let nameAndValue = component.split(separator: "=", maxSplits: 1).map {
144-
$0.trimmingCharacters(in: .whitespaces)
145-
}
146-
if nameAndValue.count == 2 {
147-
return (nameAndValue[0].lowercased(), nameAndValue[1])
148-
} else if nameAndValue.count == 1 {
149-
return (nameAndValue[0].lowercased(), nil)
150-
}
151-
return (nil, nil)
152-
}
142+
extension HTTPClient.Response {
143+
/// List of HTTP cookies returned by the server.
144+
public var cookies: [HTTPClient.Cookie] {
145+
return self.headers["set-cookie"].compactMap { HTTPClient.Cookie(header: $0, defaultDomain: self.host) }
153146
}
154147
}
155148

156149
extension String {
157-
fileprivate func omittingQuotes() -> String {
158-
let dquote = "\""
159-
if !hasPrefix(dquote) || !hasSuffix(dquote) {
160-
return self
150+
/// Creates a String from a slice of UTF8 code-units, aligning the bounds to unicode scalar boundaries if needed.
151+
fileprivate init(aligningUTF8 utf8Slice: String.UTF8View.SubSequence) {
152+
self.init(Substring(utf8Slice))
153+
}
154+
}
155+
156+
extension String.UTF8View.SubSequence {
157+
fileprivate func trimmingASCIISpaces() -> SubSequence {
158+
guard let start = self.firstIndex(where: { $0 != UInt8(ascii: " ") }) else {
159+
return self[self.endIndex..<self.endIndex]
160+
}
161+
let end = self.lastIndex(where: { $0 != UInt8(ascii: " ") })!
162+
return self[start...end]
163+
}
164+
165+
/// If this collection begins and ends with an ASCII double-quote ("),
166+
/// returns a version of self trimmed of those quotes. Otherwise, returns self.
167+
fileprivate func trimmingPairedASCIIQuote() -> SubSequence {
168+
let quoteChar = UInt8(ascii: "\"")
169+
var trimmed = self
170+
if trimmed.popFirst() == quoteChar && trimmed.popLast() == quoteChar {
171+
return trimmed
172+
}
173+
return self
174+
}
175+
176+
/// Splits this collection in to a key and value at the first ASCII '=' character.
177+
/// Both the key and value are trimmed of ASCII spaces.
178+
fileprivate func parseKeyValuePair() -> (key: SubSequence, value: SubSequence)? {
179+
guard let keyValueSeparator = self.firstIndex(of: UInt8(ascii: "=")) else {
180+
return nil
161181
}
182+
let trimmedName = self[..<keyValueSeparator].trimmingASCIISpaces()
183+
let trimmedValue = self[self.index(after: keyValueSeparator)...].trimmingASCIISpaces()
184+
return (trimmedName, trimmedValue)
185+
}
162186

163-
let begin = index(after: startIndex)
164-
let end = index(before: endIndex)
165-
return String(self[begin..<end])
187+
/// Parses this collection as either a key-value pair, or a plain key.
188+
/// The returned key is trimmed of ASCII spaces and normalized to lowercase.
189+
/// The returned value is trimmed of ASCII spaces.
190+
fileprivate func parseCookieComponent() -> (key: String, value: SubSequence?)? {
191+
let (trimmedName, trimmedValue) = self.parseKeyValuePair() ?? (self.trimmingASCIISpaces(), nil)
192+
guard !trimmedName.isEmpty else {
193+
return nil
194+
}
195+
return (Substring(trimmedName).lowercased(), trimmedValue)
166196
}
167197
}
168198

169-
extension HTTPClient.Response {
170-
/// List of HTTP cookies returned by the server.
171-
public var cookies: [HTTPClient.Cookie] {
172-
return self.headers["set-cookie"].compactMap { HTTPClient.Cookie(header: $0, defaultDomain: self.host) }
199+
private let posixLocale: UnsafeMutableRawPointer = {
200+
// All POSIX systems must provide a "POSIX" locale, and its date/time formats are US English.
201+
// https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap07.html#tag_07_03_05
202+
let _posixLocale = newlocale(LC_TIME_MASK | LC_NUMERIC_MASK, "POSIX", nil)!
203+
return UnsafeMutableRawPointer(_posixLocale)
204+
}()
205+
206+
private func parseTimestamp(_ utf8: String.UTF8View.SubSequence, format: String) -> tm? {
207+
var timeComponents = tm()
208+
let success = Substring(utf8).withCString { cString in
209+
swiftahc_cshims_strptime_l(cString, format, &timeComponents, posixLocale)
210+
}
211+
return success ? timeComponents : nil
212+
}
213+
214+
private func parseCookieTime(_ timestampUTF8: String.UTF8View.SubSequence) -> Int64? {
215+
if timestampUTF8.contains(where: { $0 < 0x20 /* Control characters */ || $0 == 0x7F /* DEL */ }) {
216+
return nil
217+
}
218+
var timestampUTF8 = timestampUTF8
219+
if timestampUTF8.hasSuffix("GMT".utf8) {
220+
let timezoneStart = timestampUTF8.index(timestampUTF8.endIndex, offsetBy: -3)
221+
timestampUTF8 = timestampUTF8[..<timezoneStart].trimmingASCIISpaces()
222+
guard timestampUTF8.endIndex != timezoneStart else {
223+
return nil
224+
}
225+
}
226+
guard
227+
var timeComponents = parseTimestamp(timestampUTF8, format: "%a, %d %b %Y %H:%M:%S")
228+
?? parseTimestamp(timestampUTF8, format: "%a, %d-%b-%y %H:%M:%S")
229+
?? parseTimestamp(timestampUTF8, format: "%a %b %d %H:%M:%S %Y")
230+
else {
231+
return nil
173232
}
233+
let timestamp = Int64(timegm(&timeComponents))
234+
return timestamp == -1 && errno == EOVERFLOW ? nil : timestamp
174235
}

Diff for: Sources/AsyncHTTPClient/Utils.swift

+20
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,23 @@ public final class HTTPClientCopyingDelegate: HTTPClientResponseDelegate {
4141
internal func debugOnly(_ body: () -> Void) {
4242
assert({ body(); return true }())
4343
}
44+
45+
extension BidirectionalCollection where Element: Equatable {
46+
/// Returns a Boolean value indicating whether the collection ends with the specified suffix.
47+
///
48+
/// If `suffix` is empty, this function returns `true`.
49+
/// If all elements of the collections are equal, this function also returns `true`.
50+
func hasSuffix<Suffix>(_ suffix: Suffix) -> Bool where Suffix: BidirectionalCollection, Suffix.Element == Element {
51+
var ourIdx = self.endIndex
52+
var suffixIdx = suffix.endIndex
53+
while ourIdx > self.startIndex, suffixIdx > suffix.startIndex {
54+
self.formIndex(before: &ourIdx)
55+
suffix.formIndex(before: &suffixIdx)
56+
guard self[ourIdx] == suffix[suffixIdx] else { return false }
57+
}
58+
guard suffixIdx == suffix.startIndex else {
59+
return false // Exhausted self, but 'suffix' has elements remaining.
60+
}
61+
return true // Exhausted 'other' without finding a mismatch.
62+
}
63+
}

0 commit comments

Comments
 (0)