Skip to content

Commit 657e91f

Browse files
fix(sessions): validate session ID format before using as storage key (#17718)
Add UUID_REGEX validation in #ensureSessionID() so non-UUID cookie values are rejected before reaching storage.get(). Previously, a crafted cookie value matching a non-session key in shared storage could trigger destroy() and delete that data via storage.removeItem(). Update existing test mocks to use valid UUIDs and add two new tests verifying that non-UUID values are rejected and valid UUIDs are preserved.
1 parent c8729fe commit 657e91f

3 files changed

Lines changed: 54 additions & 8 deletions

File tree

.changeset/honest-ears-argue.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'astro': patch
3+
---
4+
5+
Fixes session cookie values not being validated against the expected UUID format before being used as storage keys

packages/astro/src/core/session/runtime.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export const PERSIST_SYMBOL = Symbol();
1212

1313
const DEFAULT_COOKIE_NAME = 'astro-session';
1414
const VALID_COOKIE_REGEX = /^[\w-]+$/;
15+
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
1516

1617
interface SessionEntry {
1718
data: any;
@@ -432,7 +433,7 @@ export class AstroSession {
432433
#ensureSessionID() {
433434
if (!this.#sessionID) {
434435
const cookieValue = this.#cookies.get(this.#cookieName)?.value;
435-
if (cookieValue) {
436+
if (cookieValue && UUID_REGEX.test(cookieValue)) {
436437
this.#sessionID = cookieValue;
437438
this.#sessionIDFromCookie = true;
438439
} else {

packages/astro/test/units/sessions/astro-session.test.ts

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ interface MockCookies {
2525
const defaultMockCookies: MockCookies = {
2626
set: () => {},
2727
delete: () => {},
28-
get: () => ({ value: 'sessionid' }),
28+
get: () => ({ value: '00000000-0000-4000-8000-000000000000' }),
2929
};
3030

3131
const stringify = (data: unknown) => JSON.parse(devalueStringify(data));
@@ -400,7 +400,7 @@ describe('AstroSession - Sparse Data Operations', () => {
400400

401401
it('should persist delete as the first mutation (no prior get/set)', async () => {
402402
const store = new Map<string, string>();
403-
const sessionId = 'sessionid';
403+
const sessionId = '00000000-0000-4000-8000-000000000000';
404404
store.set(sessionId, devalueStringify(new Map([['token', { data: 'secret' }]])));
405405

406406
const mockStorage = {
@@ -631,7 +631,7 @@ describe('AstroSession - No-Cookie Short Circuit', () => {
631631
set: () => {},
632632
delete: () => {},
633633
get: (name: string) => {
634-
if (name === 'test-session') return { value: 'existing-session-id' };
634+
if (name === 'test-session') return { value: '00000000-0000-4000-8000-000000000002' };
635635
return undefined;
636636
},
637637
};
@@ -664,18 +664,19 @@ describe('AstroSession - No-Cookie Short Circuit', () => {
664664
describe('AstroSession - regenerate() error path', () => {
665665
it('should route errors to logger and reset #partial flag', async () => {
666666
let storageGetCount = 0;
667-
// The cookie mock returns 'old-session' so that ensureData() will try to
668-
// load from storage using that key and hit the corrupt data path.
667+
// Use a valid UUID so #ensureSessionID() accepts it and ensureData() tries
668+
// to load from storage, hitting the corrupt data path.
669+
const oldSessionUUID = '00000000-0000-4000-8000-000000000001';
669670
const cookies: MockCookies = {
670671
set: () => {},
671672
delete: () => {},
672-
get: (name: string) => (name === 'test-session' ? { value: 'old-session' } : undefined),
673+
get: (name: string) => (name === 'test-session' ? { value: oldSessionUUID } : undefined),
673674
};
674675
const spyLogger = new SpyLogger();
675676
const mockStorage = {
676677
async get(key: string) {
677678
storageGetCount++;
678-
if (key === 'old-session') {
679+
if (key === oldSessionUUID) {
679680
// Return a string that unflatten() will parse into an Array, not a Map.
680681
// This causes unflatten(raw) instanceof Map to be false, throwing an AstroError.
681682
return '[1,2,3]';
@@ -734,3 +735,42 @@ describe('AstroSession - regenerate() error path', () => {
734735
});
735736
});
736737
// #endregion
738+
739+
// #region Session ID Validation
740+
describe('AstroSession - Session ID Validation', () => {
741+
it('rejects a non-UUID session cookie value and generates a new ID', async () => {
742+
const cookies: MockCookies = {
743+
set: () => {},
744+
delete: () => {},
745+
get: (name: string) =>
746+
name === 'test-session' ? { value: 'not-a-uuid-at-all' } : undefined,
747+
};
748+
749+
const session = createSession(defaultConfig, cookies);
750+
session.set('key', 'value');
751+
752+
const id = session.sessionID;
753+
assert.ok(id, 'Session ID should be set');
754+
assert.notEqual(id, 'not-a-uuid-at-all', 'Should not accept non-UUID cookie value');
755+
assert.match(
756+
id!,
757+
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
758+
'Should generate a valid UUID',
759+
);
760+
});
761+
762+
it('accepts a valid UUID session cookie value', async () => {
763+
const validUUID = '550e8400-e29b-41d4-a716-446655440000';
764+
const cookies: MockCookies = {
765+
set: () => {},
766+
delete: () => {},
767+
get: (name: string) => (name === 'test-session' ? { value: validUUID } : undefined),
768+
};
769+
770+
const session = createSession(defaultConfig, cookies);
771+
session.set('key', 'value');
772+
773+
assert.equal(session.sessionID, validUUID, 'Should preserve valid UUID from cookie');
774+
});
775+
});
776+
// #endregion

0 commit comments

Comments
 (0)