import { INestApplication } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; import request from 'supertest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { InstanceSettingsService } from '../settings/instance-settings.service'; import { SUPPRESS_ORIGIN_HEADER, createTestApp, sessionCookieOf } from '../testing/test-app'; import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; import { UsersService } from '../users/users.service'; /** * CSRF origin check, fail closed (issue #189): a cookie-carrying mutation * without `Origin` and `Referer` is rejected exactly like a mismatch, and * the exception for non-browser clients is structural — PAT/bearer requests * carry no cookie and never reach the check. Cookie-authenticated requests * never benefit from any header-based bypass. */ describe.skipIf(!hasTestDb)('csrf origin check (e2e, issue #189)', () => { let app: INestApplication; let prisma: PrismaClient; const suffix = uniqueSuffix(); const password = 'csrf fail closed pass 1'; const ids: Record = {}; const cookies: Record = {}; let pondId: string; let pondSlug: string; let patToken: string; const api = () => request(app.getHttpServer()); async function makeUser(handle: string): Promise { const users = app.get(UsersService); const username = `csrf-${handle}-${suffix}`; const user = await users.createUser({ username, email: `${username}@example.org`, displayName: `Csrf ${handle}`, password, locale: 'en', }); await users.markEmailVerified(user.id); ids[handle] = user.id; cookies[handle] = sessionCookieOf( await api() .post('/api/v1/auth/login') .send({ usernameOrEmail: username, password }) .expect(200), ); } beforeAll(async () => { prisma = createTestPrisma(); await prisma.rateLimit.deleteMany({}); app = await createTestApp(); for (const handle of ['owner', 'siteadmin']) { await makeUser(handle); } await prisma.user.update({ where: { id: ids.siteadmin! }, data: { isSiteAdmin: true } }); // Per-user quota override, never the instance default (shared database). await api() .put(`/api/v1/admin/quotas/user/${ids.owner!}/additional_ponds`) .set('Cookie', cookies.siteadmin!) .send({ value: 5 }) .expect(200); // A pond opted into the public API, and a write-scope PAT for it. const pond = await api() .post('/api/v1/ponds') .set('Cookie', cookies.owner!) .send({ name: `CSRF Pond ${suffix}` }) .expect(201); pondId = pond.body.id; pondSlug = pond.body.slug; await app.get(InstanceSettingsService).set('api.enabled', true, ids.siteadmin!); await api() .patch(`/api/v1/ponds/${pondId}`) .set('Cookie', cookies.owner!) .send({ apiEnabled: true }) .expect(200); const pat = await api() .post('/api/v1/users/me/api-tokens') .set('Cookie', cookies.owner!) .send({ name: 'csrf-write', scope: 'write' }) .expect(201); patToken = pat.body.token; }); afterAll(async () => { const all = Object.values(ids); await prisma.instanceSetting.deleteMany({ where: { key: 'api.enabled' } }); await prisma.quotaOverride.deleteMany({ where: { subjectId: { in: all } } }); await prisma.auditEntry.deleteMany({ where: { actorId: { in: all } } }); await prisma.apiToken.deleteMany({ where: { userId: { in: all } } }); const ponds = await prisma.pond.findMany({ where: { ownerId: { in: all } }, select: { id: true }, }); const pondIds = ponds.map((p) => p.id); await prisma.pageVersion.deleteMany({ where: { page: { pondId: { in: pondIds } } } }); await prisma.page.deleteMany({ where: { pondId: { in: pondIds } } }); await prisma.roleGrant.deleteMany({ where: { pondId: { in: pondIds } } }); await prisma.pondUsage.deleteMany({ where: { pondId: { in: pondIds } } }); await prisma.pond.deleteMany({ where: { id: { in: pondIds } } }); await prisma.session.deleteMany({ where: { userId: { in: all } } }); await prisma.userIdentity.deleteMany({ where: { userId: { in: all } } }); await prisma.rateLimit.deleteMany({}); await prisma.user.deleteMany({ where: { id: { in: all } } }); await prisma.$disconnect(); await app.close(); }); it('rejects a cookie mutation that sends neither Origin nor Referer', async () => { const res = await api() .patch(`/api/v1/ponds/${pondId}`) .set('Cookie', cookies.owner!) .set(SUPPRESS_ORIGIN_HEADER, '1') .send({ name: `CSRF Pond ${suffix}` }) .expect(403); expect(res.body.code).toBe('csrf_origin_mismatch'); }); it('rejects a cookie mutation from a mismatching origin (kept behaviour)', async () => { const res = await api() .patch(`/api/v1/ponds/${pondId}`) .set('Cookie', cookies.owner!) .set('Origin', 'https://evil.example') .send({ name: `CSRF Pond ${suffix}` }) .expect(403); expect(res.body.code).toBe('csrf_origin_mismatch'); }); it('rejects a cookie mutation with an unparsable Origin instead of erroring', async () => { const res = await api() .patch(`/api/v1/ponds/${pondId}`) .set('Cookie', cookies.owner!) .set('Origin', 'not a url') .send({ name: `CSRF Pond ${suffix}` }) .expect(403); expect(res.body.code).toBe('csrf_origin_mismatch'); }); it('accepts a cookie mutation from the matching origin', async () => { await api() .patch(`/api/v1/ponds/${pondId}`) .set('Cookie', cookies.owner!) .send({ name: `CSRF Pond ${suffix}` }) .expect(200); }); it('leaves cookie reads untouched — the check binds to mutations', async () => { await api() .get('/api/v1/auth/me') .set('Cookie', cookies.owner!) .set(SUPPRESS_ORIGIN_HEADER, '1') .expect(200); }); it('lets a PAT mutation through without either header — no cookie, no check', async () => { await api() .post(`/api/public/v1/ponds/${pondSlug}/pages`) .set('Authorization', `Bearer ${patToken}`) .set(SUPPRESS_ORIGIN_HEADER, '1') .send({ title: `CSRF PAT page ${suffix}` }) .expect(201); }); it('enforces the check when a request carries both cookie and bearer token', async () => { // Cookie-authenticated requests never benefit from the bearer exception. const res = await api() .patch(`/api/v1/ponds/${pondId}`) .set('Cookie', cookies.owner!) .set('Authorization', `Bearer ${patToken}`) .set(SUPPRESS_ORIGIN_HEADER, '1') .send({ name: `CSRF Pond ${suffix}` }) .expect(403); expect(res.body.code).toBe('csrf_origin_mismatch'); }); });