fix(native-chat): keep journal blob reads and writes inside the blob store

A digest reaches the filesystem after a round trip through a row on disk, so a
corrupt or crafted row could name a path outside the store. Blob names are now
required to be a bare sha256, checked on both reads and writes.
This commit is contained in:
Brennan Benson
2026-08-07 13:49:15 -07:00
parent d4727e1c4a
commit a0c960097c
2 changed files with 22 additions and 3 deletions
@@ -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<string> {
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<string | null> {
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
}
@@ -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', () => {