mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-04-22 13:31:32 +00:00
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/<cwdId>.
- 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).
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -113,8 +113,10 @@ export class GeminiSessionsProvider implements IProviderSessions {
|
||||
*/
|
||||
async fetchHistory(
|
||||
sessionId: string,
|
||||
_options: FetchHistoryOptions = {},
|
||||
options: FetchHistoryOptions = {},
|
||||
): Promise<FetchHistoryResult> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user