Skip to content

Commit 3ae359b

Browse files
Joannisscoatesadam-fowler
authored
Add cookie validation APIs and assert (on DEBUG) when initalizing an invalid cookie (#727)
* Add cookie validation APIs * Update test coverage Co-Authored-by: Sean Coates <sean@seancoates.com> * Remove the second `validated` function and add comments pointing to the differences between the initializer and `validated` * Fix formatting * Format comments --------- Co-authored-by: Sean Coates <sean@seancoates.com> Co-authored-by: Adam Fowler <adamfowler71@gmail.com>
1 parent f16edf3 commit 3ae359b

File tree

2 files changed

+172
-4
lines changed

2 files changed

+172
-4
lines changed

Sources/Hummingbird/HTTP/Cookie.swift

Lines changed: 139 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,15 @@ import Foundation
2020

2121
/// Structure holding a single cookie
2222
public 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()

Tests/HummingbirdTests/CookiesTests.swift

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,4 +105,37 @@ final class CookieTests: XCTestCase {
105105
}
106106
}
107107
}
108+
109+
func testValidatedCookieSuccess() throws {
110+
let cookie = try Cookie.validated(name: "session", value: "abcdef1234")
111+
XCTAssertEqual(cookie.name, "session")
112+
XCTAssertEqual(cookie.value, "abcdef1234")
113+
XCTAssertEqual(cookie.httpOnly, true)
114+
XCTAssertEqual(cookie.secure, false)
115+
}
116+
117+
func testValidatedCookieWithSameSite() throws {
118+
let cookie = try Cookie.validated(name: "foo", value: "bar", sameSite: .strict)
119+
XCTAssertEqual(cookie.name, "foo")
120+
XCTAssertEqual(cookie.value, "bar")
121+
XCTAssertEqual(cookie.sameSite, .strict)
122+
}
123+
124+
func testValidatedCookieInvalidName() {
125+
XCTAssertThrowsError(try Cookie.validated(name: "invalid;name", value: "value")) { error in
126+
XCTAssert(error is Cookie.ValidationError, "Unexpected error type")
127+
}
128+
XCTAssertThrowsError(try Cookie.validated(name: "invalid;name", value: "value", sameSite: .strict)) { error in
129+
XCTAssert(error is Cookie.ValidationError, "Unexpected error type")
130+
}
131+
}
132+
133+
func testValidatedCookieInvalidValue() {
134+
XCTAssertThrowsError(try Cookie.validated(name: "name", value: "inv\u{7F}alid")) { error in
135+
XCTAssert(error is Cookie.ValidationError, "Unexpected error type")
136+
}
137+
XCTAssertThrowsError(try Cookie.validated(name: "name", value: "inv\u{7F}alid", sameSite: .strict)) { error in
138+
XCTAssert(error is Cookie.ValidationError, "Unexpected error type")
139+
}
140+
}
108141
}

0 commit comments

Comments
 (0)