Skip to content

Commit fb96bf1

Browse files
authored
feat: Implement SameSite for cookies (#100)
1 parent 3366e0b commit fb96bf1

File tree

3 files changed

+226
-4
lines changed

3 files changed

+226
-4
lines changed

docs/src/content/docs/fetch-mockers/mocking-browser-requests.mdx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,13 +114,14 @@ credentials.setCookie({
114114
name: "sessionId",
115115
value: "abc123",
116116
path: "/admin", // defaults to "/"
117+
sameSite: "none", // defaults to "lax"
117118
secure: true, // defaults to false
118119
httpOnly: true // defaults to false
119120
});
120121
```
121122

122123
<Aside type="note">
123-
Real cookies also allow you to specify an expiration date and the `HttpOnly` flag, but neither is useful for testing purposes and so are not supported.
124+
Real cookies also allow you to specify an expiration date but that's not useful for testing purposes and so is not supported.
124125
</Aside>
125126

126127
<Aside type="caution">
@@ -202,6 +203,7 @@ Cookies are included in requests based on these rules:
202203
1. The request domain must match or be a subdomain of the cookie's domain
203204
2. The request path must match or be under the cookie's path
204205
3. For secure cookies, the request must use HTTPS
206+
4. The request must match the cookie's `sameSite` policy (if set)
205207

206208
For example:
207209

src/cookie-credentials.js

Lines changed: 89 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ import { parseUrl } from "./util.js";
1717

1818
/** @typedef {import("./types.js").Credentials} Credentials */
1919

20+
/**
21+
* @typedef {"strict"|"lax"|"none"} SameSiteType
22+
*/
23+
2024
/**
2125
* @typedef {Object} CookieInfo
2226
* @property {string} name The name of the cookie.
@@ -25,12 +29,15 @@ import { parseUrl } from "./util.js";
2529
* @property {string} [path] The path of the cookie.
2630
* @property {boolean} [secure] The secure flag of the cookie.
2731
* @property {boolean} [httpOnly] The HTTP-only flag of the cookie.
32+
* @property {SameSiteType} [sameSite] The SameSite attribute of the cookie.
2833
*/
2934

3035
//-----------------------------------------------------------------------------
3136
// Helpers
3237
//-----------------------------------------------------------------------------
3338

39+
const sameSiteValues = new Set(["strict", "lax", "none"]);
40+
3441
/**
3542
* Asserts that a string is a valid domain that does not include a protocol or path.
3643
* @param {string|undefined} domain The domain string to verify.
@@ -47,6 +54,23 @@ function assertValidDomain(domain) {
4754
}
4855
}
4956

57+
/**
58+
* Asserts that a string is a valid SameSite value and that the security requirements are met.
59+
* @param {SameSiteType|undefined} sameSite The SameSite value to verify.
60+
* @param {boolean} secure The secure flag of the cookie.
61+
* @throws {TypeError} If the SameSite value is not valid or if SameSite=None without Secure.
62+
*/
63+
function assertValidSameSite(sameSite, secure) {
64+
if (sameSite && !sameSiteValues.has(sameSite)) {
65+
throw new TypeError(`Invalid sameSite value: ${sameSite}`);
66+
}
67+
68+
// If sameSite is "none", secure must be true
69+
if (sameSite === "none" && !secure) {
70+
throw new TypeError(`SameSite=None requires Secure flag to be true`);
71+
}
72+
}
73+
5074
/**
5175
* Represents a cookie.
5276
* @implements {CookieInfo}
@@ -88,6 +112,12 @@ class Cookie {
88112
*/
89113
httpOnly;
90114

115+
/**
116+
* The SameSite attribute of the cookie.
117+
* @type {SameSiteType}
118+
*/
119+
sameSite;
120+
91121
/**
92122
* Creates a new CookieData instance.
93123
* @param {Object} options The options for the cookie.
@@ -97,6 +127,7 @@ class Cookie {
97127
* @param {string} [options.path=""] The path of the cookie.
98128
* @param {boolean} [options.secure=false] The secure flag of the cookie.
99129
* @param {boolean} [options.httpOnly=false] The HTTP-only flag of the cookie.
130+
* @param {SameSiteType} [options.sameSite="lax"] The SameSite attribute of the cookie.
100131
*/
101132
constructor({
102133
name,
@@ -105,6 +136,7 @@ class Cookie {
105136
path = "/",
106137
secure = false,
107138
httpOnly = false,
139+
sameSite = "lax",
108140
}) {
109141
assertValidDomain(domain);
110142

@@ -116,12 +148,15 @@ class Cookie {
116148
throw new TypeError("Cookie value is required.");
117149
}
118150

151+
assertValidSameSite(sameSite, secure);
152+
119153
this.name = name;
120154
this.value = value;
121155
this.domain = /** @type {string} */ (domain);
122156
this.path = path;
123157
this.secure = secure;
124158
this.httpOnly = httpOnly;
159+
this.sameSite = sameSite;
125160
}
126161

127162
/**
@@ -142,11 +177,58 @@ class Cookie {
142177
isCredentialForRequest(request) {
143178
const url = parseUrl(request.url);
144179

145-
return (
180+
// Basic checks for domain, path, and secure flag
181+
const basicChecks =
146182
url.hostname.endsWith(this.domain) &&
147183
url.pathname.startsWith(this.path) &&
148-
(this.secure ? url.protocol === "https:" : true)
149-
);
184+
(this.secure ? url.protocol === "https:" : true);
185+
186+
if (!basicChecks) {
187+
return false;
188+
}
189+
190+
// Check SameSite attribute
191+
if (this.sameSite) {
192+
const requestOrigin = request.headers?.get("Origin");
193+
194+
switch (this.sameSite) {
195+
case "strict":
196+
// Only send cookie if the request came from the same origin
197+
if (requestOrigin && requestOrigin !== url.origin) {
198+
return false;
199+
}
200+
break;
201+
202+
case "lax":
203+
// Permit cookies for navigation to top-level document via "safe" methods
204+
// For simplicity, we'll only block cross-origin non-GET requests in Lax mode
205+
if (
206+
requestOrigin &&
207+
requestOrigin !== url.origin &&
208+
request.method !== "GET"
209+
) {
210+
return false;
211+
}
212+
break;
213+
214+
case "none":
215+
// Allow cross-origin requests, but cookie must be Secure
216+
// We already validated secure flag in the constructor
217+
break;
218+
219+
default:
220+
// Default to Lax behavior
221+
if (
222+
requestOrigin &&
223+
requestOrigin !== url.origin &&
224+
request.method !== "GET"
225+
) {
226+
return false;
227+
}
228+
}
229+
}
230+
231+
return true;
150232
}
151233

152234
/**
@@ -172,6 +254,10 @@ class Cookie {
172254
cookieString += `; Path=${this.path}`;
173255
}
174256

257+
if (this.sameSite) {
258+
cookieString += `; SameSite=${this.sameSite}`;
259+
}
260+
175261
if (this.secure) {
176262
cookieString += `; Secure`;
177263
}

tests/cookie-credentials.test.js

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,60 @@ describe("CookieCredentials", () => {
151151
}, /value is required/gi);
152152
});
153153
});
154+
155+
describe("with sameSite", () => {
156+
it("should set a cookie with sameSite=strict", () => {
157+
const credentials = new CookieCredentials(BASE_URL);
158+
credentials.setCookie({
159+
name: "session",
160+
value: "123",
161+
sameSite: "strict",
162+
});
163+
});
164+
165+
it("should set a cookie with sameSite=lax", () => {
166+
const credentials = new CookieCredentials(BASE_URL);
167+
credentials.setCookie({
168+
name: "session",
169+
value: "123",
170+
sameSite: "lax",
171+
});
172+
});
173+
174+
it("should set a cookie with sameSite=none if secure=true", () => {
175+
const credentials = new CookieCredentials(BASE_URL);
176+
credentials.setCookie({
177+
name: "session",
178+
value: "123",
179+
sameSite: "none",
180+
secure: true,
181+
});
182+
});
183+
184+
it("should throw an error when sameSite=none without secure=true", () => {
185+
const credentials = new CookieCredentials(BASE_URL);
186+
187+
assert.throws(() => {
188+
credentials.setCookie({
189+
name: "session",
190+
value: "123",
191+
sameSite: "none",
192+
});
193+
}, /SameSite=None requires Secure flag to be true/gi);
194+
});
195+
196+
it("should throw an error with invalid sameSite value", () => {
197+
const credentials = new CookieCredentials(BASE_URL);
198+
199+
assert.throws(() => {
200+
credentials.setCookie({
201+
name: "session",
202+
value: "123",
203+
sameSite: "invalid",
204+
});
205+
}, /Invalid sameSite value/gi);
206+
});
207+
});
154208
});
155209

156210
describe("deleteCookie()", () => {
@@ -383,5 +437,85 @@ describe("CookieCredentials", () => {
383437
assert.strictEqual(headers.get("Cookie"), null);
384438
});
385439
});
440+
441+
describe("with sameSite", () => {
442+
it("should include cookie with sameSite=strict for same-origin requests", () => {
443+
const credentials = new CookieCredentials(BASE_URL);
444+
credentials.setCookie({
445+
name: "session",
446+
value: "123",
447+
sameSite: "strict",
448+
});
449+
450+
const request = new Request(BASE_URL);
451+
const headers = credentials.getHeadersForRequest(request);
452+
assert.strictEqual(headers.get("Cookie"), "session=123");
453+
});
454+
455+
it("should not include cookie with sameSite=strict for cross-origin requests", () => {
456+
const credentials = new CookieCredentials(BASE_URL);
457+
credentials.setCookie({
458+
name: "session",
459+
value: "123",
460+
sameSite: "strict",
461+
});
462+
463+
const request = new Request(BASE_URL);
464+
// Simulate a cross-origin request by adding an Origin header
465+
request.headers.set("Origin", "https://different-origin.com");
466+
467+
const headers = credentials.getHeadersForRequest(request);
468+
assert.strictEqual(headers.get("Cookie"), null);
469+
});
470+
471+
it("should include cookie with sameSite=lax for cross-origin GET requests", () => {
472+
const credentials = new CookieCredentials(BASE_URL);
473+
credentials.setCookie({
474+
name: "session",
475+
value: "123",
476+
sameSite: "lax",
477+
});
478+
479+
const request = new Request(BASE_URL, { method: "GET" });
480+
// Simulate a cross-origin request by adding an Origin header
481+
request.headers.set("Origin", "https://different-origin.com");
482+
483+
const headers = credentials.getHeadersForRequest(request);
484+
assert.strictEqual(headers.get("Cookie"), "session=123");
485+
});
486+
487+
it("should not include cookie with sameSite=lax for cross-origin POST requests", () => {
488+
const credentials = new CookieCredentials(BASE_URL);
489+
credentials.setCookie({
490+
name: "session",
491+
value: "123",
492+
sameSite: "lax",
493+
});
494+
495+
const request = new Request(BASE_URL, { method: "POST" });
496+
// Simulate a cross-origin request by adding an Origin header
497+
request.headers.set("Origin", "https://different-origin.com");
498+
499+
const headers = credentials.getHeadersForRequest(request);
500+
assert.strictEqual(headers.get("Cookie"), null);
501+
});
502+
503+
it("should include cookie with sameSite=none for cross-origin requests", () => {
504+
const credentials = new CookieCredentials(BASE_URL);
505+
credentials.setCookie({
506+
name: "session",
507+
value: "123",
508+
sameSite: "none",
509+
secure: true,
510+
});
511+
512+
const request = new Request(BASE_URL, { method: "POST" });
513+
// Simulate a cross-origin request by adding an Origin header
514+
request.headers.set("Origin", "https://different-origin.com");
515+
516+
const headers = credentials.getHeadersForRequest(request);
517+
assert.strictEqual(headers.get("Cookie"), "session=123");
518+
});
519+
});
386520
});
387521
});

0 commit comments

Comments
 (0)