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
77 lines
3.4 KiB
TypeScript
77 lines
3.4 KiB
TypeScript
import { mkdtempSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
|
|
import { INestApplication } from '@nestjs/common';
|
|
import { Test, TestingModuleBuilder } from '@nestjs/testing';
|
|
import type { NestExpressApplication } from '@nestjs/platform-express';
|
|
import cookieParser from 'cookie-parser';
|
|
import type { NextFunction, Request, Response } from 'express';
|
|
|
|
import { AppModule } from '../app.module';
|
|
import { AppConfig } from '../config/app-config.service';
|
|
|
|
/**
|
|
* Suppresses the test harness's automatic `Origin` header (below) for a
|
|
* single request — the way the CSRF spec simulates a client that sends
|
|
* neither `Origin` nor `Referer` (issue #189).
|
|
*/
|
|
export const SUPPRESS_ORIGIN_HEADER = 'x-test-suppress-origin';
|
|
|
|
/**
|
|
* Boots the full application for e2e tests, mirroring main.ts middleware.
|
|
* Requires TEST_DATABASE_URL; DATABASE_URL is pointed at it so the app
|
|
* under test uses the test database. `customize` can override providers
|
|
* (e.g. inject a fake converter for the conversion queue tests, issue #62).
|
|
*/
|
|
export async function createTestApp(
|
|
customize?: (builder: TestingModuleBuilder) => TestingModuleBuilder,
|
|
): Promise<INestApplication> {
|
|
process.env.NODE_ENV = 'test';
|
|
if (process.env.TEST_DATABASE_URL) {
|
|
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
|
}
|
|
process.env.DATABASE_URL ??= 'postgresql://nobody:nothing@127.0.0.1:59999/absent';
|
|
// Fresh scratch directory per test file so upload tests never touch the
|
|
// repository or collide with each other.
|
|
process.env.UPLOADS_DIR ??= mkdtempSync(join(tmpdir(), 'dorfteich-uploads-'));
|
|
process.env.PLUGINS_DIR ??= mkdtempSync(join(tmpdir(), 'dorfteich-plugins-'));
|
|
|
|
const base = Test.createTestingModule({ imports: [AppModule] });
|
|
const moduleRef = await (customize ? customize(base) : base).compile();
|
|
const app = moduleRef.createNestApplication<NestExpressApplication>();
|
|
app.use(cookieParser());
|
|
// Browsers always send `Origin` on mutations, and since #189 the CSRF
|
|
// check fails closed without it. supertest simulates a browser page of
|
|
// this instance, so the harness injects the matching header — absence is
|
|
// simulated explicitly via SUPPRESS_ORIGIN_HEADER, never by accident.
|
|
const expectedOrigin = new URL(moduleRef.get(AppConfig).env.APP_BASE_URL).origin;
|
|
app.use((req: Request, _res: Response, next: NextFunction) => {
|
|
if (SUPPRESS_ORIGIN_HEADER in req.headers) {
|
|
delete req.headers[SUPPRESS_ORIGIN_HEADER];
|
|
delete req.headers.origin;
|
|
delete req.headers.referer;
|
|
} else {
|
|
req.headers.origin ??= expectedOrigin;
|
|
}
|
|
next();
|
|
});
|
|
// Mirrors main.ts: base64 Yjs page state needs more than Express's 100kb default.
|
|
app.useBodyParser('json', { limit: '8mb' });
|
|
// Mirrors main.ts: the public API (issue #104) declares its full path.
|
|
app.setGlobalPrefix('api/v1', {
|
|
exclude: ['api/public/v1', 'api/public/v1/{*path}', 'api/mcp'],
|
|
});
|
|
await app.init();
|
|
return app;
|
|
}
|
|
|
|
/** Extracts the dt_session cookie pair ("name=value") from a response. */
|
|
export function sessionCookieOf(res: { headers: Record<string, unknown> }): string {
|
|
const header = res.headers['set-cookie'];
|
|
const cookies = Array.isArray(header) ? header : [header].filter(Boolean);
|
|
const session = (cookies as string[]).find((c) => c.startsWith('dt_session='));
|
|
if (!session) throw new Error('response carries no dt_session cookie');
|
|
return session.split(';')[0]!;
|
|
}
|