Files
gbrain/test/put-page-dedup-fence.test.ts
15b9863d13 v0.42.73.2 fix(security): fence dedup-resolved writes to the caller's own write scope (#3809)
* fix(security): fence the dedup-resolved slug under the caller's own confinement

put_page's resolved-slug re-check tested `ctx.auth.boundSlugPrefixes` only.
The delegated submit_agent -> subagent context carries `viaSubagent` +
`allowedSlugPrefixes` but no `auth`, so a slug-bound client holding `agent`
scope could delegate a write and have importFromContent's dedup pre-check
redirect it onto a page outside its grant — where the disk write-through
then re-rendered the victim's file with the caller's provenance.

The re-check now applies whichever confinement the caller is actually
under (OAuth binding and/or subagent allow-list / legacy namespace) via
`slugOutsideCallerFence`, which composes the existing match rules rather
than re-deriving them. Dedup returns status 'skipped' before any DB write,
so the throw still rolls nothing back. The denial does not name the
resolved slug (slug-enumeration oracle) and reads "your write scope",
since either confinement can trigger it.

Reported privately by Aleksei Razsadin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test: coverage for the OAuth in-fence redirect and the missing-subagentId guard

* v0.42.73.2 fix(security): fence dedup-resolved writes to the caller's own write scope

VERSION + package.json + CHANGELOG for 0.42.73.2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: state that the write fence follows a delegated write

---------

Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 07:59:50 +07:00

189 lines
7.0 KiB
TypeScript

