/* 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), ''); 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; });