Skip to content

Commit f6a9c4f

Browse files
committed
Improve cookie parsing algorithm.
While the previous parser dealt with commas, some cases were not correctly handled, like when a comma was in the last attribute before a second cookie. Also trim the names of the cookies to allow spaces before the equal.
1 parent 7b72fbd commit f6a9c4f

File tree

2 files changed

+203
-64
lines changed

2 files changed

+203
-64
lines changed

Foundation/HTTPCookie.swift

Lines changed: 172 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,26 @@ extension HTTPCookiePropertyKey {
6969
internal static let created = HTTPCookiePropertyKey(rawValue: "Created")
7070
}
7171

72+
internal extension HTTPCookiePropertyKey {
73+
static private let _setCookieAttributes: [String: HTTPCookiePropertyKey] = {
74+
// Only some attributes are valid in the Set-Cookie header.
75+
let validProperties: [HTTPCookiePropertyKey] = [
76+
.expires, .maximumAge, .domain, .path, .secure, .comment, .discard,
77+
.port, .version
78+
]
79+
let canonicalNames = validProperties.map { $0.rawValue.lowercased() }
80+
return Dictionary(uniqueKeysWithValues: zip(canonicalNames, validProperties))
81+
}()
82+
83+
init?(attributeName: String) {
84+
let canonical = attributeName.lowercased()
85+
switch HTTPCookiePropertyKey._setCookieAttributes[canonical] {
86+
case let property?: self = property
87+
case nil: return nil
88+
}
89+
}
90+
}
91+
7292
/// `HTTPCookie` represents an http cookie.
7393
///
7494
/// An `HTTPCookie` instance represents a single http cookie. It is
@@ -123,11 +143,6 @@ open class HTTPCookie : NSObject {
123143
static let _allFormatters: [DateFormatter]
124144
= [_formatter1, _formatter2, _formatter3]
125145

126-
static let _attributes: [HTTPCookiePropertyKey]
127-
= [.name, .value, .originURL, .version, .domain,
128-
.path, .secure, .expires, .comment, .commentURL,
129-
.discard, .maximumAge, .port]
130-
131146
/// Initialize a HTTPCookie object with a dictionary of parameters
132147
///
133148
/// - Parameter properties: The dictionary of properties to be used to
@@ -412,30 +427,74 @@ open class HTTPCookie : NSObject {
412427
/// - Returns: An array of HTTPCookie objects
413428
open class func cookies(withResponseHeaderFields headerFields: [String : String], for URL: URL) -> [HTTPCookie] {
414429

415-
//HTTP Cookie parsing based on RFC 6265: https://tools.ietf.org/html/rfc6265
416-
//Though RFC6265 suggests that multiple cookies cannot be folded into a single Set-Cookie field, this is
417-
//pretty common. It also suggests that commas and semicolons among other characters, cannot be a part of
430+
// HTTP Cookie parsing based on RFC 6265: https://tools.ietf.org/html/rfc6265
431+
// Though RFC6265 suggests that multiple cookies cannot be folded into a single Set-Cookie field, this is
432+
// pretty common. It also suggests that commas and semicolons among other characters, cannot be a part of
418433
// names and values. This implementation takes care of multiple cookies in the same field, however it doesn't
419-
//support commas and semicolons in names and values(except for dates)
434+
// support commas and semicolons in names and values(except for dates)
420435

421436
guard let cookies: String = headerFields["Set-Cookie"] else { return [] }
422437

423-
let nameValuePairs = cookies.components(separatedBy: ";") //split the name/value and attribute/value pairs
424-
.map({$0.trim()}) //trim whitespaces
425-
.map({removeCommaFromDate($0)}) //get rid of commas in dates
426-
.flatMap({$0.components(separatedBy: ",")}) //cookie boundaries are marked by commas
427-
.map({$0.trim()}) //trim again
428-
.filter({$0.caseInsensitiveCompare("HTTPOnly") != .orderedSame}) //we don't use HTTPOnly, do we?
429-
.flatMap({createNameValuePair(pair: $0)}) //create Name and Value properties
438+
var httpCookies: [HTTPCookie] = []
430439

431-
//mark cookie boundaries in the name-value array
432-
var cookieIndices = (0..<nameValuePairs.count).filter({nameValuePairs[$0].hasPrefix("Name")})
433-
cookieIndices.append(nameValuePairs.count)
440+
// Let's do old school parsing, which should allow us to handle the
441+
// embedded commas correctly.
442+
var idx: String.Index = cookies.startIndex
443+
let end: String.Index = cookies.endIndex
444+
while idx < end {
445+
// Skip leading spaces.
446+
while idx < end && cookies[idx].isSpace {
447+
idx = cookies.index(after: idx)
448+
}
449+
let cookieStartIdx: String.Index = idx
450+
var cookieEndIdx: String.Index = idx
451+
452+
while idx < end {
453+
// Scan to the next comma, but check that the comma is not a
454+
// legal comma in a value, by looking ahead for the token,
455+
// which indicates the comma was separating cookies.
456+
let cookiesRest = cookies[idx..<end]
457+
if let commaIdx = cookiesRest.firstIndex(of: ",") {
458+
// We are looking for WSP* TOKEN_CHAR+ WSP* '='
459+
var lookaheadIdx = cookies.index(after: commaIdx)
460+
// Skip whitespace
461+
while lookaheadIdx < end && cookies[lookaheadIdx].isSpace {
462+
lookaheadIdx = cookies.index(after: lookaheadIdx)
463+
}
464+
// Skip over the token characters
465+
var tokenLength = 0
466+
while lookaheadIdx < end && cookies[lookaheadIdx].isTokenCharacter {
467+
lookaheadIdx = cookies.index(after: lookaheadIdx)
468+
tokenLength += 1
469+
}
470+
// Skip whitespace
471+
while lookaheadIdx < end && cookies[lookaheadIdx].isSpace {
472+
lookaheadIdx = cookies.index(after: lookaheadIdx)
473+
}
474+
// Check there was a token, and there's an equals.
475+
if lookaheadIdx < end && cookies[lookaheadIdx] == "=" && tokenLength > 0 {
476+
// We found a token after the comma, this is a cookie
477+
// separator, and not an embedded comma.
478+
idx = cookies.index(after: commaIdx)
479+
cookieEndIdx = commaIdx
480+
break
481+
}
482+
// Otherwise, keep scanning from the comma.
483+
idx = cookies.index(after: commaIdx)
484+
cookieEndIdx = idx
485+
} else {
486+
// No more commas, skip to the end.
487+
idx = end
488+
cookieEndIdx = end
489+
break
490+
}
491+
}
434492

435-
//bake the cookies
436-
var httpCookies: [HTTPCookie] = []
437-
for i in 0..<cookieIndices.count-1 {
438-
if let aCookie = createHttpCookie(url: URL, pairs: nameValuePairs[cookieIndices[i]..<cookieIndices[i+1]]) {
493+
if cookieEndIdx <= cookieStartIdx {
494+
continue
495+
}
496+
497+
if let aCookie = createHttpCookie(url: URL, cookie: String(cookies[cookieStartIdx..<cookieEndIdx])) {
439498
httpCookies.append(aCookie)
440499
}
441500
}
@@ -444,62 +503,99 @@ open class HTTPCookie : NSObject {
444503
}
445504

446505
//Bake a cookie
447-
private class func createHttpCookie(url: URL, pairs: ArraySlice<String>) -> HTTPCookie? {
506+
private class func createHttpCookie(url: URL, cookie: String) -> HTTPCookie? {
448507
var properties: [HTTPCookiePropertyKey : Any] = [:]
449-
for pair in pairs {
450-
let name = pair.components(separatedBy: "=")[0]
451-
var value = pair.components(separatedBy: "\(name)=")[1] //a value can have an "="
452-
if canonicalize(name) == .expires {
453-
value = value.unmaskCommas() //re-insert the comma
508+
let scanner = Scanner(string: cookie)
509+
510+
guard let nameValuePair = scanner.scanUpToString(";") else {
511+
// if the scanner does not read anything, there's no cookie
512+
return nil
513+
}
514+
515+
guard case (let name?, let value?) = splitNameValue(nameValuePair) else {
516+
return nil
517+
}
518+
519+
properties[.name] = name
520+
properties[.value] = value
521+
properties[.originURL] = url
522+
523+
while scanner.scanString(";") != nil {
524+
if let attribute = scanner.scanUpToString(";") {
525+
switch splitNameValue(attribute) {
526+
case (nil, _):
527+
// ignore empty attribute names
528+
break
529+
case (let name?, nil):
530+
switch HTTPCookiePropertyKey(attributeName: name) {
531+
case .secure?:
532+
properties[.secure] = "TRUE"
533+
case .discard?:
534+
properties[.discard] = "TRUE"
535+
default:
536+
// ignore unknown attributes like HttpOnly
537+
break
538+
}
539+
case (let name?, let value?):
540+
switch HTTPCookiePropertyKey(attributeName: name) {
541+
case .comment?:
542+
properties[.comment] = value
543+
case .commentURL?:
544+
properties[.commentURL] = value
545+
case .domain?:
546+
properties[.domain] = value
547+
case .maximumAge?:
548+
properties[.maximumAge] = value
549+
case .path?:
550+
properties[.path] = value
551+
case .port?:
552+
properties[.port] = value
553+
case .version?:
554+
properties[.version] = value
555+
case .expires?:
556+
properties[.expires] = value
557+
default:
558+
// ignore unknown attributes
559+
break
560+
}
561+
}
454562
}
455-
properties[canonicalize(name)] = value
456563
}
457564

458565
// If domain wasn't provided, extract it from the URL
459566
if properties[.domain] == nil {
460567
properties[.domain] = url.host
461568
}
462569

463-
//the default Path is "/"
464-
if properties[.path] == nil {
570+
// the default Path is "/"
571+
if let path = properties[.path] as? String, path.first == "/" {
572+
// do nothing
573+
} else {
465574
properties[.path] = "/"
466575
}
467576

468577
return HTTPCookie(properties: properties)
469578
}
470579

471-
//we pass this to a map()
472-
private class func removeCommaFromDate(_ value: String) -> String {
473-
if value.hasPrefix("Expires") || value.hasPrefix("expires") {
474-
return value.maskCommas()
580+
private class func splitNameValue(_ pair: String) -> (name: String?, value: String?) {
581+
let scanner = Scanner(string: pair)
582+
583+
guard let name = scanner.scanUpToString("=")?.trim(),
584+
!name.isEmpty else {
585+
// if the scanner does not read anything, or the trimmed name is
586+
// empty, there's no name=value
587+
return (nil, nil)
475588
}
476-
return value
477-
}
478589

479-
//These cookie attributes are defined in RFC 6265 and 2965(which is obsolete)
480-
//HTTPCookie supports these
481-
private class func isCookieAttribute(_ string: String) -> Bool {
482-
return _attributes.first(where: {$0.rawValue.caseInsensitiveCompare(string) == .orderedSame}) != nil
483-
}
590+
guard scanner.scanString("=") != nil else {
591+
// if the scanner does not find =, there's no value
592+
return (name, nil)
593+
}
484594

485-
//Cookie attribute names are case-insensitive as per RFC6265: https://tools.ietf.org/html/rfc6265
486-
//but HTTPCookie needs only the first letter of each attribute in uppercase
487-
private class func canonicalize(_ name: String) -> HTTPCookiePropertyKey {
488-
let idx = _attributes.firstIndex(where: {$0.rawValue.caseInsensitiveCompare(name) == .orderedSame})!
489-
return _attributes[idx]
490-
}
595+
let location = scanner.scanLocation
596+
let value = String(pair[pair.index(pair.startIndex, offsetBy: location)..<pair.endIndex]).trim()
491597

492-
//A name=value pair should be translated to two properties, Name=name and Value=value
493-
private class func createNameValuePair(pair: String) -> [String] {
494-
if pair.caseInsensitiveCompare(HTTPCookiePropertyKey.secure.rawValue) == .orderedSame {
495-
return ["Secure=TRUE"]
496-
}
497-
let name = pair.components(separatedBy: "=")[0]
498-
let value = pair.components(separatedBy: "\(name)=")[1]
499-
if !isCookieAttribute(name) {
500-
return ["Name=\(name)", "Value=\(value)"]
501-
}
502-
return [pair]
598+
return (name, value)
503599
}
504600

505601
/// Returns a dictionary representation of the receiver.
@@ -654,12 +750,24 @@ fileprivate extension String {
654750
func trim() -> String {
655751
return self.trimmingCharacters(in: .whitespacesAndNewlines)
656752
}
753+
}
657754

658-
func maskCommas() -> String {
659-
return self.replacingOccurrences(of: ",", with: "&comma")
755+
fileprivate extension Character {
756+
var isSpace: Bool {
757+
return self == " " || self == "\t" || self == "\n" || self == "\r"
660758
}
661759

662-
func unmaskCommas() -> String {
663-
return self.replacingOccurrences(of: "&comma", with: ",")
760+
var isTokenCharacter: Bool {
761+
guard let asciiValue = self.asciiValue else {
762+
return false
763+
}
764+
765+
// CTL, 0-31 and DEL (127)
766+
if asciiValue <= 31 || asciiValue >= 127 {
767+
return false
768+
}
769+
770+
let nonTokenCharacters = "()<>@,;:\\\"/[]?={} \t"
771+
return !nonTokenCharacters.contains(self)
664772
}
665773
}

TestFoundation/TestHTTPCookie.swift

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ class TestHTTPCookie: XCTestCase {
1919
("test_cookiesWithResponseHeaderNoDomain", test_cookiesWithResponseHeaderNoDomain),
2020
("test_cookiesWithResponseHeaderNoPathNoDomain", test_cookiesWithResponseHeaderNoPathNoDomain),
2121
("test_cookieExpiresDateFormats", test_cookieExpiresDateFormats),
22+
("test_cookiesWithExpiresAsLastAttribute", test_cookiesWithExpiresAsLastAttribute),
23+
("test_cookiesWithResponseHeaderTrimsNames", test_cookiesWithResponseHeaderTrimsNames),
2224
("test_httpCookieWithSubstring", test_httpCookieWithSubstring),
2325
]
2426
}
@@ -171,6 +173,35 @@ class TestHTTPCookie: XCTestCase {
171173
XCTAssertEqual(cookies[0].path, "/")
172174
}
173175

176+
func test_cookiesWithExpiresAsLastAttribute() throws {
177+
let cookies = HTTPCookie.cookies(withResponseHeaderFields: [
178+
"Set-Cookie": "AAA=111; path=/; domain=.example.com; expires=Sun, 16-Aug-2025 22:39:54 GMT, BBB=222; path=/; domain=.example.com; HttpOnly; expires=Sat, 15-Feb-2014 22:39:54 GMT"
179+
], for: try URL(string: "http://www.example.com/").unwrapped())
180+
XCTAssertEqual(cookies.count, 2)
181+
XCTAssertEqual(cookies[0].name, "AAA")
182+
XCTAssertEqual(cookies[1].name, "BBB")
183+
}
184+
185+
func test_cookiesWithResponseHeaderTrimsNames() throws {
186+
do {
187+
let cookies = HTTPCookie.cookies(withResponseHeaderFields: [
188+
"Set-Cookie": "AAA =1; path=/; domain=.example.com; expires=Sun, 16-Aug-2025 22:39:54 GMT, BBB=2; path=/; domain=.example.com; HttpOnly; expires=Sat, 15-Feb-2014 22:39:54 GMT"
189+
], for: try URL(string: "http://www.example.com/").unwrapped())
190+
XCTAssertEqual(cookies.count, 2)
191+
XCTAssertEqual(cookies[0].name, "AAA")
192+
XCTAssertEqual(cookies[1].name, "BBB")
193+
}
194+
195+
do {
196+
let cookies = HTTPCookie.cookies(withResponseHeaderFields: [
197+
"Set-Cookie": " AAA=1; path=/; domain=.example.com; expires=Sun, 16-Aug-2025 22:39:54 GMT, BBB=2; path=/; domain=.example.com; HttpOnly; expires=Sat, 15-Feb-2014 22:39:54 GMT"
198+
], for: try URL(string: "http://www.example.com/").unwrapped())
199+
XCTAssertEqual(cookies.count, 2)
200+
XCTAssertEqual(cookies[0].name, "AAA")
201+
XCTAssertEqual(cookies[1].name, "BBB")
202+
}
203+
}
204+
174205
func test_cookieExpiresDateFormats() {
175206
let testDate = Date(timeIntervalSince1970: 1577881800)
176207
let cookieString =

0 commit comments

Comments
 (0)