mirror of
https://github.com/ancsemi/Haven.git
synced 2026-10-27 17:15:24 +00:00
160 lines
7.9 KiB
JavaScript
160 lines
7.9 KiB
JavaScript
'use strict';
|
|
|
|
// Bot and webhook hardening end to end: banned creators, creator permissions
|
|
// for moderation bots, own-message deletes, avatar URLs, high scores, invite
|
|
// links and the Discord emote cache.
|
|
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const net = require('node:net');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
const { spawn, spawnSync } = require('node:child_process');
|
|
const test = require('node:test');
|
|
const Database = require('better-sqlite3');
|
|
|
|
async function availablePort() {
|
|
return new Promise((resolve, reject) => {
|
|
const server = net.createServer();
|
|
server.once('error', reject);
|
|
server.listen(0, '127.0.0.1', () => {
|
|
const { port } = server.address();
|
|
server.close(error => error ? reject(error) : resolve(port));
|
|
});
|
|
});
|
|
}
|
|
|
|
async function waitForServer(url, child, output) {
|
|
const deadline = Date.now() + 15000;
|
|
while (Date.now() < deadline) {
|
|
if (child.exitCode !== null) throw new Error(`Server exited early:\n${output()}`);
|
|
try {
|
|
const response = await fetch(url);
|
|
if (response.ok) return;
|
|
} catch { /* not listening yet */ }
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
}
|
|
throw new Error(`Server did not become ready:\n${output()}`);
|
|
}
|
|
|
|
test('bot and webhook hardening', async t => {
|
|
const root = path.resolve(__dirname, '..');
|
|
const dataDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'haven-bot-hardening-'));
|
|
const tokens = { a: 'a'.repeat(64), b: 'b'.repeat(64), memberMod: 'c'.repeat(64), banned: 'd'.repeat(64), adminMod: 'e'.repeat(64) };
|
|
const port = await availablePort();
|
|
const env = {
|
|
...process.env,
|
|
HAVEN_DATA_DIR: dataDir,
|
|
FORCE_HTTP: 'true',
|
|
HOST: '127.0.0.1',
|
|
JWT_SECRET: 'b'.repeat(64),
|
|
PORT: String(port)
|
|
};
|
|
|
|
const seed = spawnSync(process.execPath, ['-e', `
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const jwt = require('jsonwebtoken');
|
|
const { initDatabase } = require('./src/database');
|
|
const db = initDatabase();
|
|
const addUser = (name, admin) => Number(db.prepare("INSERT INTO users (username, password_hash, display_name, is_admin) VALUES (?, 'x', ?, ?)").run(name, name, admin ? 1 : 0).lastInsertRowid);
|
|
const owner = addUser('owner', true);
|
|
const member = addUser('member', false);
|
|
const target = addUser('target', false);
|
|
const outcast = addUser('outcast', false);
|
|
db.prepare('INSERT INTO bans (user_id, banned_by, reason) VALUES (?, ?, ?)').run(outcast, owner, 'test');
|
|
const channel = Number(db.prepare("INSERT INTO channels (name, code, created_by, is_dm) VALUES ('Bots', 'abcd1234', ?, 0)").run(owner).lastInsertRowid);
|
|
for (const id of [owner, member, target, outcast]) db.prepare('INSERT INTO channel_members (channel_id, user_id) VALUES (?, ?)').run(channel, id);
|
|
const addBot = (name, token, by, mod) => Number(db.prepare('INSERT INTO webhooks (channel_id, name, token, created_by, can_moderate) VALUES (?, ?, ?, ?, ?)').run(channel, name, token, by, mod).lastInsertRowid);
|
|
addBot('Bot A', '${tokens.a}', owner, 0);
|
|
addBot('Bot B', '${tokens.b}', owner, 0);
|
|
addBot('Member Mod', '${tokens.memberMod}', member, 1);
|
|
addBot('Outcast Bot', '${tokens.banned}', outcast, 0);
|
|
addBot('Admin Mod', '${tokens.adminMod}', owner, 1);
|
|
fs.writeFileSync(path.join(process.env.HAVEN_DATA_DIR, 'fixture.json'), JSON.stringify({
|
|
target,
|
|
memberToken: jwt.sign({ id: member, username: 'member', pwv: 1 }, process.env.JWT_SECRET),
|
|
}));
|
|
db.close();
|
|
`], { cwd: root, env, encoding: 'utf8' });
|
|
assert.equal(seed.status, 0, seed.stderr || seed.stdout);
|
|
const fixture = JSON.parse(await fs.promises.readFile(path.join(dataDir, 'fixture.json'), 'utf8'));
|
|
|
|
let logs = '';
|
|
const child = spawn(process.execPath, ['server.js'], { cwd: root, env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
child.stdout.on('data', chunk => { logs += chunk; });
|
|
child.stderr.on('data', chunk => { logs += chunk; });
|
|
let db = null;
|
|
t.after(async () => {
|
|
db?.close();
|
|
if (child.exitCode === null) child.kill('SIGTERM');
|
|
await Promise.race([
|
|
child.exitCode === null ? new Promise(resolve => child.once('exit', resolve)) : Promise.resolve(),
|
|
new Promise(resolve => setTimeout(resolve, 3000))
|
|
]);
|
|
if (child.exitCode === null) child.kill('SIGKILL');
|
|
await fs.promises.rm(dataDir, { recursive: true, force: true });
|
|
});
|
|
|
|
const base = `http://127.0.0.1:${port}`;
|
|
await waitForServer(`${base}/api/health`, child, () => logs);
|
|
const post = (token, body) => fetch(`${base}/api/webhooks/${token}`, {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
|
|
});
|
|
|
|
// A banned creator's bot cannot post.
|
|
assert.equal((await post(tokens.banned, { content: 'hi' })).status, 404);
|
|
|
|
// Avatar URLs: https and uploads only; names lose markup characters.
|
|
const fromA = await post(tokens.a, { content: 'from a', avatar_url: 'http://tracker.example/p.png', username: '<b>Ann\u0007</b>' });
|
|
assert.equal(fromA.status, 200);
|
|
const aId = (await fromA.json()).message_id;
|
|
const fromB = await post(tokens.b, { content: 'from b', avatar_url: 'https://cdn.example/b.png' });
|
|
const bId = (await fromB.json()).message_id;
|
|
db = new Database(path.join(dataDir, 'haven.db'));
|
|
const rowA = db.prepare('SELECT webhook_avatar, webhook_username, webhook_id FROM messages WHERE id = ?').get(aId);
|
|
assert.equal(rowA.webhook_avatar, null);
|
|
assert.equal(rowA.webhook_username, 'bAnn/b');
|
|
assert.equal(db.prepare('SELECT webhook_avatar FROM messages WHERE id = ?').get(bId).webhook_avatar, 'https://cdn.example/b.png');
|
|
|
|
// A bot without moderation deletes its own messages, not another bot's.
|
|
const del = (token, id) => fetch(`${base}/api/webhooks/${token}/messages/${id}`, { method: 'DELETE' });
|
|
assert.equal((await del(tokens.a, bId)).status, 403);
|
|
assert.equal((await del(tokens.a, aId)).status, 200);
|
|
assert.ok(db.prepare('SELECT 1 FROM messages WHERE id = ?').get(bId));
|
|
|
|
// A moderation bot needs its creator to hold the permission.
|
|
const ban = token => fetch(`${base}/api/webhooks/${token}/moderation/ban`, {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId: fixture.target })
|
|
});
|
|
const refused = await ban(tokens.memberMod);
|
|
assert.equal(refused.status, 403);
|
|
assert.match((await refused.json()).error, /no longer has this moderation permission/);
|
|
assert.equal((await ban(tokens.adminMod)).status, 200);
|
|
// The same goes for deleting other people's messages.
|
|
assert.equal((await del(tokens.memberMod, bId)).status, 403);
|
|
assert.equal((await fetch(`${base}/api/webhooks/${tokens.memberMod}/messages?limit=1`, { method: 'DELETE' })).status, 403);
|
|
assert.ok(db.prepare('SELECT 1 FROM messages WHERE id = ?').get(bId));
|
|
|
|
// High scores: safe integers under the ceiling only.
|
|
const score = value => fetch(`${base}/api/high-scores`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${fixture.memberToken}` },
|
|
body: `{"game":"flappy","score":${value}}`
|
|
});
|
|
assert.equal((await score('1e300')).status, 400);
|
|
assert.equal((await score('9007199254740993')).status, 400);
|
|
assert.equal((await score('"12"')).status, 400);
|
|
assert.equal((await score('12')).status, 200);
|
|
|
|
// Invite links redirect the same way whether or not the code exists.
|
|
const invite = await fetch(`${base}/invite/nosuchcode`, { redirect: 'manual' });
|
|
assert.equal(invite.status, 302);
|
|
assert.equal(invite.headers.get('location'), '/app?invite=nosuchcode');
|
|
|
|
// The Discord emote cache answers members only.
|
|
assert.equal((await fetch(`${base}/api/ferry/emote/123456789012345678.png`)).status, 401);
|
|
const asMember = await fetch(`${base}/api/ferry/emote/123456789012345678.png`, { headers: { Authorization: `Bearer ${fixture.memberToken}` } });
|
|
assert.equal(asMember.status, 404, 'past the sign-in check; the bridge is off here');
|
|
});
|