From 25da1b5200ca9981d2a2157fa81205c369c1f6aa Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Wed, 15 Jul 2026 17:41:13 +0200 Subject: [PATCH 1/4] test(index): reproduce .da-versions bypass via generic source/list routes The router guard only matches a leading '.da-versions', but version and audit objects are stored at the repo-scoped key '{repo}/.da-versions/...'. daCtx.key is org-stripped, so it starts with '{repo}/' and the guard never fires - the generic source/list/delete routes reach raw version and audit storage. These tests drive GET/DELETE/list at a .da-versions path and expect 404; they fail (200) against the current guard. --- test/index.test.js | 119 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) diff --git a/test/index.test.js b/test/index.test.js index d884cd87..86da9d07 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -161,6 +161,125 @@ describe('fetch', () => { }); }); +describe('.da-versions storage guard', () => { + // Version and audit objects live at R2 key `{org}/{repo}/.da-versions/...`, so + // daCtx.key (org stripped) is `{repo}/.da-versions/...`. The generic + // source/list/delete routes must not reach that storage - only the ACL-aware + // /versionsource and /versionlist routes may. When the guard is bypassed, + // dispatch reaches the handler mocked below and leaks or alters the raw object. + const leakHandler = { default: async () => ({ status: 200, body: 'SECRET VERSION BODY' }) }; + + it('blocks reading a version object via the generic source route', async () => { + const hnd = await esmock('../src/index.js', { + '../src/utils/daCtx.js': { + default: async () => ({ + authorized: true, + users: [{ email: 'test@example.com' }], + path: '/source/org/repo/.da-versions/1234/5678.html', + api: 'source', + org: 'org', + key: 'repo/.da-versions/1234/5678.html', + }), + }, + '../src/handlers/get.js': leakHandler, + }); + + const resp = await hnd.fetch( + { method: 'GET', url: 'http://www.example.com/source/org/repo/.da-versions/1234/5678.html' }, + {}, + ); + assert.strictEqual(resp.status, 404); + }); + + it('blocks deleting a version object via the generic source route', async () => { + const hnd = await esmock('../src/index.js', { + '../src/utils/daCtx.js': { + default: async () => ({ + authorized: true, + users: [{ email: 'test@example.com' }], + path: '/source/org/repo/.da-versions/1234/5678.html', + api: 'source', + org: 'org', + key: 'repo/.da-versions/1234/5678.html', + }), + }, + '../src/handlers/delete.js': leakHandler, + }); + + const resp = await hnd.fetch( + { method: 'DELETE', url: 'http://www.example.com/source/org/repo/.da-versions/1234/5678.html' }, + {}, + ); + assert.strictEqual(resp.status, 404); + }); + + it('blocks listing the .da-versions folder via the generic list route', async () => { + const hnd = await esmock('../src/index.js', { + '../src/utils/daCtx.js': { + default: async () => ({ + authorized: true, + users: [{ email: 'test@example.com' }], + path: '/list/org/repo/.da-versions', + api: 'list', + org: 'org', + key: 'repo/.da-versions', + }), + }, + '../src/handlers/get.js': leakHandler, + }); + + const resp = await hnd.fetch( + { method: 'GET', url: 'http://www.example.com/list/org/repo/.da-versions' }, + {}, + ); + assert.strictEqual(resp.status, 404); + }); + + it('does not block the dedicated version route (no .da-versions in key)', async () => { + const hnd = await esmock('../src/index.js', { + '../src/utils/daCtx.js': { + default: async () => ({ + authorized: true, + users: [{ email: 'test@example.com' }], + path: '/versionsource/org/repo/1234/5678.html', + api: 'versionsource', + org: 'org', + key: 'repo/1234/5678.html', + }), + }, + '../src/handlers/get.js': { default: async () => ({ status: 200, body: 'ok' }) }, + }); + + const resp = await hnd.fetch( + { method: 'GET', url: 'http://www.example.com/versionsource/org/repo/1234/5678.html' }, + {}, + ); + assert.strictEqual(resp.status, 200); + }); + + it('does not block ordinary content paths', async () => { + const hnd = await esmock('../src/index.js', { + '../src/utils/daCtx.js': { + default: async () => ({ + authorized: true, + users: [{ email: 'test@example.com' }], + path: '/source/org/repo/path/file.html', + api: 'source', + org: 'org', + key: 'repo/path/file.html', + }), + }, + '../src/handlers/get.js': { default: async () => ({ status: 200, body: 'ok' }) }, + }); + + const resp = await hnd.fetch( + { method: 'GET', url: 'http://www.example.com/source/org/repo/path/file.html' }, + {}, + ); + assert.strictEqual(resp.status, 200); + }); +}); + describe('invalid routes', () => { let hnd; From 65b22a2872ec3ca4561a5000b22933df48332c2e Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Wed, 15 Jul 2026 17:42:10 +0200 Subject: [PATCH 2/4] fix: block .da-versions access through generic source/list/delete routes Version bodies and audit logs are stored at the repo-scoped R2 key '{org}/{repo}/.da-versions/{fileId}/...'. The router guard that is meant to keep the generic source/list/delete routes out of that storage only matched a leading '.da-versions', but daCtx.key is org-stripped and so begins with '{repo}/'. The guard stopped firing when versions moved from the org-root layout to the repo-scoped one, letting any authorized org member read version history and audit logs of documents their ACL forbids, and delete or forge the audit log through the generic routes. Match '.da-versions' as any path segment of the org-stripped key. The ACL-aware versionsource/versionlist routes build the physical key internally and never carry a '.da-versions' segment in daCtx.key, so they are unaffected; ordinary content paths are unaffected. The legacy org-root layout stays covered as the first segment. --- src/index.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/index.js b/src/index.js index 5599e5fd..30d2eefd 100644 --- a/src/index.js +++ b/src/index.js @@ -55,7 +55,12 @@ export default { // blanket gate must not shadow it. if (!authorized && api !== 'list') return daResp({ status: 403 }); - if (key?.startsWith('.da-versions')) { + // `key` is org-stripped (daCtx.key), so version storage appears as the + // segment `{repo}/.da-versions/...`, not a leading `.da-versions`. Block the + // reserved folder anywhere in the path so the generic source/list/delete + // routes cannot read, list, write, or delete raw version and audit objects, + // which must only be reached via the ACL-aware versionsource/versionlist routes. + if (key?.split('/').includes('.da-versions')) { return daResp({ status: 404 }); } From e99b898b1135454d62439b54b565888a8ce23f1a Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Wed, 15 Jul 2026 17:51:16 +0200 Subject: [PATCH 3/4] test(it): assert generic list/source routes are blocked from .da-versions Adds two integration assertions after page creation: the generic list route on the reserved .da-versions folder and a write+read through the generic source route both return 404. Against the stale guard the list returns 200 and leaks the audit/version entries; the fix makes both 404. --- test/it/it-tests.js | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/test/it/it-tests.js b/test/it/it-tests.js index f37b988d..f6a990a6 100644 --- a/test/it/it-tests.js +++ b/test/it/it-tests.js @@ -292,6 +292,42 @@ export default (ctx) => describe('Integration Tests: it tests', function () { assert.ok([200, 201].includes(resp.status), `Expected 200 or 201, got ${resp.status} - user: ${superUser.email}`); }); + it('[super user] cannot list the reserved .da-versions folder via the generic list route', async () => { + // Version bodies and audit logs live at '{repo}/.da-versions/{fileId}/...'. + // Creating the pages above wrote audit entries there, so without the router + // guard this list returns 200 with those entries; only /versionlist may reach + // them. The guard must make the generic list route 404 instead. + const { + serverUrl, org, repo, superUser, + } = ctx; + const resp = await fetch(`${serverUrl}/list/${org}/${repo}/.da-versions`, { + headers: { Authorization: `Bearer ${superUser.accessToken}` }, + }); + assert.strictEqual(resp.status, 404, `Expected 404 from the router guard, got ${resp.status} - user: ${superUser.email}`); + }); + + it('[super user] cannot read or write a .da-versions object via the generic source route', async () => { + // Without the guard this POST forges an audit/version object under + // .da-versions and the GET reads it back. The guard must 404 both. + const { + serverUrl, org, repo, superUser, + } = ctx; + const url = `${serverUrl}/source/${org}/${repo}/.da-versions/forge-test/leak.html`; + const formData = new FormData(); + const blob = new Blob(['forged'], { type: 'text/html' }); + formData.append('data', new File([blob], 'leak.html', { type: 'text/html' })); + const putResp = await fetch(url, { + method: 'POST', + body: formData, + headers: { Authorization: `Bearer ${superUser.accessToken}` }, + }); + assert.strictEqual(putResp.status, 404, `Expected 404 from the router guard on write, got ${putResp.status} - user: ${superUser.email}`); + const getResp = await fetch(url, { + headers: { Authorization: `Bearer ${superUser.accessToken}` }, + }); + assert.strictEqual(getResp.status, 404, `Expected 404 from the router guard on read, got ${getResp.status} - user: ${superUser.email}`); + }); + it('[limited user] cannot read page1', async () => { const { serverUrl, org, repo, limitedUser, From 0ebc5eb96c469e0955f69d47fd505eba4e03d082 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Wed, 15 Jul 2026 17:58:02 +0200 Subject: [PATCH 4/4] test(index): harden .da-versions guard (legacy layout, write vector, substring) Adds three regression locks: the legacy org-root '.da-versions' first-segment layout still 404s, a POST write to a .da-versions audit key 404s (the forge vector), and a path segment that merely contains 'da-versions' ('my-da-versions-notes') still dispatches - pinning segment-exact matching so a future change to a substring check would fail loudly. --- test/index.test.js | 66 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/test/index.test.js b/test/index.test.js index 86da9d07..f4f6336c 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -278,6 +278,72 @@ describe('.da-versions storage guard', () => { ); assert.strictEqual(resp.status, 200); }); + + it('blocks the legacy org-root .da-versions layout (first-segment key)', async () => { + const hnd = await esmock('../src/index.js', { + '../src/utils/daCtx.js': { + default: async () => ({ + authorized: true, + users: [{ email: 'test@example.com' }], + path: '/source/org/.da-versions/1234/5678.html', + api: 'source', + org: 'org', + key: '.da-versions/1234/5678.html', + }), + }, + '../src/handlers/get.js': leakHandler, + }); + + const resp = await hnd.fetch( + { method: 'GET', url: 'http://www.example.com/source/org/.da-versions/1234/5678.html' }, + {}, + ); + assert.strictEqual(resp.status, 404); + }); + + it('blocks writing an audit object via the generic source route', async () => { + const hnd = await esmock('../src/index.js', { + '../src/utils/daCtx.js': { + default: async () => ({ + authorized: true, + users: [{ email: 'test@example.com' }], + path: '/source/org/repo/.da-versions/1234/audit.txt', + api: 'source', + org: 'org', + key: 'repo/.da-versions/1234/audit.txt', + }), + }, + '../src/handlers/post.js': leakHandler, + }); + + const resp = await hnd.fetch( + { method: 'POST', url: 'http://www.example.com/source/org/repo/.da-versions/1234/audit.txt' }, + {}, + ); + assert.strictEqual(resp.status, 404); + }); + + it('does not block a path segment that merely contains da-versions', async () => { + const hnd = await esmock('../src/index.js', { + '../src/utils/daCtx.js': { + default: async () => ({ + authorized: true, + users: [{ email: 'test@example.com' }], + path: '/source/org/repo/my-da-versions-notes/file.html', + api: 'source', + org: 'org', + key: 'repo/my-da-versions-notes/file.html', + }), + }, + '../src/handlers/get.js': { default: async () => ({ status: 200, body: 'ok' }) }, + }); + + const resp = await hnd.fetch( + { method: 'GET', url: 'http://www.example.com/source/org/repo/my-da-versions-notes/file.html' }, + {}, + ); + assert.strictEqual(resp.status, 200); + }); }); describe('invalid routes', () => {