Skip to content

Commit c4efc26

Browse files
authored
breaking: require path when setting/deleting/serializing cookies (#11240)
* resolve empty string correctly * require path when setting/deleting/serializing cookies * update types and tests * test for "" * changeset * fix * fix * lint * fix * fix * fix * update docs * only resolve same-domain paths * this is out of date --------- Co-authored-by: Rich Harris <[email protected]>
1 parent 61626de commit c4efc26

File tree

25 files changed

+117
-86
lines changed

25 files changed

+117
-86
lines changed

.changeset/poor-parrots-own.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@sveltejs/kit': major
3+
---
4+
5+
breaking: require path option when setting/deleting/serializing cookies

documentation/docs/20-core-concepts/20-load.md

-2
Original file line numberDiff line numberDiff line change
@@ -283,8 +283,6 @@ For example, if SvelteKit is serving my.domain.com:
283283

284284
Other cookies will not be passed when `credentials: 'include'` is set, because SvelteKit does not know which domain which cookie belongs to (the browser does not pass this information along), so it's not safe to forward any of them. Use the [handleFetch hook](hooks#server-hooks-handlefetch) to work around it.
285285

286-
> When setting cookies, be aware of the `path` property. By default, the `path` of a cookie is the current pathname. If you for example set a cookie at page `admin/user`, the cookie will only be available within the `admin` pages by default. In most cases you likely want to set `path` to `'/'` to make the cookie available throughout your app.
287-
288286
## Headers
289287

290288
Both server and universal `load` functions have access to a `setHeaders` function that, when running on the server, can set headers for the response. (When running in the browser, `setHeaders` has no effect.) This is useful if you want the page to be cached, for example:

packages/create-svelte/templates/default/src/routes/sverdle/+page.server.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export const actions = {
4545
game.guesses[i] += key;
4646
}
4747

48-
cookies.set('sverdle', game.toString());
48+
cookies.set('sverdle', game.toString(), { path: '' });
4949
},
5050

5151
/**
@@ -62,10 +62,10 @@ export const actions = {
6262
return fail(400, { badGuess: true });
6363
}
6464

65-
cookies.set('sverdle', game.toString());
65+
cookies.set('sverdle', game.toString(), { path: '' });
6666
},
6767

6868
restart: async ({ cookies }) => {
69-
cookies.delete('sverdle');
69+
cookies.delete('sverdle', { path: '' });
7070
}
7171
} satisfies Actions;

packages/kit/src/exports/public.d.ts

+14-6
Original file line numberDiff line numberDiff line change
@@ -212,34 +212,42 @@ export interface Cookies {
212212
*
213213
* The `httpOnly` and `secure` options are `true` by default (except on http://localhost, where `secure` is `false`), and must be explicitly disabled if you want cookies to be readable by client-side JavaScript and/or transmitted over HTTP. The `sameSite` option defaults to `lax`.
214214
*
215-
* By default, the `path` of a cookie is the 'directory' of the current pathname. In most cases you should explicitly set `path: '/'` to make the cookie available throughout your app.
215+
* You must specify a `path` for the cookie. In most cases you should explicitly set `path: '/'` to make the cookie available throughout your app. You can use relative paths, or set `path: ''` to make the cookie only available on the current path and its children
216216
* @param name the name of the cookie
217217
* @param value the cookie value
218218
* @param opts the options, passed directly to `cookie.serialize`. See documentation [here](https://github.com./jshttp/cookie#cookieserializename-value-options)
219219
*/
220-
set(name: string, value: string, opts?: import('cookie').CookieSerializeOptions): void;
220+
set(
221+
name: string,
222+
value: string,
223+
opts: import('cookie').CookieSerializeOptions & { path: string }
224+
): void;
221225

222226
/**
223227
* Deletes a cookie by setting its value to an empty string and setting the expiry date in the past.
224228
*
225-
* By default, the `path` of a cookie is the 'directory' of the current pathname. In most cases you should explicitly set `path: '/'` to make the cookie available throughout your app.
229+
* You must specify a `path` for the cookie. In most cases you should explicitly set `path: '/'` to make the cookie available throughout your app. You can use relative paths, or set `path: ''` to make the cookie only available on the current path and its children
226230
* @param name the name of the cookie
227231
* @param opts the options, passed directly to `cookie.serialize`. The `path` must match the path of the cookie you want to delete. See documentation [here](https://github.com./jshttp/cookie#cookieserializename-value-options)
228232
*/
229-
delete(name: string, opts?: import('cookie').CookieSerializeOptions): void;
233+
delete(name: string, opts: import('cookie').CookieSerializeOptions & { path: string }): void;
230234

