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
6 changes: 4 additions & 2 deletions server/app.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,10 @@ export async function createApplication({ worker, databasePath, setupSecret, pub
if (!['GET', 'HEAD'].includes(request.method)) return new Response('Method not allowed.', { status: 405 });
const pathname = new URL(request.url).pathname;
const name = pathname === '/' ? 'index.html' : pathname.slice(1);
// Serve only top-level public assets, never directories or project/configuration files.
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name)) return new Response('Not found.', { status: 404 });
// Serve top-level public assets and known preset avatars, never arbitrary directories or project/configuration files.
const topLevelAsset = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name);
const presetAvatar = /^avatars\/(?:dots|grok|muse)\.svg$/.test(name);
if (!topLevelAsset && !presetAvatar) return new Response('Not found.', { status: 404 });
const filename = name.includes('.') ? name : name + '.html';
const extension = filename.slice(filename.lastIndexOf('.')); if (!types[extension]) return new Response('Not found.', { status: 404 });
try { const bytes = await readFile(join(assetsDirectory, filename)); return new Response(request.method === 'HEAD' ? null : bytes, { headers: { 'Content-Type': types[extension], 'Cache-Control': 'no-cache' } }); }
Expand Down
8 changes: 8 additions & 0 deletions tests/server.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ test('server persists messages, acknowledgments and keys across restart; HTTP pr
const oversized = await call('/api/v1/messages', { method: 'POST', key, body: { content: 'a'.repeat(70000) } }); assert.equal(oversized.status, 413);
for (const path of ['/.server.env', '/store.sqlite', '/server/start.mjs', '/%2e%2e/.server.env']) assert.equal((await fetch(`http://127.0.0.1:${app.server.address().port}${path}`)).status, 404);
assert.equal((await fetch(`http://127.0.0.1:${app.server.address().port}/`)).status, 200);
for (const name of ['dots', 'grok', 'muse']) {
const avatar = await fetch(`http://127.0.0.1:${app.server.address().port}/avatars/${name}.svg`);
assert.equal(avatar.status, 200, `Preset ${name} must be served by the SQLite host`);
assert.match(avatar.headers.get('content-type'), /^image\/svg\+xml/);
assert.match(await avatar.text(), /<svg\b/);
}
for (const path of ['/avatars/unknown.svg', '/avatars/.server.env', '/avatars/../server/start.mjs'])
assert.equal((await fetch(`http://127.0.0.1:${app.server.address().port}${path}`)).status, 404);
await app.close(); app = undefined; await start();
assert.equal((await call('/api/v1/status')).data.initialized, true);
assert.equal((await call('/api/v1/me', { key })).status, 200);
Expand Down
Loading