106 lines
7.2 KiB
JavaScript
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; });
|