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 }); } diff --git a/test/index.test.js b/test/index.test.js index d884cd87..f4f6336c 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -161,6 +161,191 @@ 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); + }); + + 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', () => { let hnd; 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,