Files

106 lines
7.2 KiB
JavaScript

/* eslint-disable @typescript-eslint/no-require-imports -- Standalone Node integration test. */
const assert = require('node:assert/strict');
const { PrismaClient } = require('@prisma/client');
const { hash } = require('bcryptjs');
const { randomUUID } = require('node:crypto');
const fs = require('node:fs/promises');
const path = require('node:path');
const origin = 'http://localhost:3057';
const testUrl = process.env.SECURITY_TEST_DATABASE_URL;
if (!testUrl || !['127.0.0.1','localhost'].includes(new URL(testUrl).hostname) || !new URL(testUrl).pathname.endsWith('/kita_security_test')) throw new Error('Use only the dedicated local test database.');
const prisma = new PrismaClient({ datasourceUrl: testUrl });
const prefix = `http-security-${randomUUID()}`;
let attachmentId;
const upload = path.resolve(process.env.UPLOAD_DIR, 'announcements');
let checks = 0;
function checked() { checks++; }
async function login(email, password) {
const jar = new Map();
function cookies(response) {
for (const cookie of response.headers.getSetCookie()) {
const item = cookie.split(';')[0]; const index = item.indexOf('=');
jar.set(item.slice(0,index), item.slice(index + 1));
}
}
const csrf = await fetch(origin + '/api/auth/csrf'); cookies(csrf);
const { csrfToken } = await csrf.json();
const response = await fetch(origin + '/api/auth/callback/credentials', {
method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Origin: origin,
Cookie: [...jar].map(([key,value]) => `${key}=${value}`).join('; '), 'X-Auth-Return-Redirect': '1' },
body: new URLSearchParams({ csrfToken, email, password, callbackUrl: origin + '/dashboard' }), redirect: 'manual',
});
cookies(response);
return [...jar].map(([key,value]) => `${key}=${value}`).join('; ');
}
async function main() {
const password = 'Local-http-test-password';
const passwordHash = await hash(password, 12);
const kitaA = await prisma.kita.create({ data: { name: prefix + '-A', slug: prefix + '-a' } });
const kitaB = await prisma.kita.create({ data: { name: prefix + '-B', slug: prefix + '-b' } });
const makeUser = (kita,label) => prisma.user.create({ data: { email: `${label}.${prefix}@example.invalid`, firstName: label, lastName: 'HTTP', kitaId: kita.id,
role: 'ADMIN', passwordHash, emailVerifiedAt: new Date(), privacyPolicyAcceptedAt: new Date() } });
const userA = await makeUser(kitaA,'a'); const userB = await makeUser(kitaB,'b');
const page = await fetch(origin + '/login');
const html = await page.text();
assert.equal(page.status, 200); checked();
assert.equal(page.headers.get('x-content-type-options'), 'nosniff'); checked();
assert.equal(page.headers.get('x-frame-options'), 'DENY'); checked();
assert.equal(page.headers.get('referrer-policy'), 'no-referrer'); checked();
const csp = page.headers.get('content-security-policy');
assert.ok(csp.includes("'strict-dynamic'") && !csp.includes("'unsafe-eval'")); checked();
const nonce = csp.match(/'nonce-([^']+)'/)[1];
assert.ok(html.includes(`nonce="${nonce}"`)); checked();
assert.ok(!html.includes('umami-before-send')); checked();
for (const headers of [ {}, { Origin: 'https://evil.invalid' }, { Origin: 'https://evil.invalid', 'x-forwarded-host': 'evil.invalid' } ]) {
const blocked = await fetch(origin + '/register', { method: 'POST', headers: { ...headers, 'Next-Action': 'invalid-test-action' }, body: '[]', redirect: 'manual' });
assert.equal(blocked.status,403); checked();
}
for (const url of ['/api/announcements/attachments/invalid', 'https://images.unsplash.com/unknown-image']) {
const optimized = await fetch(origin + '/_next/image?' + new URLSearchParams({ url, w: '640', q: '75' }));
assert.equal(optimized.status,400); checked();
}
const cookieA = await login(userA.email,password);
const cookieB = await login(userB.email,password);
const getSession = async cookie => (await fetch(origin + '/api/auth/session', { headers: { Cookie: cookie } })).json();
assert.equal((await getSession(cookieA))?.user.id, userA.id); checked();
assert.equal((await fetch(origin + '/admin', { headers: { Cookie: cookieA }, redirect: 'manual' })).status, 307); checked();
assert.equal((await fetch(origin + '/dashboard', { redirect: 'manual' })).status, 307); checked();
const privatePage = await fetch(origin + '/dashboard', { headers: { Cookie: cookieA }, redirect: 'manual' });
assert.equal(privatePage.status, 200); checked();
assert.match(privatePage.headers.get('cache-control'), /no-store/); checked();
attachmentId = randomUUID();
await prisma.announcement.create({ data: { kitaId: kitaB.id, authorId: userB.id, title: 'Private attachment', content: 'Private',
attachments: { create: { id: attachmentId, fileName: 'bad.html', fileType: 'text/html', fileUrl: '/api/' + attachmentId } } } });
await fs.mkdir(upload, { recursive: true }); await fs.writeFile(path.join(upload,attachmentId), '<script>bad()</script>');
const attachment = cookie => fetch(origin + '/api/announcements/attachments/' + attachmentId, { headers: { Cookie: cookie } });
assert.equal((await attachment(cookieA)).status, 404); checked();
const own = await attachment(cookieB); assert.equal(own.status,200); checked();
assert.equal(own.headers.get('content-type'),'application/octet-stream'); checked();
assert.match(own.headers.get('content-disposition'),/^attachment;/); checked();
assert.equal(own.headers.get('content-security-policy'),"default-src 'none'; sandbox"); checked();
await prisma.user.update({ where: { id: userB.id }, data: { privacyPolicyAcceptedAt: null } });
assert.equal((await attachment(cookieB)).status,401); checked();
await prisma.user.update({ where: { id: userA.id }, data: { disabledAt: new Date(), sessionVersion: { increment: 1 } } });
assert.equal((await getSession(cookieA))?.user,undefined); checked();
await prisma.user.update({ where: { id: userA.id }, data: { disabledAt: null } });
assert.equal((await getSession(cookieA))?.user,undefined); checked();
const newCookie = await login(userA.email,password);
assert.equal((await getSession(newCookie))?.user.id,userA.id); checked();
await prisma.user.delete({ where: { id: userA.id } });
assert.equal((await getSession(newCookie))?.user,undefined); checked();
// Auth.js must reject trusted-but-obsolete token payloads rather than constructing a ghost session.
const { encode } = await import('@auth/core/jwt');
for (const payload of [{ role: 'ADMIN', kitaId: kitaA.id }, { id: userB.id, role: 'ADMIN', kitaId: kitaB.id },
{ id: 123, sessionVersion: 0, authenticatedAt: Date.now() },
{ id: 'deleted-' + prefix, role: 'ADMIN', kitaId: kitaA.id, sessionVersion: 0, authenticatedAt: Date.now() }]) {
const jwt = await encode({ token: payload, secret: 'security-test-only-not-a-production-secret', salt: 'authjs.session-token' });
assert.equal((await getSession('authjs.session-token=' + jwt))?.user,undefined); checked();
}
console.log(`${checks} HTTP security checks passed against the production build.`);
}
main().finally(async () => {
if (attachmentId) await fs.rm(path.join(upload,attachmentId),{ force: true });
await prisma.kita.deleteMany({ where: { slug: { startsWith: prefix } } });
await prisma.$disconnect();
}).catch(error => { console.error(error); process.exitCode = 1; });