From 1d885076e97a9961b2ced7c15b00b5990b8258f8 Mon Sep 17 00:00:00 2001 From: Haileyesus <118998054+blackmammoth@users.noreply.github.com> Date: Tue, 21 Apr 2026 13:24:52 +0300 Subject: [PATCH] fix(server/providers): harden and correct session history normalization/pagination Address correctness and safety issues in provider session adapters while preserving existing normalized message shapes. Claude sessions: - Ensure user text content parts generate unique normalized message ids. - Replace duplicate `${baseId}_text` ids with index-suffixed ids to avoid collisions when one user message contains multiple text segments. Cursor sessions: - Add session id sanitization before constructing SQLite paths to prevent path traversal via crafted session ids. - Enforce containment by resolving the computed DB path and asserting it stays under ~/.cursor/chats/. - Refactor blob parsing to a two-pass flow: first build blobMap and collect JSON blobs, then parse binary parent refs against the fully populated map. - Fix pagination semantics so limit=0 returns an empty page instead of full history, with consistent total/hasMore/offset/limit metadata. Gemini sessions: - Honor FetchHistoryOptions pagination by reading limit/offset and slicing normalized history accordingly. - Return consistent hasMore/offset/limit metadata for paged responses. Validation: - eslint passed for touched files. - server TypeScript check passed (tsc --noEmit -p server/tsconfig.json). --- .../list/claude/claude-sessions.provider.ts | 5 +- .../list/cursor/cursor-sessions.provider.ts | 91 +++++++++++++------ .../list/gemini/gemini-sessions.provider.ts | 18 +++- 3 files changed, 81 insertions(+), 33 deletions(-) diff --git a/server/modules/providers/list/claude/claude-sessions.provider.ts b/server/modules/providers/list/claude/claude-sessions.provider.ts index 5d0e3bd5..72bbe07e 100644 --- a/server/modules/providers/list/claude/claude-sessions.provider.ts +++ b/server/modules/providers/list/claude/claude-sessions.provider.ts @@ -70,7 +70,8 @@ export class ClaudeSessionsProvider implements IProviderSessions { if (raw.message?.role === 'user' && raw.message?.content) { if (Array.isArray(raw.message.content)) { - for (const part of raw.message.content) { + for (let partIndex = 0; partIndex < raw.message.content.length; partIndex++) { + const part = raw.message.content[partIndex]; if (part.type === 'tool_result') { messages.push(createNormalizedMessage({ id: `${baseId}_tr_${part.tool_use_id}`, @@ -88,7 +89,7 @@ export class ClaudeSessionsProvider implements IProviderSessions { const text = part.text || ''; if (text && !isInternalContent(text)) { messages.push(createNormalizedMessage({ - id: `${baseId}_text`, + id: `${baseId}_text_${partIndex}`, sessionId, timestamp: ts, provider: PROVIDER, diff --git a/server/modules/providers/list/cursor/cursor-sessions.provider.ts b/server/modules/providers/list/cursor/cursor-sessions.provider.ts index a200e568..e276ba8c 100644 --- a/server/modules/providers/list/cursor/cursor-sessions.provider.ts +++ b/server/modules/providers/list/cursor/cursor-sessions.provider.ts @@ -25,6 +25,24 @@ type CursorMessageBlob = { content: AnyRecord; }; +function sanitizeCursorSessionId(sessionId: string): string { + const normalized = sessionId.trim(); + if (!normalized) { + throw new Error('Cursor session id is required.'); + } + + if ( + normalized.includes('..') + || normalized.includes(path.posix.sep) + || normalized.includes(path.win32.sep) + || normalized !== path.basename(normalized) + ) { + throw new Error(`Invalid cursor session id "${sessionId}".`); + } + + return normalized; +} + export class CursorSessionsProvider implements IProviderSessions { /** * Loads Cursor's SQLite blob DAG and returns message blobs in conversation @@ -35,9 +53,17 @@ export class CursorSessionsProvider implements IProviderSessions { const { default: Database } = await import('better-sqlite3'); const cwdId = crypto.createHash('md5').update(projectPath || process.cwd()).digest('hex'); - const storeDbPath = path.join(os.homedir(), '.cursor', 'chats', cwdId, sessionId, 'store.db'); + const safeSessionId = sanitizeCursorSessionId(sessionId); + const baseChatsPath = path.join(os.homedir(), '.cursor', 'chats', cwdId); + const storeDbPath = path.join(baseChatsPath, safeSessionId, 'store.db'); + const resolvedBaseChatsPath = path.resolve(baseChatsPath); + const resolvedStoreDbPath = path.resolve(storeDbPath); + const relativeStorePath = path.relative(resolvedBaseChatsPath, resolvedStoreDbPath); + if (relativeStorePath.startsWith('..') || path.isAbsolute(relativeStorePath)) { + throw new Error(`Invalid cursor session path for "${sessionId}".`); + } - const db = new Database(storeDbPath, { readonly: true, fileMustExist: true }); + const db = new Database(resolvedStoreDbPath, { readonly: true, fileMustExist: true }); try { const allBlobs = db.prepare<[], CursorDbBlob>('SELECT rowid, id, data FROM blobs').all(); @@ -57,28 +83,35 @@ export class CursorSessionsProvider implements IProviderSessions { } catch { // Cursor can include binary or partial blobs; only JSON blobs become messages. } - } else if (blob.data) { - const parents: string[] = []; - let i = 0; - while (i < blob.data.length - 33) { - if (blob.data[i] === 0x0A && blob.data[i + 1] === 0x20) { - const parentHash = blob.data.slice(i + 2, i + 34).toString('hex'); - if (blobMap.has(parentHash)) { - parents.push(parentHash); - } - i += 34; - } else { - i++; + } + } + + for (const blob of allBlobs) { + if (!blob.data || blob.data[0] === 0x7B) { + continue; + } + + const parents: string[] = []; + let i = 0; + while (i < blob.data.length - 33) { + if (blob.data[i] === 0x0A && blob.data[i + 1] === 0x20) { + const parentHash = blob.data.slice(i + 2, i + 34).toString('hex'); + if (blobMap.has(parentHash)) { + parents.push(parentHash); } + i += 34; + } else { + i++; } - if (parents.length > 0) { - parentRefs.set(blob.id, parents); - for (const parentId of parents) { - if (!childRefs.has(parentId)) { - childRefs.set(parentId, []); - } - childRefs.get(parentId)?.push(blob.id); + } + + if (parents.length > 0) { + parentRefs.set(blob.id, parents); + for (const parentId of parents) { + if (!childRefs.has(parentId)) { + childRefs.set(parentId, []); } + childRefs.get(parentId)?.push(blob.id); } } } @@ -192,14 +225,20 @@ export class CursorSessionsProvider implements IProviderSessions { try { const blobs = await this.loadCursorBlobs(sessionId, projectPath); const allNormalized = this.normalizeCursorBlobs(blobs, sessionId); + const total = allNormalized.length; - if (limit !== null && limit > 0) { + if (limit !== null) { const start = offset; - const page = allNormalized.slice(start, start + limit); + const page = limit === 0 + ? [] + : allNormalized.slice(start, start + limit); + const hasMore = limit === 0 + ? start < total + : start + limit < total; return { messages: page, - total: allNormalized.length, - hasMore: start + limit < allNormalized.length, + total, + hasMore, offset, limit, }; @@ -207,7 +246,7 @@ export class CursorSessionsProvider implements IProviderSessions { return { messages: allNormalized, - total: allNormalized.length, + total, hasMore: false, offset: 0, limit: null, diff --git a/server/modules/providers/list/gemini/gemini-sessions.provider.ts b/server/modules/providers/list/gemini/gemini-sessions.provider.ts index c181ab3b..7d5b5f1a 100644 --- a/server/modules/providers/list/gemini/gemini-sessions.provider.ts +++ b/server/modules/providers/list/gemini/gemini-sessions.provider.ts @@ -113,8 +113,10 @@ export class GeminiSessionsProvider implements IProviderSessions { */ async fetchHistory( sessionId: string, - _options: FetchHistoryOptions = {}, + options: FetchHistoryOptions = {}, ): Promise { + const { limit = null, offset = 0 } = options; + let rawMessages: AnyRecord[]; try { rawMessages = sessionManager.getSessionMessages(sessionId) as AnyRecord[]; @@ -208,12 +210,18 @@ export class GeminiSessionsProvider implements IProviderSessions { } } + const start = Math.max(0, offset); + const pageLimit = limit === null ? null : Math.max(0, limit); + const messages = pageLimit === null + ? normalized.slice(start) + : normalized.slice(start, start + pageLimit); + return { - messages: normalized, + messages, total: normalized.length, - hasMore: false, - offset: 0, - limit: null, + hasMore: pageLimit === null ? false : start + pageLimit < normalized.length, + offset: start, + limit: pageLimit, }; } }