Skip to content

Commit e929bd1

Browse files
committed
Adjust cookie component parsing to better match RFC-6562
1 parent 3bebe58 commit e929bd1

File tree

4 files changed

+292
-18
lines changed

4 files changed

+292
-18
lines changed

Sources/AsyncHTTPClient/FoundationExtensions.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ extension HTTPClient.Cookie {
4040
/// - httpOnly: Whether this cookie should be used by HTTP servers only, defaults to false.
4141
/// - secure: Whether this cookie should only be sent using secure channels, defaults to false.
4242
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).
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).
4445
self.init(
4546
name: name,
4647
value: value,

Sources/AsyncHTTPClient/HTTPClient+HTTPCookie.swift

Lines changed: 39 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ extension HTTPClient {
4747
/// - defaultDomain: Default domain to use if cookie was sent without one.
4848
/// - returns: nil if the header is invalid.
4949
public init?(header: String, defaultDomain: String) {
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
5052
var components = header.utf8.split(separator: UInt8(ascii: ";"), omittingEmptySubsequences: false)[...]
5153
guard let keyValuePair = components.popFirst()?.trimmingASCIISpaces() else {
5254
return nil
@@ -58,35 +60,58 @@ extension HTTPClient {
5860
return nil
5961
}
6062

61-
// FIXME: The parsed values should be validated to ensure they only contain ASCII characters allowed by RFC-6265.
62-
// https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1
6363
self.name = String(aligningUTF8: trimmedName)
6464
self.value = String(aligningUTF8: trimmedValue.trimmingPairedASCIIQuote())
65-
self.path = "/"
66-
self.domain = defaultDomain
6765
self.expires_timestamp = nil
6866
self.maxAge = nil
6967
self.httpOnly = false
7068
self.secure = false
7169

70+
var parsedPath: String.UTF8View.SubSequence?
71+
var parsedDomain: String.UTF8View.SubSequence?
72+
7273
for component in components {
7374
switch component.parseCookieComponent() {
74-
case ("path", .some(let value))?:
75-
self.path = String(aligningUTF8: value)
76-
case ("domain", .some(let value))?:
77-
self.domain = String(aligningUTF8: value)
78-
case ("expires", .some(let value))?:
79-
self.expires_timestamp = parseCookieTime(value)
80-
case ("max-age", .some(let value))?:
81-
self.maxAge = Int(Substring(value))
82-
case ("secure", nil)?:
75+
case ("path", let value)?:
76+
// Unlike other values, unspecified and empty paths reset to the default path.
77+
guard let value = value, value.first == UInt8(ascii: "/") else {
78+
parsedPath = nil
79+
break
80+
}
81+
parsedPath = value
82+
case ("domain", let value)?:
83+
guard var value = value, !value.isEmpty else {
84+
break
85+
}
86+
if value.first == UInt8(ascii: ".") {
87+
value.removeFirst()
88+
}
89+
guard !value.isEmpty else {
90+
parsedDomain = nil
91+
break
92+
}
93+
parsedDomain = value
94+
case ("expires", let value)?:
95+
guard let value = value, let timestamp = parseCookieTime(value) else {
96+
break
97+
}
98+
self.expires_timestamp = timestamp
99+
case ("max-age", let value)?:
100+
guard let value = value, let age = Int(Substring(value)) else {
101+
break
102+
}
103+
self.maxAge = age
104+
case ("secure", _)?:
83105
self.secure = true
84-
case ("httponly", nil)?:
106+
case ("httponly", _)?:
85107
self.httpOnly = true
86108
default:
87109
continue
88110
}
89111
}
112+
113+
self.domain = parsedDomain.map { Substring($0).lowercased() } ?? defaultDomain.lowercased()
114+
self.path = parsedPath.map { String(aligningUTF8: $0) } ?? "/"
90115
}
91116

92117
/// Create HTTP cookie.

Tests/AsyncHTTPClientTests/HTTPClientCookieTests+XCTest.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ extension HTTPClientCookieTests {
3030
("testCookieDefaults", testCookieDefaults),
3131
("testCookieInit", testCookieInit),
3232
("testMalformedCookies", testMalformedCookies),
33+
("testExpires", testExpires),
34+
("testMaxAge", testMaxAge),
35+
("testDomain", testDomain),
36+
("testPath", testPath),
37+
("testSecure", testSecure),
38+
("testHttpOnly", testHttpOnly),
3339
("testCookieExpiresDateParsing", testCookieExpiresDateParsing),
3440
("testQuotedCookies", testQuotedCookies),
3541
("testCookieExpiresDateParsingWithNonEnglishLocale", testCookieExpiresDateParsingWithNonEnglishLocale),

Tests/AsyncHTTPClientTests/HTTPClientCookieTests.swift

Lines changed: 245 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ import XCTest
1919

2020
class HTTPClientCookieTests: XCTestCase {
2121
func testCookie() {
22-
let v = "key=value; PaTh=/path; DoMaIn=example.com; eXpIRes=Wed, 21 Oct 2015 07:28:00 GMT; max-AGE=42; seCURE; HTTPOnly"
23-
guard let c = HTTPClient.Cookie(header: v, defaultDomain: "example.com") else {
22+
let v = "key=value; PaTh=/path; DoMaIn=EXampLE.CoM; eXpIRes=Wed, 21 Oct 2015 07:28:00 GMT; max-AGE=42; seCURE; HTTPOnly"
23+
guard let c = HTTPClient.Cookie(header: v, defaultDomain: "exAMPle.cOm") else {
2424
XCTFail("Failed to parse cookie")
2525
return
2626
}
@@ -52,7 +52,7 @@ class HTTPClientCookieTests: XCTestCase {
5252

5353
func testCookieDefaults() {
5454
let v = "key=value"
55-
guard let c = HTTPClient.Cookie(header: v, defaultDomain: "example.com") else {
55+
guard let c = HTTPClient.Cookie(header: v, defaultDomain: "exAMPle.com") else {
5656
XCTFail("Failed to parse cookie")
5757
return
5858
}
@@ -94,6 +94,248 @@ class HTTPClientCookieTests: XCTestCase {
9494
XCTAssertNil(HTTPClient.Cookie(header: "=value;", defaultDomain: "exampe.org"))
9595
}
9696

97+
func testExpires() {
98+
// Empty values, and unrecognized timestamps, are ignored.
99+
// https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.1
100+
var c = HTTPClient.Cookie(header: "key=value; expires=", defaultDomain: "example.com")
101+
XCTAssertEqual("key", c?.name)
102+
XCTAssertEqual("value", c?.value)
103+
XCTAssertNil(c?.expires)
104+
105+
c = HTTPClient.Cookie(header: "key=value; expires", defaultDomain: "example.com")
106+
XCTAssertEqual("key", c?.name)
107+
XCTAssertEqual("value", c?.value)
108+
XCTAssertNil(c?.expires)
109+
110+
c = HTTPClient.Cookie(header: "key=value; expires=foo", defaultDomain: "example.com")
111+
XCTAssertEqual("key", c?.name)
112+
XCTAssertEqual("value", c?.value)
113+
XCTAssertNil(c?.expires)
114+
115+
c = HTTPClient.Cookie(header: "key=value; expires=04/01/2022", defaultDomain: "example.com")
116+
XCTAssertEqual("key", c?.name)
117+
XCTAssertEqual("value", c?.value)
118+
XCTAssertNil(c?.expires)
119+
120+
// Later values override earlier values, except if they are ignored.
121+
c = HTTPClient.Cookie(header: "key=value; expires=Sunday, 06-Nov-94 08:49:37 GMT; expires=04/01/2022", defaultDomain: "example.com")
122+
XCTAssertEqual("key", c?.name)
123+
XCTAssertEqual("value", c?.value)
124+
XCTAssertEqual(Date(timeIntervalSince1970: 784_111_777), c?.expires)
125+
126+
c = HTTPClient.Cookie(header: "key=value; expires=Sunday, 06-Nov-94 08:49:37 GMT; expires=", defaultDomain: "example.com")
127+
XCTAssertEqual("key", c?.name)
128+
XCTAssertEqual("value", c?.value)
129+
XCTAssertEqual(Date(timeIntervalSince1970: 784_111_777), c?.expires)
130+
131+
c = HTTPClient.Cookie(header: "key=value; expires=Sunday, 06-Nov-94 08:49:37 GMT; expires", defaultDomain: "example.com")
132+
XCTAssertEqual("key", c?.name)
133+
XCTAssertEqual("value", c?.value)
134+
XCTAssertEqual(Date(timeIntervalSince1970: 784_111_777), c?.expires)
135+
136+
// For more comprehensive tests of the various timestamp formats, see: `testCookieExpiresDateParsing`.
137+
}
138+
139+
func testMaxAge() {
140+
// Empty values, and values containing non-digits, are ignored.
141+
// https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.2
142+
var c = HTTPClient.Cookie(header: "key=value; max-age=", defaultDomain: "example.com")
143+
XCTAssertEqual("key", c?.name)
144+
XCTAssertEqual("value", c?.value)
145+
XCTAssertNil(c?.maxAge)
146+
147+
c = HTTPClient.Cookie(header: "key=value; max-age", defaultDomain: "example.com")
148+
XCTAssertEqual("key", c?.name)
149+
XCTAssertEqual("value", c?.value)
150+
XCTAssertNil(c?.maxAge)
151+
152+
c = HTTPClient.Cookie(header: "key=value; max-age=foo", defaultDomain: "example.com")
153+
XCTAssertEqual("key", c?.name)
154+
XCTAssertEqual("value", c?.value)
155+
XCTAssertNil(c?.maxAge)
156+
157+
c = HTTPClient.Cookie(header: "key=value; max-age=123foo", defaultDomain: "example.com")
158+
XCTAssertEqual("key", c?.name)
159+
XCTAssertEqual("value", c?.value)
160+
XCTAssertNil(c?.maxAge)
161+
162+
// Later values override earlier values, except if they are ignored.
163+
c = HTTPClient.Cookie(header: "key=value; max-age=123; max-age=456baz", defaultDomain: "example.com")
164+
XCTAssertEqual("key", c?.name)
165+
XCTAssertEqual("value", c?.value)
166+
XCTAssertEqual(123, c?.maxAge)
167+
168+
c = HTTPClient.Cookie(header: "key=value; max-age=-123; max-age=", defaultDomain: "example.com")
169+
XCTAssertEqual("key", c?.name)
170+
XCTAssertEqual("value", c?.value)
171+
XCTAssertEqual(-123, c?.maxAge)
172+
173+
c = HTTPClient.Cookie(header: "key=value; max-age=123; max-age", defaultDomain: "example.com")
174+
XCTAssertEqual("key", c?.name)
175+
XCTAssertEqual("value", c?.value)
176+
XCTAssertEqual(123, c?.maxAge)
177+
}
178+
179+
func testDomain() {
180+
// Empty domains should be ignored.
181+
// https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.3
182+
var c = HTTPClient.Cookie(header: "key=value; domain=", defaultDomain: "example.com")
183+
XCTAssertEqual("key", c?.name)
184+
XCTAssertEqual("value", c?.value)
185+
XCTAssertEqual("example.com", c?.domain)
186+
187+
c = HTTPClient.Cookie(header: "key=value; domain", defaultDomain: "example.com")
188+
XCTAssertEqual("key", c?.name)
189+
XCTAssertEqual("value", c?.value)
190+
XCTAssertEqual("example.com", c?.domain)
191+
192+
// A single leading dot is stripped.
193+
c = HTTPClient.Cookie(header: "key=value; domain=.foo", defaultDomain: "example.com")
194+
XCTAssertEqual("key", c?.name)
195+
XCTAssertEqual("value", c?.value)
196+
XCTAssertEqual("foo", c?.domain)
197+
198+
c = HTTPClient.Cookie(header: "key=value; domain=..foo", defaultDomain: "example.com")
199+
XCTAssertEqual("key", c?.name)
200+
XCTAssertEqual("value", c?.value)
201+
XCTAssertEqual(".foo", c?.domain)
202+
203+
// RFC-6562 checks for empty values before stipping the dot (resulting in an empty domain),
204+
// but later, empty domains are placed by the canonicalized request host.
205+
// We use the default domain as the request host.
206+
c = HTTPClient.Cookie(header: "key=value; domain=.", defaultDomain: "example.com")
207+
XCTAssertEqual("key", c?.name)
208+
XCTAssertEqual("value", c?.value)
209+
XCTAssertEqual("example.com", c?.domain)
210+
211+
// Later values override earlier values, except if they are ignored.
212+
c = HTTPClient.Cookie(header: "key=value; domain=foo; domain=bar", defaultDomain: "example.com")
213+
XCTAssertEqual("key", c?.name)
214+
XCTAssertEqual("value", c?.value)
215+
XCTAssertEqual("bar", c?.domain)
216+
217+
c = HTTPClient.Cookie(header: "key=value; domain=foo; domain=", defaultDomain: "example.com")
218+
XCTAssertEqual("key", c?.name)
219+
XCTAssertEqual("value", c?.value)
220+
XCTAssertEqual("foo", c?.domain)
221+
222+
c = HTTPClient.Cookie(header: "key=value; domain=foo; domain", defaultDomain: "example.com")
223+
XCTAssertEqual("key", c?.name)
224+
XCTAssertEqual("value", c?.value)
225+
XCTAssertEqual("foo", c?.domain)
226+
227+
c = HTTPClient.Cookie(header: "key=value; domain=foo; domain=.", defaultDomain: "example.com")
228+
XCTAssertEqual("key", c?.name)
229+
XCTAssertEqual("value", c?.value)
230+
XCTAssertEqual("example.com", c?.domain)
231+
232+
// The domain (including the defaultDomain parameter) should be normalized to lowercase.
233+
c = HTTPClient.Cookie(header: "key=value; domain=FOO; domain", defaultDomain: "example.com")
234+
XCTAssertEqual("key", c?.name)
235+
XCTAssertEqual("value", c?.value)
236+
XCTAssertEqual("foo", c?.domain)
237+
238+
c = HTTPClient.Cookie(header: "key=value; domain=; domain", defaultDomain: "EXAMPLE.com")
239+
XCTAssertEqual("key", c?.name)
240+
XCTAssertEqual("value", c?.value)
241+
XCTAssertEqual("example.com", c?.domain)
242+
}
243+
244+
func testPath() {
245+
// An empty path, or path which does not begin with a "/", is considered the default path.
246+
// https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.4
247+
var c = HTTPClient.Cookie(header: "key=value; path=", defaultDomain: "example.com")
248+
XCTAssertEqual("key", c?.name)
249+
XCTAssertEqual("value", c?.value)
250+
XCTAssertEqual("/", c?.path)
251+
252+
c = HTTPClient.Cookie(header: "key=value; path", defaultDomain: "example.com")
253+
XCTAssertEqual("key", c?.name)
254+
XCTAssertEqual("value", c?.value)
255+
XCTAssertEqual("/", c?.path)
256+
257+
c = HTTPClient.Cookie(header: "key=value; path=foo", defaultDomain: "example.com")
258+
XCTAssertEqual("key", c?.name)
259+
XCTAssertEqual("value", c?.value)
260+
XCTAssertEqual("/", c?.path)
261+
262+
// Later path values override earlier values, even if the later value is considered the default path.
263+
c = HTTPClient.Cookie(header: "key=value; path=/abc; path=/foo", defaultDomain: "example.com")
264+
XCTAssertEqual("key", c?.name)
265+
XCTAssertEqual("value", c?.value)
266+
XCTAssertEqual("/foo", c?.path)
267+
268+
c = HTTPClient.Cookie(header: "key=value; path=/abc; path=foo", defaultDomain: "example.com")
269+
XCTAssertEqual("key", c?.name)
270+
XCTAssertEqual("value", c?.value)
271+
XCTAssertEqual("/", c?.path)
272+
273+
c = HTTPClient.Cookie(header: "key=value; path=/abc; path", defaultDomain: "example.com")
274+
XCTAssertEqual("key", c?.name)
275+
XCTAssertEqual("value", c?.value)
276+
XCTAssertEqual("/", c?.path)
277+
}
278+
279+
func testSecure() {
280+
// If the cookie contains a key called "secure" (case-insensitive), the secure flag is set.
281+
// Regardless of its value.
282+
// https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.5
283+
var c = HTTPClient.Cookie(header: "key=value; secure=", defaultDomain: "example.com")
284+
XCTAssertEqual("key", c?.name)
285+
XCTAssertEqual("value", c?.value)
286+
XCTAssertEqual(true, c?.secure)
287+
288+
c = HTTPClient.Cookie(header: "key=value; secure", defaultDomain: "example.com")
289+
XCTAssertEqual("key", c?.name)
290+
XCTAssertEqual("value", c?.value)
291+
XCTAssertEqual(true, c?.secure)
292+
293+
c = HTTPClient.Cookie(header: "key=value; secure=0", defaultDomain: "example.com")
294+
XCTAssertEqual("key", c?.name)
295+
XCTAssertEqual("value", c?.value)
296+
XCTAssertEqual(true, c?.secure)
297+
298+
c = HTTPClient.Cookie(header: "key=value; secure=false", defaultDomain: "example.com")
299+
XCTAssertEqual("key", c?.name)
300+
XCTAssertEqual("value", c?.value)
301+
XCTAssertEqual(true, c?.secure)
302+
303+
c = HTTPClient.Cookie(header: "key=value; secure=no", defaultDomain: "example.com")
304+
XCTAssertEqual("key", c?.name)
305+
XCTAssertEqual("value", c?.value)
306+
XCTAssertEqual(true, c?.secure)
307+
}
308+
309+
func testHttpOnly() {
310+
// If the cookie contains a key called "httponly" (case-insensitive), the http-only flag is set.
311+
// Regardless of its value.
312+
// https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.6
313+
var c = HTTPClient.Cookie(header: "key=value; httponly=", defaultDomain: "example.com")
314+
XCTAssertEqual("key", c?.name)
315+
XCTAssertEqual("value", c?.value)
316+
XCTAssertEqual(true, c?.httpOnly)
317+
318+
c = HTTPClient.Cookie(header: "key=value; httponly", defaultDomain: "example.com")
319+
XCTAssertEqual("key", c?.name)
320+
XCTAssertEqual("value", c?.value)
321+
XCTAssertEqual(true, c?.httpOnly)
322+
323+
c = HTTPClient.Cookie(header: "key=value; httponly=0", defaultDomain: "example.com")
324+
XCTAssertEqual("key", c?.name)
325+
XCTAssertEqual("value", c?.value)
326+
XCTAssertEqual(true, c?.httpOnly)
327+
328+
c = HTTPClient.Cookie(header: "key=value; httponly=false", defaultDomain: "example.com")
329+
XCTAssertEqual("key", c?.name)
330+
XCTAssertEqual("value", c?.value)
331+
XCTAssertEqual(true, c?.httpOnly)
332+
333+
c = HTTPClient.Cookie(header: "key=value; httponly=no", defaultDomain: "example.com")
334+
XCTAssertEqual("key", c?.name)
335+
XCTAssertEqual("value", c?.value)
336+
XCTAssertEqual(true, c?.httpOnly)
337+
}
338+
97339
func testCookieExpiresDateParsing() {
98340
let domain = "example.org"
99341

0 commit comments

Comments
 (0)