A cookie-carrying mutation without Origin and Referer (or with an unparsable one) is now rejected with 403 csrf_origin_mismatch instead of passing unchecked. The exception for non-browser clients stays structural: PAT/bearer requests carry no session cookie and never reach the check, and a request that does carry the cookie is always checked. The test harness injects the matching Origin (supertest simulates a browser page of this instance) with an explicit suppression header for the negative cases; the Playwright fixture contexts send the header on their manual seeding calls; release-qa.sh pins APP_BASE_URL and sends the matching Origin. Dedicated spec covers: missing headers 403, mismatch 403, unparsable 403, match passes, GETs untouched, PAT mutation without headers passes, cookie+bearer still checked. Refs #189 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
179 lines
6.7 KiB
TypeScript
179 lines
6.7 KiB
TypeScript
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<string, string> = {};
|
|
const cookies: Record<string, string> = {};
|
|
let pondId: string;
|
|
let pondSlug: string;
|
|
let patToken: string;
|
|
|
|
const api = () => request(app.getHttpServer());
|
|
|
|
async function makeUser(handle: string): Promise<void> {
|
|
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');
|
|
});
|
|
});
|