diff --git a/src/main/native-chat/agent-session-journal/journal-blob-store.ts b/src/main/native-chat/agent-session-journal/journal-blob-store.ts index a084c5641d8..d517a68c0ff 100644 --- a/src/main/native-chat/agent-session-journal/journal-blob-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-blob-store.ts @@ -10,9 +10,12 @@ import { join } from 'node:path' import { durableWriteTempPath, writeFileDurable } from '../../durable-file-write' const BLOB_DIR = 'blobs' +const DIGEST_PATTERN = /^[0-9a-f]{64}$/ -function blobPath(journalDir: string, digest: string): string { - return join(journalDir, BLOB_DIR, digest) +/** A digest arrives back from a row on disk, so it is untrusted by the time it + * reaches the filesystem: anything but a bare sha256 could escape the store. */ +function blobPath(journalDir: string, digest: string): string | null { + return DIGEST_PATTERN.test(digest) ? join(journalDir, BLOB_DIR, digest) : null } /** Persist `payload` under its digest. Returns the digest so the caller can @@ -23,6 +26,9 @@ export async function putJournalBlob( payload: string ): Promise { const target = blobPath(journalDir, digest) + if (!target) { + throw new Error('refusing to write a journal blob under a name that is not a sha256 digest') + } // Content addressing makes a rewrite pointless: identical digest, identical bytes. if (await pathExists(target)) { return digest @@ -33,8 +39,12 @@ export async function putJournalBlob( } export async function readJournalBlob(journalDir: string, digest: string): Promise { + const source = blobPath(journalDir, digest) + if (!source) { + return null + } try { - return await readFile(blobPath(journalDir, digest), 'utf-8') + return await readFile(source, 'utf-8') } catch { return null } diff --git a/src/main/native-chat/agent-session-journal/journal-store.test.ts b/src/main/native-chat/agent-session-journal/journal-store.test.ts index 0d13061b264..f6e85e63adf 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.test.ts @@ -225,6 +225,15 @@ describe('compaction and retention', () => { expect(await readJournalBlob(root, kept.digest)).toBe('k'.repeat(64)) expect(await readJournalBlob(root, dropped.digest)).toBeNull() }) + + it('refuses a blob name that is not a bare digest, on either slash', async () => { + const { putJournalBlob } = await import('./journal-blob-store') + // A corrupt or crafted row must not steer a read or a write out of the store. + for (const name of ['../../escape', '..\\..\\escape', 'nested/name', 'NOTHEX']) { + expect(await readJournalBlob(root, name)).toBeNull() + await expect(putJournalBlob(root, name, 'payload')).rejects.toThrow('sha256 digest') + } + }) }) describe('bounds', () => {