Skip to content

Commit 273ebad

Browse files
committed
Redo HTTP cookie parsing using strptime
1 parent 19e83a3 commit 273ebad

11 files changed

+475
-101
lines changed

Package.swift

Lines changed: 2 additions & 0 deletions
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"),
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
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 (for example, if the strings contain non-ASCII characters).
44+
self.init(
45+
name: name,
46+
value: value,
47+
path: path,
48+
domain: domain,
49+
expires_timestamp: expires.map { Int64($0.timeIntervalSince1970) },
50+
maxAge: maxAge,
51+
httpOnly: httpOnly,
52+
secure: secure
53+
)
54+
}
55+
}

Sources/AsyncHTTPClient/HTTPClient+HTTPCookie.swift

Lines changed: 120 additions & 83 deletions
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,74 +47,40 @@ 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+
var components = header.utf8.split(separator: UInt8(ascii: ";"), omittingEmptySubsequences: false)[...]
51+
guard let keyValuePair = components.popFirst()?.trimmingASCIISpaces() else {
5052
return nil
5153
}
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 {
54+
guard let (trimmedName, trimmedValue) = keyValuePair.parseKeyValuePair() else {
5855
return nil
5956
}
60-
61-
self.name = nameAndValue[0]
62-
self.value = nameAndValue[1].omittingQuotes()
63-
64-
guard !self.name.isEmpty else {
57+
guard !trimmedName.isEmpty else {
6558
return nil
6659
}
67-
60+
// FIXME: The parsed values should be validated to ensure they only contain ASCII characters allowed by RFC-6265.
61+
// https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1
62+
self.name = String(utf8Slice: trimmedName, in: header)
63+
self.value = String(utf8Slice: trimmedValue.trimmingPairedASCIIQuote(), in: header)
6864
self.path = "/"
6965
self.domain = defaultDomain
70-
self.expires = nil
66+
self.expires_timestamp = nil
7167
self.maxAge = nil
7268
self.httpOnly = false
7369
self.secure = false
7470

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 {
85-
continue
86-
}
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
95-
continue
96-
}
97-
98-
formatter.dateFormat = "EEE, dd-MMM-yy HH:mm:ss z"
99-
if let date = formatter.date(from: value) {
100-
self.expires = date
101-
continue
102-
}
103-
104-
formatter.dateFormat = "EEE MMM d hh:mm:s yyyy"
105-
if let date = formatter.date(from: value) {
106-
self.expires = date
107-
}
108-
case ("max-age", let value):
109-
self.maxAge = value.flatMap(Int.init)
110-
case ("secure", nil):
71+
for component in components {
72+
switch component.parseCookieComponent() {
73+
case ("path", .some(let value))?:
74+
self.path = String(utf8Slice: value, in: header)
75+
case ("domain", .some(let value))?:
76+
self.domain = String(utf8Slice: value, in: header)
77+
case ("expires", .some(let value))?:
78+
self.expires_timestamp = parseCookieTime(value, in: header)
79+
case ("max-age", .some(let value))?:
80+
self.maxAge = Int(Substring(value))
81+
case ("secure", nil)?:
11182
self.secure = true
112-
case ("httponly", nil):
83+
case ("httponly", nil)?:
11384
self.httpOnly = true
11485
default:
11586
continue
@@ -124,51 +95,117 @@ extension HTTPClient {
12495
/// - value: The cookie's string value.
12596
/// - path: The cookie's path.
12697
/// - domain: The domain of the cookie, defaults to nil.
127-
/// - expires: The cookie's expiration date, defaults to nil.
98+
/// - expires_timestamp: The cookie's expiration date, as a number of seconds since the Unix epoch. defaults to nil.
12899
/// - maxAge: The cookie's age in seconds, defaults to nil.
129100
/// - httpOnly: Whether this cookie should be used by HTTP servers only, defaults to false.
130101
/// - 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) {
102+
internal init(name: String, value: String, path: String = "/", domain: String? = nil, expires_timestamp: Int64? = nil, maxAge: Int? = nil, httpOnly: Bool = false, secure: Bool = false) {
132103
self.name = name
133104
self.value = value
134105
self.path = path
135106
self.domain = domain
136-
self.expires = expires
107+
self.expires_timestamp = expires_timestamp
137108
self.maxAge = maxAge
138109
self.httpOnly = httpOnly
139110
self.secure = secure
140111
}
112+
}
113+
}
141114

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-
}
115+
extension HTTPClient.Response {
116+
/// List of HTTP cookies returned by the server.
117+
public var cookies: [HTTPClient.Cookie] {
118+
return self.headers["set-cookie"].compactMap { HTTPClient.Cookie(header: $0, defaultDomain: self.host) }
153119
}
154120
}
155121

156122
extension String {
157-
fileprivate func omittingQuotes() -> String {
158-
let dquote = "\""
159-
if !hasPrefix(dquote) || !hasSuffix(dquote) {
160-
return self
123+
/// Creates a String from a slice of UTF8 code-units, aligning the bounds to unicode scalar boundaries if needed.
124+
fileprivate init(utf8Slice: String.UTF8View.SubSequence, in base: String) {
125+
self.init(base[utf8Slice.startIndex..<utf8Slice.endIndex])
126+
}
127+
}
128+
129+
extension String.UTF8View.SubSequence {
130+
fileprivate func trimmingASCIISpaces() -> SubSequence {
131+
guard let start = self.firstIndex(where: { $0 != UInt8(ascii: " ") }) else {
132+
return self[self.endIndex..<self.endIndex]
161133
}
134+
let end = self.lastIndex(where: { $0 != UInt8(ascii: " ") })!
135+
return self[start...end]
136+
}
162137

163-
let begin = index(after: startIndex)
164-
let end = index(before: endIndex)
165-
return String(self[begin..<end])
138+
/// If this collection begins and ends with an ASCII double-quote ("),
139+
/// returns a version of self trimmed of those quotes. Otherwise, returns self.
140+
fileprivate func trimmingPairedASCIIQuote() -> SubSequence {
141+
let quoteChar = UInt8(ascii: "\"")
142+
var trimmed = self
143+
if trimmed.popFirst() == quoteChar && trimmed.popLast() == quoteChar {
144+
return trimmed
145+
}
146+
return self
147+
}
148+
149+
/// Splits this collection in to a key and value at the first ASCII '=' character.
150+
/// Both the key and value are trimmed of ASCII spaces.
151+
fileprivate func parseKeyValuePair() -> (key: SubSequence, value: SubSequence)? {
152+
guard let keyValueSeparator = self.firstIndex(of: UInt8(ascii: "=")) else {
153+
return nil
154+
}
155+
let trimmedName = self[..<keyValueSeparator].trimmingASCIISpaces()
156+
let trimmedValue = self[self.index(after: keyValueSeparator)...].trimmingASCIISpaces()
157+
return (trimmedName, trimmedValue)
158+
}
159+
160+
/// Parses this collection as either a key-value pair, or a plain key.
161+
/// The returned key is trimmed of ASCII spaces and normalized to lowercase.
162+
/// The returned value is trimmed of ASCII spaces.
163+
fileprivate func parseCookieComponent() -> (key: String, value: SubSequence?)? {
164+
let (trimmedName, trimmedValue) = self.parseKeyValuePair() ?? (self.trimmingASCIISpaces(), nil)
165+
guard !trimmedName.isEmpty else {
166+
return nil
167+
}
168+
return (Substring(trimmedName).lowercased(), trimmedValue)
166169
}
167170
}
168171

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) }
172+
private let posixLocale: UnsafeMutableRawPointer = {
173+
// All POSIX systems must provide a "POSIX" locale, and its date/time formats are US English.
174+
// https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap07.html#tag_07_03_05
175+
let _posixLocale = newlocale(LC_TIME_MASK | LC_NUMERIC_MASK, "POSIX", nil)!
176+
return UnsafeMutableRawPointer(_posixLocale)
177+
}()
178+
179+
private func parseTimestamp(_ string: String, format: String) -> tm? {
180+
var timeComponents = tm()
181+
guard swiftahc_cshims_strptime_l(string, format, &timeComponents, posixLocale) else {
182+
return nil
183+
}
184+
return timeComponents
185+
}
186+
187+
private func parseCookieTime(_ timestampUTF8: String.UTF8View.SubSequence, in header: String) -> Int64? {
188+
if timestampUTF8.contains(where: { $0 < 0x20 /* Control characters */ || $0 == 0x7F /* DEL */ }) {
189+
return nil
190+
}
191+
let timestampString: String
192+
if timestampUTF8.hasSuffix("GMT".utf8) {
193+
let timezoneStart = timestampUTF8.index(timestampUTF8.endIndex, offsetBy: -3)
194+
let trimmedTimestampUTF8 = timestampUTF8[..<timezoneStart].trimmingASCIISpaces()
195+
guard trimmedTimestampUTF8.endIndex != timezoneStart else {
196+
return nil
197+
}
198+
timestampString = String(utf8Slice: trimmedTimestampUTF8, in: header)
199+
} else {
200+
timestampString = String(utf8Slice: timestampUTF8, in: header)
201+
}
202+
guard
203+
var timeComponents = parseTimestamp(timestampString, format: "%a, %d %b %Y %H:%M:%S")
204+
?? parseTimestamp(timestampString, format: "%a, %d-%b-%y %H:%M:%S")
205+
?? parseTimestamp(timestampString, format: "%a %b %d %H:%M:%S %Y")
206+
else {
207+
return nil
173208
}
209+
let timestamp = Int64(timegm(&timeComponents))
210+
return timestamp == -1 && errno == EOVERFLOW ? nil : timestamp
174211
}

Sources/AsyncHTTPClient/Utils.swift

Lines changed: 20 additions & 0 deletions
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+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
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+
#if __APPLE__
16+
#include <xlocale.h>
17+
#elif __linux__
18+
#define _GNU_SOURCE
19+
#include <locale.h>
20+
#endif
21+
22+
#include <stdbool.h>
23+
#include <time.h>
24+
25+
bool swiftahc_cshims_strptime(const char * string, const char * format, struct tm * result) {
26+
const char * firstNonProcessed = strptime(string, format, result);
27+
if (firstNonProcessed) {
28+
return *firstNonProcessed == 0;
29+
}
30+
return false;
31+
}
32+
33+
bool swiftahc_cshims_strptime_l(const char * string, const char * format, struct tm * result, void * locale) {
34+
// The pointer cast is fine as long we make sure it really points to a locale_t.
35+
const char * firstNonProcessed = strptime_l(string, format, result, (locale_t)locale);
36+
if (firstNonProcessed) {
37+
return *firstNonProcessed == 0;
38+
}
39+
return false;
40+
}

0 commit comments

Comments
 (0)