Skip to content

Commit c872ec1

Browse files
devartifexCopilot
andcommitted
fix: add path traversal protection to session persistence (#55)
- Add isValidSessionId() UUID validation for getSessionDetail/buildSessionContext - Reset isProcessing flag on resume to prevent stale state - Add 4 unit tests for UUID validation and path traversal rejection Closes #55 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent ffa41b3 commit c872ec1

2 files changed

Lines changed: 38 additions & 5 deletions

File tree

src/lib/server/copilot/session-metadata.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import {
4242
deleteSessionFromFilesystem,
4343
getSessionDetail,
4444
getSessionStateDir,
45+
isValidSessionId,
4546
listSessionsFromFilesystem,
4647
} from './session-metadata.js';
4748

@@ -314,3 +315,29 @@ describe('deleteSessionFromFilesystem', () => {
314315
expect(rmMock).toHaveBeenCalledWith(sessionDir, { recursive: true, force: true });
315316
});
316317
});
318+
319+
describe('isValidSessionId', () => {
320+
it('accepts well-formed UUIDs', () => {
321+
expect(isValidSessionId(sessionId)).toBe(true);
322+
expect(isValidSessionId('AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE')).toBe(true);
323+
});
324+
325+
it('rejects non-UUID strings', () => {
326+
expect(isValidSessionId('')).toBe(false);
327+
expect(isValidSessionId('not-a-uuid')).toBe(false);
328+
expect(isValidSessionId('../../etc/passwd')).toBe(false);
329+
expect(isValidSessionId('../escape')).toBe(false);
330+
});
331+
});
332+
333+
describe('path traversal protection', () => {
334+
it('getSessionDetail rejects path traversal attempts', async () => {
335+
await expect(getSessionDetail('../../etc/passwd')).resolves.toBeNull();
336+
expect(accessMock).not.toHaveBeenCalled();
337+
});
338+
339+
it('buildSessionContext rejects path traversal attempts', async () => {
340+
await expect(buildSessionContext('../../etc/passwd')).resolves.toBeNull();
341+
expect(readFileMock).not.toHaveBeenCalled();
342+
});
343+
});

src/lib/server/copilot/session-metadata.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@ export interface SessionDetail {
2222
isRemote?: boolean;
2323
}
2424

25+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
26+
27+
/** Validate that a session ID is a well-formed UUID (prevents path traversal) */
28+
export function isValidSessionId(id: string): boolean {
29+
return UUID_RE.test(id);
30+
}
31+
2532
/** Resolve the session-state root directory */
2633
export function getSessionStateDir(): string {
2734
const base = config.copilotConfigDir || join(homedir(), '.copilot');
@@ -113,6 +120,7 @@ export async function enrichSessionMetadata(
113120

114121
/** Get full session detail for the preview panel */
115122
export async function getSessionDetail(sessionId: string): Promise<SessionDetail | null> {
123+
if (!isValidSessionId(sessionId)) return null;
116124
const sessionDir = join(getSessionStateDir(), sessionId);
117125
if (!await pathExists(sessionDir)) return null;
118126

@@ -216,11 +224,9 @@ export async function listSessionsFromFilesystem(): Promise<FilesystemSession[]>
216224
}
217225

218226
// UUID pattern — only pick directories that look like session IDs
219-
const uuidRe = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
220-
221227
const results = await Promise.all(
222228
entries
223-
.filter((name) => uuidRe.test(name))
229+
.filter((name) => isValidSessionId(name))
224230
.map(async (sessionId): Promise<FilesystemSession | null> => {
225231
const sessionDir = join(stateDir, sessionId);
226232

@@ -267,6 +273,7 @@ export async function listSessionsFromFilesystem(): Promise<FilesystemSession[]>
267273
* Used as a fallback when resumeSession() fails for bundled/filesystem-only sessions.
268274
*/
269275
export async function buildSessionContext(sessionId: string): Promise<string | null> {
276+
if (!isValidSessionId(sessionId)) return null;
270277
const sessionDir = join(getSessionStateDir(), sessionId);
271278
if (!await pathExists(sessionDir)) return null;
272279

@@ -339,8 +346,7 @@ export async function buildSessionContext(sessionId: string): Promise<string | n
339346
* (e.g. bundled or filesystem-only sessions).
340347
*/
341348
export async function deleteSessionFromFilesystem(sessionId: string): Promise<boolean> {
342-
const uuidRe = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
343-
if (!uuidRe.test(sessionId)) return false;
349+
if (!isValidSessionId(sessionId)) return false;
344350

345351
const sessionDir = join(getSessionStateDir(), sessionId);
346352
if (!await pathExists(sessionDir)) return false;

0 commit comments

Comments
 (0)