/**
* put_page dedup resolved-slug fence (v0.42.73.1).
*
* importFromContent's dedup pre-check can resolve a write to a DIFFERENT page
* than the caller named (same `frontmatter.id`), and the disk write-through
* runs against that RESOLVED slug. The re-check that fences it shipped in
* v0.42.72.0 testing `ctx.auth.boundSlugPrefixes` only — so a slug-bound
* OAuth client holding `agent` scope could delegate the write through
* submit_agent, whose subagent context carries `viaSubagent` +
* `allowedSlugPrefixes` but NO `auth`, and land the rewrite on a page outside
* its grant.
*
* Pins: every confinement a caller can be under fences the RESOLVED slug
* (OAuth binding, trusted-workspace allow-list, legacy subagent namespace),
* unconfined callers keep the dedup redirect, an in-fence redirect still
* works, and the denial never names the resolved slug (it would be a
* slug-enumeration oracle).
*
* PGLite hermetic. Every case resolves at the dedup pre-check, which returns
* before any chunk/embed work.
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { importFromContent } from '../src/core/import-file.ts';
import { operations, OperationError } from '../src/core/operations.ts';
import type { OperationContext, Operation, AuthInfo } from '../src/core/operations.ts';
import { resetPgliteState } from './helpers/reset-pglite.ts';
let engine: PGLiteEngine;
const VICTIM_SLUG = 'people/alice-example';
const VICTIM_ID = 'external-uuid-victim';
function put(): Operation {
const found = operations.find(o => o.name === 'put_page');
if (!found) throw new Error('put_page op missing');
return found;
}
function page(id: string, body: string): string {
return ['---', 'type: concept', 'title: Notes', `id: ${id}`, '---', '', body].join('\n');
}
function makeCtx(overrides: Partial<OperationContext> = {}): OperationContext {
return {
engine,
config: { engine: 'pglite' } as any,
logger: { info: () => {}, warn: () => {}, error: () => {} },
dryRun: false,
remote: true,
sourceId: 'default',
...overrides,
};
}
function boundAuth(prefixes: string[]): AuthInfo {
return {
token: 'test-token',
clientId: 'gbrain_cl_dedup_fence',
scopes: ['read', 'write', 'agent'],
sourceId: 'default',
boundSlugPrefixes: prefixes,
};
}
/** The attacker's move: echo the victim's frontmatter id under an in-fence slug. */
async function putEchoingVictimId(ctx: OperationContext, slug: string, id = VICTIM_ID) {
return put().handler(ctx, { slug, content: page(id, 'Attacker body, different text.') });
}
async function expectFenced(p: Promise<unknown>): Promise<void> {
try {
await p;
throw new Error('should have thrown');
} catch (e) {
expect(e).toBeInstanceOf(OperationError);
expect((e as OperationError).code).toBe('permission_denied');
expect((e as Error).message).toContain('write scope');
// The oracle guard: the resolved slug belongs to a page the caller may
// not see, so it must never appear in the denial.
expect((e as Error).message).not.toContain('alice-example');
}
}
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
}, 60_000);
afterAll(async () => {
if (engine) await engine.disconnect();
}, 60_000);
beforeEach(async () => {
await resetPgliteState(engine);
const victim = await importFromContent(engine, VICTIM_SLUG, page(VICTIM_ID, 'Confidential.'), {
noEmbed: true,
sourceId: 'default',
});
expect(victim.status).toBe('imported');
});
describe('put_page: dedup-resolved slug is fenced by the caller\'s own confinement', () => {
test('delegated subagent (allow-list, NO auth) cannot rewrite an out-of-fence page', async () => {
// The bypass: submit_agent's subagent context carries allowedSlugPrefixes
// but no auth, so an auth-only re-check skipped exactly this caller.
const ctx = makeCtx({
viaSubagent: true,
subagentId: 7,
allowedSlugPrefixes: ['wiki/agents/7/*'],
});
await expectFenced(putEchoingVictimId(ctx, 'wiki/agents/7/notes'));
});
test('legacy sandbox subagent (namespace fence, no allow-list) cannot either', async () => {
const ctx = makeCtx({ viaSubagent: true, subagentId: 7 });
await expectFenced(putEchoingVictimId(ctx, 'wiki/agents/7/notes'));
});
test('slug-bound OAuth client cannot (the v0.42.72.0 case, still fenced)', async () => {
const ctx = makeCtx({ auth: boundAuth(['emp-bob/']) });
await expectFenced(putEchoingVictimId(ctx, 'emp-bob/notes'));
});
test('a caller under BOTH confinements is fenced (requested slug satisfies both)', async () => {
const ctx = makeCtx({
auth: boundAuth(['emp-bob/']),
viaSubagent: true,
subagentId: 7,
allowedSlugPrefixes: ['emp-bob/*'],
});
await expectFenced(putEchoingVictimId(ctx, 'emp-bob/notes'));
});
test('feature preserved: a redirect INSIDE the fence still dedups', async () => {
const inFence = await importFromContent(engine, 'wiki/agents/7/first', page('in-fence-id', 'Body.'), {
noEmbed: true,
sourceId: 'default',
});
expect(inFence.status).toBe('imported');
const ctx = makeCtx({
viaSubagent: true,
subagentId: 7,
allowedSlugPrefixes: ['wiki/agents/7/*'],
});
const r = await putEchoingVictimId(ctx, 'wiki/agents/7/second', 'in-fence-id') as {
slug: string; status: string;
};
expect(r.status).toBe('skipped');
expect(r.slug).toBe('wiki/agents/7/first');
});
test('feature preserved: a bound client\'s in-fence redirect still dedups', async () => {
// The OAuth mirror of the case above — the fence must not break the happy
// path it was already allowing before this change.
const inFence = await importFromContent(engine, 'emp-bob/first', page('bob-id', 'Body.'), {
noEmbed: true,
sourceId: 'default',
});
expect(inFence.status).toBe('imported');
const ctx = makeCtx({ auth: boundAuth(['emp-bob/']) });
const r = await putEchoingVictimId(ctx, 'emp-bob/second', 'bob-id') as {
slug: string; status: string;
};
expect(r.status).toBe('skipped');
expect(r.slug).toBe('emp-bob/first');
});
test('fail-closed: viaSubagent without a subagentId is denied before any write', async () => {
// enforceSubagentSlugFence refuses rather than trusting a dispatcher that
// set viaSubagent but forgot the id — the branch slugUnderSubagentFence
// would otherwise evaluate against 'wiki/agents/undefined/'.
const ctx = makeCtx({ viaSubagent: true });
const p = putEchoingVictimId(ctx, 'wiki/agents/7/notes');
await expect(p).rejects.toBeInstanceOf(OperationError);
await expect(p).rejects.toThrow(/requires ctx\.subagentId/);
});
test('regression: an unconfined caller keeps the dedup redirect', async () => {
const r = await putEchoingVictimId(makeCtx(), 'anywhere/notes') as { slug: string; status: string };
expect(r.status).toBe('skipped');
expect(r.slug).toBe(VICTIM_SLUG);
});
});