@@ -20,6 +20,15 @@ import Foundation
2020
2121/// Structure holding a single cookie
2222public struct Cookie : Sendable , CustomStringConvertible {
23+ public struct ValidationError : Error {
24+ enum Reason {
25+ case invalidName
26+ case invalidValue
27+ }
28+
29+ let reason : Reason
30+ }
31+
2332 public enum SameSite : String , Sendable {
2433 case lax = " Lax "
2534 case strict = " Strict "
@@ -52,7 +61,118 @@ public struct Cookie: Sendable, CustomStringConvertible {
5261 /// The SameSite attribute lets servers specify whether/when cookies are sent with cross-origin requests
5362 public var sameSite : SameSite ? { self . properties [ . sameSite] . map { SameSite ( rawValue: $0) } ?? nil }
5463
55- /// Create `Cookie`
64+ private static func isValidValue( _ value: String ) -> Bool {
65+ // RFC 6265 Section 4.1.1: cookie-octet set
66+ // Allowed: 0x21, 0x23-0x2B, 0x2D-0x3A, 0x3C-0x5B, 0x5D-0x7E
67+ value. utf8. allSatisfy { byte in
68+ ( byte == 0x21 ) || ( 0x23 ... 0x2B ) . contains ( byte) || ( 0x2D ... 0x3A ) . contains ( byte) || ( 0x3C ... 0x5B ) . contains ( byte)
69+ || ( 0x5D ... 0x7E ) . contains ( byte)
70+ }
71+ }
72+
73+ private static func isValidName( _ name: String ) -> Bool {
74+ // RFC 2616 Section 2.2: token = 1*<any CHAR except CTLs or separators>
75+ // CTLs: 0-31, 127
76+ // Separators: ()<>@,;:\"/[]?={} \t
77+ let separators : Set < UInt8 > = [
78+ UInt8 ( ascii: " ( " ) ,
79+ UInt8 ( ascii: " ) " ) ,
80+ UInt8 ( ascii: " < " ) ,
81+ UInt8 ( ascii: " > " ) ,
82+ UInt8 ( ascii: " @ " ) ,
83+ UInt8 ( ascii: " , " ) ,
84+ UInt8 ( ascii: " ; " ) ,
85+ UInt8 ( ascii: " : " ) ,
86+ UInt8 ( ascii: " \\ " ) ,
87+ UInt8 ( ascii: " \" " ) ,
88+ UInt8 ( ascii: " / " ) ,
89+ UInt8 ( ascii: " [ " ) ,
90+ UInt8 ( ascii: " ] " ) ,
91+ UInt8 ( ascii: " ? " ) ,
92+ UInt8 ( ascii: " = " ) ,
93+ UInt8 ( ascii: " { " ) ,
94+ UInt8 ( ascii: " } " ) ,
95+ ]
96+ // Space is no in the separators, but is added to the CTLs because it's right behind the last CTL
97+ // `0x1F` is the last CTL, and space is `0x20`
98+ return !name. isEmpty
99+ && name. utf8. allSatisfy { byte in
100+ ( byte >= 0x21 && byte != 127 && !separators. contains ( byte) )
101+ }
102+ }
103+
104+ /// Create `Cookie` and validates the name and value to be valid as per RFC 6265.
105+ ///
106+ /// If the name and value are not valid, an `ValidationError` will be thrown. Contrary to
107+ /// the equivalent initializer, this function will not `assert` on DEBUG for invalid names
108+ /// and values.
109+ ///
110+ /// - Parameters:
111+ /// - name: Name of cookie
112+ /// - value: Value of cookie
113+ /// - expires: indicates the maximum lifetime of the cookie
114+ /// - maxAge: indicates the maximum lifetime of the cookie in seconds. Max age has precedence
115+ /// over expires (not all user agents support max-age)
116+ /// - domain: specifies those hosts to which the cookie will be sent
117+ /// - path: The scope of each cookie is limited to a set of paths, controlled by the Path attribute
118+ /// - secure: The Secure attribute limits the scope of the cookie to "secure" channels
119+ /// - httpOnly: The HttpOnly attribute limits the scope of the cookie to HTTP requests
120+ /// - sameSite: The SameSite attribute lets servers specify whether/when cookies are sent with cross-origin requests
121+ public static func validated(
122+ name: String ,
123+ value: String ,
124+ expires: Date ? = nil ,
125+ maxAge: Int ? = nil ,
126+ domain: String ? = nil ,
127+ path: String ? = nil ,
128+ secure: Bool = false ,
129+ httpOnly: Bool = true ,
130+ sameSite: SameSite ? = nil
131+ ) throws -> Cookie {
132+ guard Cookie . isValidName ( name) else {
133+ throw ValidationError ( reason: . invalidName)
134+ }
135+
136+ guard Cookie . isValidValue ( value) else {
137+ throw ValidationError ( reason: . invalidValue)
138+ }
139+
140+ assert ( !( secure == false && sameSite == Cookie . SameSite. none) , " Cookies with SameSite set to None require the Secure attribute to be set " )
141+
142+ if let sameSite {
143+ return Cookie (
144+ name: name,
145+ value: value,
146+ expires: expires,
147+ maxAge: maxAge,
148+ domain: domain,
149+ path: path,
150+ secure: secure,
151+ httpOnly: httpOnly,
152+ sameSite: sameSite
153+ )
154+ } else {
155+ return Cookie (
156+ name: name,
157+ value: value,
158+ expires: expires,
159+ maxAge: maxAge,
160+ domain: domain,
161+ path: path,
162+ secure: secure,
163+ httpOnly: httpOnly
164+ )
165+ }
166+ }
167+
168+ /// Create `Cookie`. The `name` and `value` are assumed to contain valid characters as per RFC 6265.
169+ ///
170+ /// If the name and value are not valid, an `assert` will fail on DEBUG, or the cookie will be have
171+ /// an invalid `String` representation on RELEASE.
172+ ///
173+ /// Use ``Cookie/validated(name:value:expires:maxAge:domain:path:secure:httpOnly:sameSite:)`` to create
174+ /// a cookie while validating name and value.
175+ ///
56176 /// - Parameters:
57177 /// - name: Name of cookie
58178 /// - value: Value of cookie
@@ -72,6 +192,9 @@ public struct Cookie: Sendable, CustomStringConvertible {
72192 secure: Bool = false ,
73193 httpOnly: Bool = true
74194 ) {
195+ assert ( Cookie . isValidName ( name) , " Cookie name contains invalid characters as per RFC 6265 " )
196+ assert ( Cookie . isValidValue ( value) , " Cookie value contains invalid characters as per RFC 6265 " )
197+
75198 self . name = name
76199 self . value = value
77200 var properties = Properties ( )
@@ -84,17 +207,26 @@ public struct Cookie: Sendable, CustomStringConvertible {
84207 self . properties = properties
85208 }
86209
87- /// Create `Cookie`
210+ /// Create `Cookie`. The `name` and `value` are assumed to contain valid characters as per RFC 6265.
211+ ///
212+ /// If the name and value are not valid, an `assert` will fail on DEBUG, or the cookie will be have
213+ /// an invalid `String` representation on RELEASE.
214+ ///
215+ /// Use ``Cookie/validated(name:value:expires:maxAge:domain:path:secure:httpOnly:sameSite:)`` to create
216+ /// a cookie while validating name and value.
217+ ///
88218 /// - Parameters:
89219 /// - name: Name of cookie
90220 /// - value: Value of cookie
91221 /// - expires: indicates the maximum lifetime of the cookie
92- /// - maxAge: indicates the maximum lifetime of the cookie in seconds. Max age has precedence over expires (not all user agents support max-age)
222+ /// - maxAge: indicates the maximum lifetime of the cookie in seconds. Max age has precedence over
223+ /// expires (not all user agents support max-age)
93224 /// - domain: specifies those hosts to which the cookie will be sent
94225 /// - path: The scope of each cookie is limited to a set of paths, controlled by the Path attribute
95226 /// - secure: The Secure attribute limits the scope of the cookie to "secure" channels
96227 /// - httpOnly: The HttpOnly attribute limits the scope of the cookie to HTTP requests
97- /// - sameSite: The SameSite attribute lets servers specify whether/when cookies are sent with cross-origin requests
228+ /// - sameSite: The SameSite attribute lets servers specify whether/when cookies are sent with
229+ /// cross-origin requests
98230 public init (
99231 name: String ,
100232 value: String ,
@@ -106,7 +238,10 @@ public struct Cookie: Sendable, CustomStringConvertible {
106238 httpOnly: Bool = true ,
107239 sameSite: SameSite
108240 ) {
241+ assert ( Cookie . isValidName ( name) , " Cookie name contains invalid characters as per RFC 6265 " )
109242 assert ( !( secure == false && sameSite == . none) , " Cookies with SameSite set to None require the Secure attribute to be set " )
243+ assert ( Cookie . isValidValue ( value) , " Cookie value contains invalid characters as per RFC 6265 " )
244+
110245 self . name = name
111246 self . value = value
112247 var properties = Properties ( )
0 commit comments