Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}

Expand Down
185 changes: 185 additions & 0 deletions test/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: '[email protected]' }],
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: '[email protected]' }],
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: '[email protected]' }],
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: '[email protected]' }],
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: '[email protected]' }],
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: '[email protected]' }],
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: '[email protected]' }],
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: '[email protected]' }],
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;

Expand Down
36 changes: 36 additions & 0 deletions test/it/it-tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(['<html><body>forged</body></html>'], { 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,
Expand Down
Loading