fix(#274): escape page_changed injection surface, drop dead content_hash (review F1-F5)
F1: escape the collaborative page title before interpolating into
<page_changed page="..."> (and the pre-existing openedPage attr) — strip
<>" and collapse whitespace, so a crafted title can't break out of the
attribute into the system prompt (cross-user injection).
F2: neutralize <page_changed>/</page_changed> occurrences inside the diff body
so a crafted line can't close the block early.
F3: remove the dead content_hash column (written every turn, never read) —
migration, repo, service hashing + crypto import, db.d.ts, spec asserts.
F4: test the best-effort catch branches (detectPageChange / snapshotOpenPage
swallow errors and don't break the turn).
F5: soften the overstated 'diff cannot smuggle instructions' comment to
defense-in-depth framing referencing the F1/F2 mitigations + safety sandwich.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -34,9 +34,6 @@ export async function up(db: Kysely<any>): Promise<void> {
|
||||
// against the live page.updated_at as a cheap fast path: equal => nothing
|
||||
// changed, skip the render + diff entirely.
|
||||
.addColumn('page_updated_at', 'timestamptz', (col) => col.notNull())
|
||||
// Optional content fingerprint (informational; the updated_at fast path is the
|
||||
// primary change signal). Nullable so a snapshot can be written without one.
|
||||
.addColumn('content_hash', 'varchar', (col) => col)
|
||||
.addColumn('created_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
|
||||
@@ -102,7 +102,6 @@ describe('AiChatPageSnapshotRepo', () => {
|
||||
workspaceId: 'ws1',
|
||||
contentMd: '# hello',
|
||||
pageUpdatedAt,
|
||||
contentHash: 'abc',
|
||||
});
|
||||
|
||||
expect(rec.table).toBe('aiChatPageSnapshots');
|
||||
@@ -112,31 +111,13 @@ describe('AiChatPageSnapshotRepo', () => {
|
||||
workspaceId: 'ws1',
|
||||
contentMd: '# hello',
|
||||
pageUpdatedAt,
|
||||
contentHash: 'abc',
|
||||
});
|
||||
expect(rec.conflictColumns).toEqual(['chatId', 'pageId']);
|
||||
expect(rec.conflictUpdate).toMatchObject({
|
||||
contentMd: '# hello',
|
||||
pageUpdatedAt,
|
||||
contentHash: 'abc',
|
||||
});
|
||||
expect(rec.conflictUpdate?.updatedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('defaults a missing content hash to null (insert and conflict update)', async () => {
|
||||
const { db, rec } = makeDb({ id: 's1' });
|
||||
const repo = new AiChatPageSnapshotRepo(db);
|
||||
|
||||
await repo.upsert({
|
||||
chatId: 'c1',
|
||||
pageId: 'p1',
|
||||
workspaceId: 'ws1',
|
||||
contentMd: 'body',
|
||||
pageUpdatedAt: new Date('2026-07-02T10:00:00Z'),
|
||||
});
|
||||
|
||||
expect(rec.values?.contentHash).toBeNull();
|
||||
expect(rec.conflictUpdate?.contentHash).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,7 +48,6 @@ export class AiChatPageSnapshotRepo {
|
||||
workspaceId: string;
|
||||
contentMd: string;
|
||||
pageUpdatedAt: Date;
|
||||
contentHash?: string | null;
|
||||
},
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<AiChatPageSnapshot> {
|
||||
@@ -61,13 +60,11 @@ export class AiChatPageSnapshotRepo {
|
||||
workspaceId: values.workspaceId,
|
||||
contentMd: values.contentMd,
|
||||
pageUpdatedAt: values.pageUpdatedAt,
|
||||
contentHash: values.contentHash ?? null,
|
||||
})
|
||||
.onConflict((oc) =>
|
||||
oc.columns(['chatId', 'pageId']).doUpdateSet({
|
||||
contentMd: values.contentMd,
|
||||
pageUpdatedAt: values.pageUpdatedAt,
|
||||
contentHash: values.contentHash ?? null,
|
||||
updatedAt: new Date(),
|
||||
}),
|
||||
)
|
||||
|
||||
-1
@@ -657,7 +657,6 @@ export interface AiChatPageSnapshots {
|
||||
workspaceId: string;
|
||||
contentMd: string;
|
||||
pageUpdatedAt: Timestamp;
|
||||
contentHash: string | null;
|
||||
createdAt: Generated<Timestamp>;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user