231235
/**
232236
* Serialize a cookie name-value pair into a `Set-Cookie` header string, but don't apply it to the response.
233237
*
234238
* The `httpOnly` and `secure` options are `true` by default (except on http://localhost, where `secure` is `false`), and must be explicitly disabled if you want cookies to be readable by client-side JavaScript and/or transmitted over HTTP. The `sameSite` option defaults to `lax`.
235239
*
236-
* By default, the `path` of a cookie is the current pathname. In most cases you should explicitly set `path: '/'` to make the cookie available throughout your app.
240+
* You must specify a `path` for the cookie. In most cases you should explicitly set `path: '/'` to make the cookie available throughout your app. You can use relative paths, or set `path: ''` to make the cookie only available on the current path and its children
237241
*
238242
* @param name the name of the cookie
239243
* @param value the cookie value
240244
* @param opts the options, passed directly to `cookie.serialize`. See documentation [here](https://github.com./jshttp/cookie#cookieserializename-value-options)
241245
*/
242-
serialize(name: string, value: string, opts?: import('cookie').CookieSerializeOptions): string;
246+
serialize(
247+
name: string,
248+
value: string,
249+
opts: import('cookie').CookieSerializeOptions & { path: string }
250+
): string;
243251
}
244252

245253
export interface KitConfig {

packages/kit/src/runtime/server/cookie.js

+39-32
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { parse, serialize } from 'cookie';
2-
import { normalize_path } from '../../utils/url.js';
2+
import { normalize_path, resolve } from '../../utils/url.js';
33

44
/**
55
* Tracks all cookies set during dev mode so we can emit warnings
@@ -14,6 +14,14 @@ const cookie_paths = {};
1414
*/
1515
const MAX_COOKIE_SIZE = 4129;
1616

17+
// TODO 3.0 remove this check
18+
/** @param {import('./page/types.js').Cookie['options']} options */
19+
function validate_options(options) {
20+
if (options?.path === undefined) {
21+
throw new Error('You must specify a `path` when setting, deleting or serializing cookies');
22+
}
23+
}
24+
1725
/**
1826
* @param {Request} request
1927
* @param {URL} url
@@ -24,8 +32,6 @@ export function get_cookies(request, url, trailing_slash) {
2432
const initial_cookies = parse(header, { decode: (value) => value });
2533

2634
const normalized_url = normalize_path(url.pathname, trailing_slash);
27-
// Emulate browser-behavior: if the cookie is set at '/foo/bar', its path is '/foo'
28-
const default_path = normalized_url.split('/').slice(0, -1).join('/') || '/';
2935

3036
/** @type {Record<string, import('./page/types.js').Cookie>} */
3137
const new_cookies = {};
@@ -104,33 +110,37 @@ export function get_cookies(request, url, trailing_slash) {
104110
/**
105111
* @param {string} name
106112
* @param {string} value
107-
* @param {import('cookie').CookieSerializeOptions} opts
113+
* @param {import('./page/types.js').Cookie['options']} options
108114
*/
109-
set(name, value, opts = {}) {
110-
set_internal(name, value, { ...defaults, ...opts });
115+
set(name, value, options) {
116+
validate_options(options);
117+
set_internal(name, value, { ...defaults, ...options });
111118
},
112119

113120
/**
114121
* @param {string} name
115-
* @param {import('cookie').CookieSerializeOptions} opts
122+
* @param {import('./page/types.js').Cookie['options']} options
116123
*/
117-
delete(name, opts = {}) {
118-
cookies.set(name, '', {
119-
...opts,
120-
maxAge: 0
121-
});
124+
delete(name, options) {
125+
validate_options(options);
126+
cookies.set(name, '', { ...options, maxAge: 0 });
122127
},
123128

124129
/**
125130
* @param {string} name
126131
* @param {string} value
127-
* @param {import('cookie').CookieSerializeOptions} opts
132+
* @param {import('./page/types.js').Cookie['options']} options
128133
*/
129-
serialize(name, value, opts) {
130-
return serialize(name, value, {
131-
...defaults,
132-
...opts
133-
});
134+
serialize(name, value, options) {
135+
validate_options(options);
136+
137+
let path = options.path;
138+
139+
if (!options.domain || options.domain === url.hostname) {
140+
path = resolve(normalized_url, path);
141+
}
142+
143+
return serialize(name, value, { ...defaults, ...options, path });
134144
}
135145
};
136146

@@ -171,19 +181,16 @@ export function get_cookies(request, url, trailing_slash) {
171181
/**
172182
* @param {string} name
173183
* @param {string} value
174-
* @param {import('cookie').CookieSerializeOptions} opts
184+
* @param {import('./page/types.js').Cookie['options']} options
175185
*/
176-
function set_internal(name, value, opts) {
177-
const path = opts.path ?? default_path;
178-
179-
new_cookies[name] = {
180-
name,
181-
value,
182-
options: {
183-
...opts,
184-
path
185-
}
186-
};
186+
function set_internal(name, value, options) {
187+
let path = options.path;
188+
189+
if (!options.domain || options.domain === url.hostname) {
190+
path = resolve(normalized_url, path);
191+
}
192+
193+
new_cookies[name] = { name, value, options: { ...options, path } };
187194

188195
if (__SVELTEKIT_DEV__) {
189196
const serialized = serialize(name, value, new_cookies[name].options);
@@ -194,9 +201,9 @@ export function get_cookies(request, url, trailing_slash) {
194201
cookie_paths[name] ??= new Set();
195202

196203
if (!value) {
197-
cookie_paths[name].delete(path);
204+
cookie_paths[name].delete(options.path);
198205
} else {
199-
cookie_paths[name].add(path);
206+
cookie_paths[name].add(options.path);
200207
}
201208
}
202209
}

packages/kit/src/runtime/server/cookie.spec.js

+21-21
Original file line numberDiff line numberDiff line change
@@ -58,15 +58,15 @@ const cookies_setup = ({ href, headers } = {}) => {
5858

5959
test('a cookie should not be present after it is deleted', () => {
6060
const { cookies } = cookies_setup();
61-
cookies.set('a', 'b');
61+
cookies.set('a', 'b', { path: '/' });
6262
expect(cookies.get('a')).toEqual('b');
63-
cookies.delete('a');
63+
cookies.delete('a', { path: '/' });
6464
assert.isNotOk(cookies.get('a'));
6565
});
6666

6767
test('default values when set is called', () => {
6868
const { cookies, new_cookies } = cookies_setup();
69-
cookies.set('a', 'b');
69+
cookies.set('a', 'b', { path: '/' });
7070
const opts = new_cookies['a']?.options;
7171
assert.equal(opts?.secure, true);
7272
assert.equal(opts?.httpOnly, true);
@@ -76,17 +76,17 @@ test('default values when set is called', () => {
7676

7777
test('default values when set is called on sub path', () => {
7878
const { cookies, new_cookies } = cookies_setup({ href: 'https://example.com/foo/bar' });
79-
cookies.set('a', 'b');
79+
cookies.set('a', 'b', { path: '' });
8080
const opts = new_cookies['a']?.options;
8181
assert.equal(opts?.secure, true);
8282
assert.equal(opts?.httpOnly, true);
83-
assert.equal(opts?.path, '/foo');
83+
assert.equal(opts?.path, '/foo/bar');
8484
assert.equal(opts?.sameSite, 'lax');
8585
});
8686

8787
test('default values when on localhost', () => {
8888
const { cookies, new_cookies } = cookies_setup({ href: 'http://localhost:1234' });
89-
cookies.set('a', 'b');
89+
cookies.set('a', 'b', { path: '/' });
9090
const opts = new_cookies['a']?.options;
9191
assert.equal(opts?.secure, false);
9292
});
@@ -103,7 +103,7 @@ test('overridden defaults when set is called', () => {
103103

104104
test('default values when delete is called', () => {
105105
const { cookies, new_cookies } = cookies_setup();
106-
cookies.delete('a');
106+
cookies.delete('a', { path: '/' });
107107
const opts = new_cookies['a']?.options;
108108
assert.equal(opts?.secure, true);
109109
assert.equal(opts?.httpOnly, true);
@@ -125,24 +125,24 @@ test('overridden defaults when delete is called', () => {
125125

126126
test('cannot override maxAge on delete', () => {
127127
const { cookies, new_cookies } = cookies_setup();
128-
cookies.delete('a', { maxAge: 1234 });
128+
cookies.delete('a', { path: '/', maxAge: 1234 });
129129
const opts = new_cookies['a']?.options;
130130
assert.equal(opts?.maxAge, 0);
131131
});
132132

133133
test('last cookie set with the same name wins', () => {
134134
const { cookies, new_cookies } = cookies_setup();
135-
cookies.set('a', 'foo');
136-
cookies.set('a', 'bar');
135+
cookies.set('a', 'foo', { path: '/' });
136+
cookies.set('a', 'bar', { path: '/' });
137137
const entry = new_cookies['a'];
138138
assert.equal(entry?.value, 'bar');
139139
});
140140

141141
test('cookie names are case sensitive', () => {
142142
const { cookies, new_cookies } = cookies_setup();
143143
// not that one should do this, but we follow the spec...
144-
cookies.set('a', 'foo');
145-
cookies.set('A', 'bar');
144+
cookies.set('a', 'foo', { path: '/' });
145+
cookies.set('A', 'bar', { path: '/' });
146146
const entrya = new_cookies['a'];
147147
const entryA = new_cookies['A'];
148148
assert.equal(entrya?.value, 'foo');
@@ -157,8 +157,8 @@ test('serialized cookie header should be url-encoded', () => {
157157
cookie: 'a=f%C3%BC; b=foo+bar' // a=fü
158158
}
159159
});
160-
cookies.set('c', 'fö'); // should use default encoding
161-
cookies.set('d', 'fö', { encode: () => 'öf' }); // should respect `encode`
160+
cookies.set('c', 'fö', { path: '/' }); // should use default encoding
161+
cookies.set('d', 'fö', { path: '/', encode: () => 'öf' }); // should respect `encode`
162162
const header = get_cookie_header(new URL(href), 'e=f%C3%A4; f=foo+bar');
163163
assert.equal(header, 'a=f%C3%BC; b=foo+bar; c=f%C3%B6; d=öf; e=f%C3%A4; f=foo+bar');
164164
});
@@ -169,7 +169,7 @@ test('warns if cookie exceeds 4,129 bytes', () => {
169169

170170
try {
171171
const { cookies } = cookies_setup();
172-
cookies.set('a', 'a'.repeat(4097));
172+
cookies.set('a', 'a'.repeat(4097), { path: '/' });
173173
} catch (e) {
174174
const error = /** @type {Error} */ (e);
175175

@@ -184,9 +184,9 @@ test('get all cookies from header and set calls', () => {
184184
const { cookies } = cookies_setup();
185185
expect(cookies.getAll()).toEqual([{ name: 'a', value: 'b' }]);
186186

187-
cookies.set('a', 'foo');
188-
cookies.set('a', 'bar');
189-
cookies.set('b', 'baz');
187+
cookies.set('a', 'foo', { path: '/' });
188+
cookies.set('a', 'bar', { path: '/' });
189+
cookies.set('b', 'baz', { path: '/' });
190190

191191
expect(cookies.getAll()).toEqual([
192192
{ name: 'a', value: 'bar' },
@@ -199,12 +199,12 @@ test("set_internal isn't affected by defaults", () => {
199199
href: 'https://example.com/a/b/c'
200200
});
201201

202-
const options = /** @type {const} */ ({
202+
const options = {
203203
secure: false,
204204
httpOnly: false,
205-
sameSite: 'none',
205+
sameSite: /** @type {const} */ ('none'),
206206
path: '/a/b/c'
207-
});
207+
};
208208

209209
set_internal('test', 'foo', options);
210210

packages/kit/src/runtime/server/fetch.js

+7-6
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import * as paths from '__sveltekit/paths';
99
* manifest: import('@sveltejs/kit').SSRManifest;
1010
* state: import('types').SSRState;
1111
* get_cookie_header: (url: URL, header: string | null) => string;
12-
* set_internal: (name: string, value: string, opts: import('cookie').CookieSerializeOptions) => void;
12+
* set_internal: (name: string, value: string, opts: import('./page/types.js').Cookie['options']) => void;
1313
* }} opts
1414
* @returns {typeof fetch}
1515
*/
@@ -134,12 +134,13 @@ export function create_fetch({ event, options, manifest, state, get_cookie_heade
134134
for (const str of set_cookie_parser.splitCookiesString(set_cookie)) {
135135
const { name, value, ...options } = set_cookie_parser.parseString(str);
136136

137+
const path = options.path ?? (url.pathname.split('/').slice(0, -1).join('/') || '/');
138+
137139
// options.sameSite is string, something more specific is required - type cast is safe
138-
set_internal(
139-
name,
140-
value,
141-
/** @type {import('cookie').CookieSerializeOptions} */ (options)
142-
);
140+
set_internal(name, value, {
141+
path,
142+
.../** @type {import('cookie').CookieSerializeOptions} */ (options)
143+
});
143144
}
144145
}
145146

packages/kit/src/runtime/server/page/types.d.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -31,5 +31,5 @@ export interface CspOpts {
3131
export interface Cookie {
3232
name: string;
3333
value: string;
34-
options: CookieSerializeOptions;
34+
options: CookieSerializeOptions & { path: string };
3535
}

packages/kit/src/utils/url.js

+1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const absolute = /^([a-z]+:)?\/?\//;
1515
export function resolve(base, path) {
1616
if (SCHEME.test(path)) return path;
1717
if (path[0] === '#') return base + path;
18+
if (path === '') return base;
1819

1920
const base_match = absolute.exec(base);
2021
const path_match = absolute.exec(path);

packages/kit/src/utils/url.spec.js

+4
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@ describe('resolve', (test) => {
5757
test('resolves data: urls', () => {
5858
assert.equal(resolve('/a/b/c', 'data:text/plain,hello'), 'data:text/plain,hello');
5959
});
60+
61+
test('resolves empty string', () => {
62+
assert.equal(resolve('/a/b/c', ''), '/a/b/c');
63+
});
6064
});
6165

6266
describe('normalize_path', (test) => {

0 commit comments

Comments
 (0)