Skip to content

Commit 7f0c573

Browse files
committed
[5.0.3] Backport 5.1 changes to HTTPCookie parsing.
Fixes https://bugs.swift.org/browse/SR-11294
1 parent 0f28ef1 commit 7f0c573

File tree

3 files changed

+223
-83
lines changed

3 files changed

+223
-83
lines changed

Foundation/HTTPCookie.swift

Lines changed: 211 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,6 @@ public struct HTTPCookiePropertyKey : RawRepresentable, Equatable, Hashable {
1717
public init(rawValue: String) {
1818
self.rawValue = rawValue
1919
}
20-
21-
public var hashValue: Int {
22-
return self.rawValue.hashValue
23-
}
24-
25-
public static func ==(_ lhs: HTTPCookiePropertyKey, _ rhs: HTTPCookiePropertyKey) -> Bool {
26-
return lhs.rawValue == rhs.rawValue
27-
}
2820
}
2921

3022
extension HTTPCookiePropertyKey {
@@ -71,6 +63,28 @@ extension HTTPCookiePropertyKey {
7163
internal static let created = HTTPCookiePropertyKey(rawValue: "Created")
7264
}
7365

66+
internal extension HTTPCookiePropertyKey {
67+
static let httpOnly = HTTPCookiePropertyKey(rawValue: "HttpOnly")
68+
69+
static private let _setCookieAttributes: [String: HTTPCookiePropertyKey] = {
70+
// Only some attributes are valid in the Set-Cookie header.
71+
let validProperties: [HTTPCookiePropertyKey] = [
72+
.expires, .maximumAge, .domain, .path, .secure, .comment,
73+
.commentURL, .discard, .port, .version, .httpOnly
74+
]
75+
let canonicalNames = validProperties.map { $0.rawValue.lowercased() }
76+
return Dictionary(uniqueKeysWithValues: zip(canonicalNames, validProperties))
77+
}()
78+
79+
init?(attributeName: String) {
80+
let canonical = attributeName.lowercased()
81+
switch HTTPCookiePropertyKey._setCookieAttributes[canonical] {
82+
case let property?: self = property
83+
case nil: return nil
84+
}
85+
}
86+
}
87+
7488
/// `HTTPCookie` represents an http cookie.
7589
///
7690
/// An `HTTPCookie` instance represents a single http cookie. It is
@@ -125,11 +139,6 @@ open class HTTPCookie : NSObject {
125139
static let _allFormatters: [DateFormatter]
126140
= [_formatter1, _formatter2, _formatter3]
127141

128-
static let _attributes: [HTTPCookiePropertyKey]
129-
= [.name, .value, .originURL, .version, .domain,
130-
.path, .secure, .expires, .comment, .commentURL,
131-
.discard, .maximumAge, .port]
132-
133142
/// Initialize a HTTPCookie object with a dictionary of parameters
134143
///
135144
/// - Parameter properties: The dictionary of properties to be used to
@@ -255,10 +264,16 @@ open class HTTPCookie : NSObject {
255264
/// - Experiment: This is a draft API currently under consideration for official import into Foundation as a suitable alternative
256265
/// - Note: Since this API is under consideration it may be either removed or revised in the near future
257266
public init?(properties: [HTTPCookiePropertyKey : Any]) {
267+
func stringValue(_ strVal: Any?) -> String? {
268+
if let subStr = strVal as? Substring {
269+
return String(subStr)
270+
}
271+
return strVal as? String
272+
}
258273
guard
259-
let path = properties[.path] as? String,
260-
let name = properties[.name] as? String,
261-
let value = properties[.value] as? String
274+
let path = stringValue(properties[.path]),
275+
let name = stringValue(properties[.name]),
276+
let value = stringValue(properties[.value])
262277
else {
263278
return nil
264279
}
@@ -313,7 +328,7 @@ open class HTTPCookie : NSObject {
313328
}
314329

315330
var expDate: Date? = nil
316-
// Maximum-Age is prefered over expires-Date but only version 1 cookies use Maximum-Age
331+
// Maximum-Age is preferred over expires-Date but only version 1 cookies use Maximum-Age
317332
if let maximumAge = properties[.maximumAge] as? String,
318333
let secondsFromNow = Int(maximumAge) {
319334
if version == 1 {
@@ -344,8 +359,12 @@ open class HTTPCookie : NSObject {
344359
} else {
345360
_commentURL = nil
346361
}
347-
_HTTPOnly = false
348362

363+
if let httpOnlyString = properties[.httpOnly] as? String {
364+
_HTTPOnly = httpOnlyString == "TRUE"
365+
} else {
366+
_HTTPOnly = false
367+
}
349368

350369
_properties = [
351370
.created : Date().timeIntervalSinceReferenceDate, // Cocoa Compatibility
@@ -404,34 +423,78 @@ open class HTTPCookie : NSObject {
404423
/// This method will ignore irrelevant header fields so
405424
/// you can pass a dictionary containing data other than cookie data.
406425
/// - Parameter headerFields: The response header fields to check for cookies.
407-
/// - Parameter URL: The URL that the cookies came from - relevant to how the cookies are interpeted.
426+
/// - Parameter URL: The URL that the cookies came from - relevant to how the cookies are interpreted.
408427
/// - Returns: An array of HTTPCookie objects
409428
open class func cookies(withResponseHeaderFields headerFields: [String : String], for URL: URL) -> [HTTPCookie] {
410429

411-
//HTTP Cookie parsing based on RFC 6265: https://tools.ietf.org/html/rfc6265
412-
//Though RFC6265 suggests that multiple cookies cannot be folded into a single Set-Cookie field, this is
413-
//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
414433
// names and values. This implementation takes care of multiple cookies in the same field, however it doesn't
415-
//support commas and semicolons in names and values(except for dates)
434+
// support commas and semicolons in names and values(except for dates)
416435

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

419-
let nameValuePairs = cookies.components(separatedBy: ";") //split the name/value and attribute/value pairs
420-
.map({$0.trim()}) //trim whitespaces
421-
.map({removeCommaFromDate($0)}) //get rid of commas in dates
422-
.flatMap({$0.components(separatedBy: ",")}) //cookie boundaries are marked by commas
423-
.map({$0.trim()}) //trim again
424-
.filter({$0.caseInsensitiveCompare("HTTPOnly") != .orderedSame}) //we don't use HTTPOnly, do we?
425-
.flatMap({createNameValuePair(pair: $0)}) //create Name and Value properties
438+
var httpCookies: [HTTPCookie] = []
439+
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+
}
426492

427-
//mark cookie boundaries in the name-value array
428-
var cookieIndices = (0..<nameValuePairs.count).filter({nameValuePairs[$0].hasPrefix("Name")})
429-
cookieIndices.append(nameValuePairs.count)
493+
if cookieEndIdx <= cookieStartIdx {
494+
continue
495+
}
430496

431-
//bake the cookies
432-
var httpCookies: [HTTPCookie] = []
433-
for i in 0..<cookieIndices.count-1 {
434-
if let aCookie = createHttpCookie(url: URL, pairs: nameValuePairs[cookieIndices[i]..<cookieIndices[i+1]]) {
497+
if let aCookie = createHttpCookie(url: URL, cookie: String(cookies[cookieStartIdx..<cookieEndIdx])) {
435498
httpCookies.append(aCookie)
436499
}
437500
}
@@ -440,62 +503,118 @@ open class HTTPCookie : NSObject {
440503
}
441504

442505
//Bake a cookie
443-
private class func createHttpCookie(url: URL, pairs: ArraySlice<String>) -> HTTPCookie? {
506+
private class func createHttpCookie(url: URL, cookie: String) -> HTTPCookie? {
444507
var properties: [HTTPCookiePropertyKey : Any] = [:]
445-
for pair in pairs {
446-
let name = pair.components(separatedBy: "=")[0]
447-
var value = pair.components(separatedBy: "\(name)=")[1] //a value can have an "="
448-
if canonicalize(name) == .expires {
449-
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+
case .httpOnly?:
536+
properties[.httpOnly] = "TRUE"
537+
default:
538+
// ignore unknown attributes
539+
break
540+
}
541+
case (let name?, let value?):
542+
switch HTTPCookiePropertyKey(attributeName: name) {
543+
case .comment?:
544+
properties[.comment] = value
545+
case .commentURL?:
546+
properties[.commentURL] = value
547+
case .domain?:
548+
properties[.domain] = value
549+
case .maximumAge?:
550+
properties[.maximumAge] = value
551+
case .path?:
552+
properties[.path] = value
553+
case .port?:
554+
properties[.port] = value
555+
case .version?:
556+
properties[.version] = value
557+
case .expires?:
558+
properties[.expires] = value
559+
default:
560+
// ignore unknown attributes
561+
break
562+
}
563+
}
450564
}
451-
properties[canonicalize(name)] = value
452565
}
453566

454-
// If domain wasn't provided, extract it from the URL
455-
if properties[.domain] == nil {
567+
if let domain = properties[.domain] as? String {
568+
// The provided domain string has to be prepended with a dot,
569+
// because the domain field indicates that it can be sent
570+
// subdomains of the domain (but only if it is not an IP address).
571+
if (!domain.hasPrefix(".") && !isIPv4Address(domain)) {
572+
properties[.domain] = ".\(domain)"
573+
}
574+
} else {
575+
// If domain wasn't provided, extract it from the URL. No dots in
576+
// this case, only exact matching.
456577
properties[.domain] = url.host
457578
}
579+
// Always lowercase the domain.
580+
if let domain = properties[.domain] as? String {
581+
properties[.domain] = domain.lowercased()
582+
}
458583

459-
//the default Path is "/"
460-
if properties[.path] == nil {
584+
// the default Path is "/"
585+
if let path = properties[.path] as? String, path.first == "/" {
586+
// do nothing
587+
} else {
461588
properties[.path] = "/"
462589
}
463590

464591
return HTTPCookie(properties: properties)
465592
}
466593

467-
//we pass this to a map()
468-
private class func removeCommaFromDate(_ value: String) -> String {
469-
if value.hasPrefix("Expires") || value.hasPrefix("expires") {
470-
return value.maskCommas()
594+
private class func splitNameValue(_ pair: String) -> (name: String?, value: String?) {
595+
let scanner = Scanner(string: pair)
596+
597+
guard let name = scanner.scanUpToString("=")?.trim(),
598+
!name.isEmpty else {
599+
// if the scanner does not read anything, or the trimmed name is
600+
// empty, there's no name=value
601+
return (nil, nil)
471602
}
472-
return value
473-
}
474603

475-
//These cookie attributes are defined in RFC 6265 and 2965(which is obsolete)
476-
//HTTPCookie supports these
477-
private class func isCookieAttribute(_ string: String) -> Bool {
478-
return _attributes.first(where: {$0.rawValue.caseInsensitiveCompare(string) == .orderedSame}) != nil
479-
}
604+
guard scanner.scanString("=") != nil else {
605+
// if the scanner does not find =, there's no value
606+
return (name, nil)
607+
}
480608

481-
//Cookie attribute names are case-insensitive as per RFC6265: https://tools.ietf.org/html/rfc6265
482-
//but HTTPCookie needs only the first letter of each attribute in uppercase
483-
private class func canonicalize(_ name: String) -> HTTPCookiePropertyKey {
484-
let idx = _attributes.index(where: {$0.rawValue.caseInsensitiveCompare(name) == .orderedSame})!
485-
return _attributes[idx]
609+
let location = scanner.scanLocation
610+
let value = String(pair[pair.index(pair.startIndex, offsetBy: location)..<pair.endIndex]).trim()
611+
612+
return (name, value)
486613
}
487614

488-
//A name=value pair should be translated to two properties, Name=name and Value=value
489-
private class func createNameValuePair(pair: String) -> [String] {
490-
if pair.caseInsensitiveCompare(HTTPCookiePropertyKey.secure.rawValue) == .orderedSame {
491-
return ["Secure=TRUE"]
492-
}
493-
let name = pair.components(separatedBy: "=")[0]
494-
let value = pair.components(separatedBy: "\(name)=")[1]
495-
if !isCookieAttribute(name) {
496-
return ["Name=\(name)", "Value=\(value)"]
497-
}
498-
return [pair]
615+
private class func isIPv4Address(_ string: String) -> Bool {
616+
var x = in_addr()
617+
return inet_pton(AF_INET, string, &x) == 1
499618
}
500619

501620
/// Returns a dictionary representation of the receiver.
@@ -573,7 +692,7 @@ open class HTTPCookie : NSObject {
573692
///
574693
/// Cookies may be marked secure by a server (or by a javascript).
575694
/// Cookies marked as such must only be sent via an encrypted connection to
576-
/// trusted servers (i.e. via SSL or TLS), and should not be delievered to any
695+
/// trusted servers (i.e. via SSL or TLS), and should not be delivered to any
577696
/// javascript applications to prevent cross-site scripting vulnerabilities.
578697
open var isSecure: Bool {
579698
return _secure
@@ -650,13 +769,24 @@ fileprivate extension String {
650769
func trim() -> String {
651770
return self.trimmingCharacters(in: .whitespacesAndNewlines)
652771
}
772+
}
653773

654-
func maskCommas() -> String {
655-
return self.replacingOccurrences(of: ",", with: "&comma")
774+
fileprivate extension Character {
775+
var isSpace: Bool {
776+
return self == " " || self == "\t" || self == "\n" || self == "\r"
656777
}
657778

658-
func unmaskCommas() -> String {
659-
return self.replacingOccurrences(of: "&comma", with: ",")
779+
var isTokenCharacter: Bool {
780+
guard let asciiValue = self.asciiValue else {
781+
return false
782+
}
783+
784+
// CTL, 0-31 and DEL (127)
785+
if asciiValue <= 31 || asciiValue >= 127 {
786+
return false
787+
}
788+
789+
let nonTokenCharacters = "()<>@,;:\\\"/[]?={} \t"
790+
return !nonTokenCharacters.contains(self)
660791
}
661792
}
662-

0 commit comments

Comments
 (0)