feat(security): add CSRF protection to private API endpoints

This adds a new endpoint /api/private/csrf/token which serves a CSRF-token that
is stored in the user's session. Following requests with POST, PUT, PATCH or DELETE
request methods, need to provide this token in the CSRF-Token header. Since this
is not possible to do via HTML forms or other cross-site effects, this prevents
cross-site attacks. The frontend loads the CSRF token on app initialization and
stores it in the redux. It keeps using the token for up to one hour and then
updates the stored token from the API endpoint again.

Signed-off-by: Erik Michelson <github@erik.michelson.eu>
This commit is contained in:
Erik Michelson
2026-01-25 23:46:26 +01:00
parent f0095cd8ec
commit 66d052d611
29 changed files with 514 additions and 72 deletions
+1
View File
@@ -24,6 +24,7 @@
"dependencies": {
"@azure/storage-blob": "12.29.1",
"@fastify/cookie": "11.0.2",
"@fastify/csrf-protection": "7.1.0",
"@fastify/multipart": "9.4.0",
"@fastify/secure-session": "8.3.0",
"@fastify/session": "11.1.1",
@@ -0,0 +1,26 @@
/*
* SPDX-FileCopyrightText: 2026 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Controller, Get, Res } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import type { FastifyReply } from 'fastify';
import { OpenApi } from '../../utils/decorators/openapi.decorator';
import { CsrfTokenDto } from '../../../dtos/csrf-token.dto';
@ApiTags('csrf')
@Controller('csrf')
export class CsrfController {
constructor() {}
@Get('token')
@OpenApi(200)
getToken(@Res({ passthrough: true }) res: FastifyReply): CsrfTokenDto {
const token = res.generateCsrf();
return CsrfTokenDto.create({
token,
});
}
}
@@ -0,0 +1,55 @@
/*
* SPDX-FileCopyrightText: 2026 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { CanActivate, ExecutionContext, Injectable, ForbiddenException } from '@nestjs/common';
import type { FastifyRequest, FastifyReply } from 'fastify';
const UNPROTECTED_METHODS = ['GET', 'HEAD', 'OPTIONS'];
@Injectable()
export class CsrfGuard implements CanActivate {
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<FastifyRequest>();
const reply = context.switchToHttp().getResponse<FastifyReply>();
// Ignore unprotected methods (GET, HEAD, OPTIONS)
const method = request.method.toUpperCase();
if (UNPROTECTED_METHODS.includes(method)) {
return true;
}
// Ignore non-private API requests
if (!request.url.startsWith('/api/private')) {
return true;
}
// Otherwise, check for CSRF-Token header and validate it
const token = request.headers['csrf-token'] as string | undefined;
if (!token) {
throw new ForbiddenException('CSRF token required');
}
const csrfProtection = request.server.csrfProtection;
if (!csrfProtection) {
throw new ForbiddenException('CSRF protection failed to load');
}
try {
const csrfProtectionPromise = new Promise<void>((resolve, reject) => {
csrfProtection(request, reply, (err?: Error) => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
await csrfProtectionPromise;
return true;
} catch {
throw new ForbiddenException('CSRF token invalid');
}
}
}
@@ -24,6 +24,7 @@ import { LdapController } from './auth/ldap/ldap.controller';
import { LocalController } from './auth/local/local.controller';
import { OidcController } from './auth/oidc/oidc.controller';
import { ConfigController } from './config/config.controller';
import { CsrfController } from './csrf/csrf.controller';
import { ExploreController } from './explore/explore.controller';
import { GroupsController } from './groups/groups.controller';
import { MeController } from './me/me.controller';
@@ -48,6 +49,7 @@ import { UsersController } from './users/users.controller';
controllers: [
ApiTokensController,
ConfigController,
CsrfController,
ExploreController,
GuestController,
MediaController,
+10 -1
View File
@@ -8,6 +8,7 @@ import { HttpAdapterHost } from '@nestjs/core';
import { NestFastifyApplication } from '@nestjs/platform-fastify';
import { WsAdapter } from '@nestjs/platform-ws';
import fastifyMultipart from '@fastify/multipart';
import fastifyCsrfProtection from '@fastify/csrf-protection';
import { AppConfig } from './config/app.config';
import { AuthConfig } from './config/auth.config';
@@ -66,6 +67,14 @@ export async function setupApp(
app.get(SessionService).getSessionStore(),
);
// Setup CSRF protection
await app.register(fastifyCsrfProtection, {
cookieKey: 'hedgedoc-csrf',
sessionPlugin: '@fastify/session',
getToken: (req) => req.headers['csrf-token'] as string | undefined,
});
logger.log('CSRF protection enabled', 'AppBootstrap');
// Enable web security aspects
app.enableCors({
origin: appConfig.rendererBaseUrl,
@@ -73,7 +82,7 @@ export async function setupApp(
logger.log(`Enabling CORS for '${appConfig.rendererBaseUrl}'`, 'AppBootstrap');
// TODO Add rate limiting (#442)
// TODO Add CSP (#1309)
// TODO Add common security headers and CSRF (#201)
// TODO Add common security headers (#201)
// Setup class-validator for incoming API request data
app.useGlobalPipes(setupValidationPipe(logger));
+6 -1
View File
@@ -5,7 +5,7 @@
*/
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { APP_INTERCEPTOR, APP_PIPE, RouterModule, Routes } from '@nestjs/core';
import { APP_GUARD, APP_INTERCEPTOR, APP_PIPE, RouterModule, Routes } from '@nestjs/core';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { ScheduleModule } from '@nestjs/schedule';
import { KnexModule } from 'nest-knexjs';
@@ -14,6 +14,7 @@ import { join } from 'node:path';
import { AliasModule } from './alias/alias.module';
import { ApiTokenModule } from './api-token/api-token.module';
import { CsrfGuard } from './api/private/csrf/csrf.guard';
import { PrivateApiModule } from './api/private/private-api.module';
import { PublicApiModule } from './api/public/public-api.module';
import { AuthModule } from './auth/auth.module';
@@ -135,6 +136,10 @@ const routes: Routes = [
provide: APP_INTERCEPTOR,
useClass: ZodSerializerInterceptor,
},
{
provide: APP_GUARD,
useClass: CsrfGuard,
},
],
})
export class AppModule {}
+9
View File
@@ -0,0 +1,9 @@
/*
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { CsrfTokenSchema } from '@hedgedoc/commons';
import { createZodDto } from 'nestjs-zod';
export class CsrfTokenDto extends createZodDto(CsrfTokenSchema) {}
@@ -5,7 +5,7 @@
*/
import { PRIVATE_API_PREFIX } from '../../src/app.module';
import { noteAlias1, password3, TestSetup, TestSetupBuilder, username3 } from '../test-setup';
import { setupAgent } from './utils/setup-agent';
import { extendAgentWithCsrf, setupAgent } from './utils/setup-agent';
import { AliasCreateInterface, AliasUpdateInterface } from '@hedgedoc/commons';
import request from 'supertest';
import { SpecialGroup } from '@hedgedoc/database';
@@ -29,7 +29,8 @@ describe('Alias', () => {
[agentNotLoggedIn, agentGuestUser, agentUser1, agentUser2] = await setupAgent(testSetup);
agentUser3 = request.agent(testSetup.app.getHttpServer());
const originalAgentUser3 = request.agent(testSetup.app.getHttpServer());
agentUser3 = await extendAgentWithCsrf(originalAgentUser3);
await agentUser3
.post(`${PRIVATE_API_PREFIX}/auth/local/login`)
.send({ username: username3, password: password3 })
@@ -1,14 +1,14 @@
import { PRIVATE_API_PREFIX } from '../../src/app.module';
import { dateTimeToISOString, getCurrentDateTime } from '../../src/utils/datetime';
import { TestSetup, TestSetupBuilder } from '../test-setup';
import { setupAgent } from './utils/setup-agent';
/*
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2026 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { ApiTokenWithSecretInterface } from '@hedgedoc/commons';
import request from 'supertest';
import { ApiTokenWithSecretInterface } from '@hedgedoc/commons';
import { dateTimeToISOString, getCurrentDateTime } from '../../src/utils/datetime';
import { PRIVATE_API_PREFIX } from '../../src/app.module';
import { setupAgent } from './utils/setup-agent';
import { TestSetup, TestSetupBuilder } from '../test-setup';
describe('Tokens', () => {
let testSetup: TestSetup;
@@ -1,10 +1,3 @@
import { PRIVATE_API_PREFIX } from '../../src/app.module';
import { LoginDto } from '../../src/dtos/login.dto';
import { RegisterDto } from '../../src/dtos/register.dto';
import { UpdatePasswordDto } from '../../src/dtos/update-password.dto';
import { NotInDBError } from '../../src/errors/errors';
import { checkPassword } from '../../src/utils/password';
import { TestSetup, TestSetupBuilder } from '../test-setup';
/*
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
*
@@ -14,8 +7,17 @@ import { TestSetup, TestSetupBuilder } from '../test-setup';
@typescript-eslint/no-unsafe-assignment,
@typescript-eslint/no-unsafe-member-access
*/
import { AuthProviderType, FieldNameIdentity, FieldNameUser } from '@hedgedoc/database';
import request from 'supertest';
import { AuthProviderType, FieldNameIdentity, FieldNameUser } from '@hedgedoc/database';
import { checkPassword } from '../../src/utils/password';
import { PRIVATE_API_PREFIX } from '../../src/app.module';
import { LoginDto } from '../../src/dtos/login.dto';
import { NotInDBError } from '../../src/errors/errors';
import { RegisterDto } from '../../src/dtos/register.dto';
import { TestSetup, TestSetupBuilder } from '../test-setup';
import { UpdatePasswordDto } from '../../src/dtos/update-password.dto';
import { extendAgentWithCsrf } from './utils/setup-agent';
import { extractCookieValue } from './utils/cookie';
describe('Auth', () => {
let testSetup: TestSetup;
@@ -44,7 +46,9 @@ describe('Auth', () => {
password: password,
username: username,
};
await request(testSetup.app.getHttpServer())
const originalAgent = request.agent(testSetup.app.getHttpServer());
const agent = await extendAgentWithCsrf(originalAgent);
await agent
.post(`${PRIVATE_API_PREFIX}/auth/local`)
.set('Content-Type', 'application/json')
.send(JSON.stringify(registrationDto))
@@ -77,7 +81,9 @@ describe('Auth', () => {
password: password,
username: conflictingUserName,
};
await request(testSetup.app.getHttpServer())
const originalAgent = request.agent(testSetup.app.getHttpServer());
const agent = await extendAgentWithCsrf(originalAgent);
await agent
.post(`${PRIVATE_API_PREFIX}/auth/local`)
.set('Content-Type', 'application/json')
.send(JSON.stringify(registrationDto))
@@ -91,7 +97,9 @@ describe('Auth', () => {
password: password,
username: username,
};
await request(testSetup.app.getHttpServer())
const originalAgent = request.agent(testSetup.app.getHttpServer());
const agent = await extendAgentWithCsrf(originalAgent);
await agent
.post(`${PRIVATE_API_PREFIX}/auth/local`)
.set('Content-Type', 'application/json')
.send(JSON.stringify(registrationDto))
@@ -104,7 +112,9 @@ describe('Auth', () => {
password: 'test1234',
username: username,
};
const response = await request(testSetup.app.getHttpServer())
const originalAgent = request.agent(testSetup.app.getHttpServer());
const agent = await extendAgentWithCsrf(originalAgent);
const response = await agent
.post(`${PRIVATE_API_PREFIX}/auth/local`)
.set('Content-Type', 'application/json')
.send(JSON.stringify(registrationDto))
@@ -118,13 +128,18 @@ describe('Auth', () => {
});
describe('With an already existing user', () => {
let loggedInAgent: request.SuperAgentTest;
let cookie: string;
beforeEach(async () => {
const registrationDto: RegisterDto = {
displayName: displayName,
password: password,
username: username,
};
await request(testSetup.app.getHttpServer())
const originalAgent = request.agent(testSetup.app.getHttpServer());
const agent = await extendAgentWithCsrf(originalAgent);
await agent
.post(`${PRIVATE_API_PREFIX}/auth/local`)
.set('Content-Type', 'application/json')
.send(JSON.stringify(registrationDto))
@@ -132,13 +147,14 @@ describe('Auth', () => {
});
describe(`PUT ${PRIVATE_API_PREFIX}/auth/local`, () => {
const newPassword = 'new_password';
let cookie = '';
beforeEach(async () => {
const loginDto: LoginDto = {
password: password,
username: username,
};
const response = await request(testSetup.app.getHttpServer())
const originalAgent = request.agent(testSetup.app.getHttpServer());
loggedInAgent = await extendAgentWithCsrf(originalAgent);
const response = await loggedInAgent
.post(`${PRIVATE_API_PREFIX}/auth/local/login`)
.set('Content-Type', 'application/json')
.send(JSON.stringify(loginDto))
@@ -151,10 +167,10 @@ describe('Auth', () => {
currentPassword: password,
newPassword: newPassword,
};
await request(testSetup.app.getHttpServer())
await loggedInAgent
.put(`${PRIVATE_API_PREFIX}/auth/local`)
.set('Content-Type', 'application/json')
.set('Cookie', cookie)
.set('Cookie', extractCookieValue(cookie))
.send(JSON.stringify(changePasswordDto))
.expect(200);
// Successfully login with new password
@@ -162,7 +178,7 @@ describe('Auth', () => {
password: newPassword,
username: username,
};
const response = await request(testSetup.app.getHttpServer())
const response = await loggedInAgent
.post(`${PRIVATE_API_PREFIX}/auth/local/login`)
.set('Content-Type', 'application/json')
.send(JSON.stringify(loginDto))
@@ -173,10 +189,10 @@ describe('Auth', () => {
currentPassword: newPassword,
newPassword: password,
};
await request(testSetup.app.getHttpServer())
await loggedInAgent
.put(`${PRIVATE_API_PREFIX}/auth/local`)
.set('Content-Type', 'application/json')
.set('Cookie', cookie)
.set('Cookie', extractCookieValue(cookie))
.send(JSON.stringify(changePasswordBackDto))
.expect(200);
});
@@ -188,10 +204,10 @@ describe('Auth', () => {
currentPassword: password,
newPassword: newPassword,
};
await request(testSetup.app.getHttpServer())
await loggedInAgent
.put(`${PRIVATE_API_PREFIX}/auth/local`)
.set('Content-Type', 'application/json')
.set('Cookie', cookie)
.set('Cookie', extractCookieValue(cookie))
.send(JSON.stringify(changePasswordDto))
.expect(403);
// enable login again
@@ -201,7 +217,7 @@ describe('Auth', () => {
password: newPassword,
username: username,
};
await request(testSetup.app.getHttpServer())
await loggedInAgent
.post(`${PRIVATE_API_PREFIX}/auth/local/login`)
.set('Content-Type', 'application/json')
.send(JSON.stringify(loginNewPasswordDto))
@@ -211,7 +227,7 @@ describe('Auth', () => {
password: password,
username: username,
};
await request(testSetup.app.getHttpServer())
await loggedInAgent
.post(`${PRIVATE_API_PREFIX}/auth/local/login`)
.set('Content-Type', 'application/json')
.send(JSON.stringify(loginOldPasswordDto))
@@ -223,10 +239,10 @@ describe('Auth', () => {
currentPassword: 'wrong_password',
newPassword: newPassword,
};
await request(testSetup.app.getHttpServer())
await loggedInAgent
.put(`${PRIVATE_API_PREFIX}/auth/local`)
.set('Content-Type', 'application/json')
.set('Cookie', cookie)
.set('Cookie', extractCookieValue(cookie))
.send(JSON.stringify(changePasswordDto))
.expect(401);
// old password still does work for login
@@ -234,7 +250,7 @@ describe('Auth', () => {
password: password,
username: username,
};
await request(testSetup.app.getHttpServer())
loggedInAgent
.post(`${PRIVATE_API_PREFIX}/auth/local/login`)
.set('Content-Type', 'application/json')
.send(JSON.stringify(loginOldPasswordDto))
@@ -246,10 +262,10 @@ describe('Auth', () => {
currentPassword: 'wrong',
newPassword: newPassword,
};
await request(testSetup.app.getHttpServer())
await loggedInAgent
.put(`${PRIVATE_API_PREFIX}/auth/local`)
.set('Content-Type', 'application/json')
.set('Cookie', cookie)
.set('Cookie', extractCookieValue(cookie))
.send(JSON.stringify(changePasswordDtoOldPwTooShort))
.expect(400);
@@ -257,10 +273,10 @@ describe('Auth', () => {
currentPassword: password,
newPassword: 'new',
};
await request(testSetup.app.getHttpServer())
await loggedInAgent
.put(`${PRIVATE_API_PREFIX}/auth/local`)
.set('Content-Type', 'application/json')
.set('Cookie', cookie)
.set('Cookie', extractCookieValue(cookie))
.send(JSON.stringify(changePasswordDtoNewPwTooShort))
.expect(400);
@@ -269,7 +285,7 @@ describe('Auth', () => {
password: password,
username: username,
};
await request(testSetup.app.getHttpServer())
await loggedInAgent
.post(`${PRIVATE_API_PREFIX}/auth/local/login`)
.set('Content-Type', 'application/json')
.send(JSON.stringify(loginOldPasswordDto))
@@ -285,7 +301,9 @@ describe('Auth', () => {
password: password,
username: username,
};
await request(testSetup.app.getHttpServer())
const originalAgent = request.agent(testSetup.app.getHttpServer());
const agent = await extendAgentWithCsrf(originalAgent);
await agent
.post(`${PRIVATE_API_PREFIX}/auth/local/login`)
.set('Content-Type', 'application/json')
.send(JSON.stringify(loginDto))
@@ -300,15 +318,17 @@ describe('Auth', () => {
password: password,
username: username,
};
const response = await request(testSetup.app.getHttpServer())
const originalAgent = request.agent(testSetup.app.getHttpServer());
const agent = await extendAgentWithCsrf(originalAgent);
const response = await agent
.post(`${PRIVATE_API_PREFIX}/auth/local/login`)
.set('Content-Type', 'application/json')
.send(JSON.stringify(loginDto))
.expect(201);
const cookie = response.get('Set-Cookie')[0];
await request(testSetup.app.getHttpServer())
await agent
.delete(`${PRIVATE_API_PREFIX}/auth/logout`)
.set('Cookie', cookie)
.set('Cookie', extractCookieValue(cookie))
.expect(200);
});
});
@@ -0,0 +1,72 @@
/*
* SPDX-FileCopyrightText: 2026 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { PRIVATE_API_PREFIX } from '../../src/app.module';
import { password1, TestSetup, TestSetupBuilder, username1 } from '../test-setup';
import { setupAgent } from './utils/setup-agent';
import request from 'supertest';
describe('CSRF Protection', () => {
let testSetup: TestSetup;
let agentUser1: request.SuperAgentTest;
let agentUser1WithoutCsrf: request.SuperAgentTest;
beforeEach(async () => {
testSetup = await TestSetupBuilder.create().withUsers().build();
await testSetup.init();
[, , agentUser1] = await setupAgent(testSetup);
// Create a separate agent without CSRF token for testing rejection
agentUser1WithoutCsrf = request.agent(testSetup.app.getHttpServer());
});
afterEach(async () => {
await testSetup.cleanup();
});
describe(`GET ${PRIVATE_API_PREFIX}/csrf/token`, () => {
it('returns a CSRF token', async () => {
const response = await agentUser1
.get(`${PRIVATE_API_PREFIX}/csrf/token`)
.expect('Content-Type', /json/)
.expect(200);
expect(response.body.token).toBeDefined();
expect(typeof response.body.token).toBe('string');
expect(response.body.token.length).toBeGreaterThan(0);
});
});
describe('CSRF token validation', () => {
it('allows state-changing requests with valid CSRF token', async () => {
await agentUser1
.post(`${PRIVATE_API_PREFIX}/notes`)
.set('Content-Type', 'text/markdown')
.send('Test note content')
.expect(201);
});
it('rejects state-changing requests without CSRF token', async () => {
await agentUser1WithoutCsrf
.post(`${PRIVATE_API_PREFIX}/auth/local/login`)
.send({ username: username1, password: password1 })
.expect(403);
});
it('rejects state-changing requests with invalid CSRF token', async () => {
await agentUser1WithoutCsrf
.post(`${PRIVATE_API_PREFIX}/auth/local/login`)
.set('csrf-token', 'invalid-token')
.send({ username: username1, password: password1 })
.expect(403);
});
it('allows GET requests without CSRF token', async () => {
await agentUser1.get(`${PRIVATE_API_PREFIX}/me`).expect(200);
});
});
});
@@ -1,3 +1,10 @@
/*
* SPDX-FileCopyrightText: 2026 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { SortMode } from '@hedgedoc/commons';
import { NoteType } from '@hedgedoc/database';
import { PRIVATE_API_PREFIX } from '../../src/app.module';
import {
noteAlias1,
@@ -8,14 +15,7 @@ import {
TestSetupBuilder,
username3,
} from '../test-setup';
import { setupAgent } from './utils/setup-agent';
/*
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { SortMode } from '@hedgedoc/commons';
import { NoteType } from '@hedgedoc/database';
import { extendAgentWithCsrf, setupAgent } from './utils/setup-agent';
import request from 'supertest';
describe('Explore', () => {
@@ -33,7 +33,8 @@ describe('Explore', () => {
await testSetup.init();
[, , agentUser1, agentUser2] = await setupAgent(testSetup);
agentUser3 = request.agent(testSetup.app.getHttpServer());
const originalAgentUser3 = request.agent(testSetup.app.getHttpServer());
agentUser3 = await extendAgentWithCsrf(originalAgentUser3);
await agentUser3
.post(`${PRIVATE_API_PREFIX}/auth/local/login`)
.send({ username: username3, password: password3 })
@@ -1,3 +1,9 @@
/*
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import request from 'supertest';
import { PRIVATE_API_PREFIX } from '../../src/app.module';
import {
displayName1,
@@ -7,12 +13,7 @@ import {
username1,
username2,
} from '../test-setup';
/*
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import request from 'supertest';
import { extendAgentWithCsrf } from './utils/setup-agent';
describe('Users', () => {
let testSetup: TestSetup;
@@ -21,7 +22,8 @@ describe('Users', () => {
beforeEach(async () => {
testSetup = await TestSetupBuilder.create().withUsers().build();
await testSetup.init();
agent = request.agent(testSetup.app.getHttpServer());
const originalAgent = request.agent(testSetup.app.getHttpServer());
agent = await extendAgentWithCsrf(originalAgent);
});
afterEach(async () => {
+17
View File
@@ -0,0 +1,17 @@
/*
* SPDX-FileCopyrightText: 2026 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* Extracts the cookie name=value pair from a Set-Cookie header.
* Strips out attributes like HttpOnly, Secure, Path, etc.
* Header format: Set-Cookie format: "name=value; HttpOnly; Path=/; ..."
*
* @param setCookieHeader The full Set-Cookie header value
* @returns Just the name=value part of the cookie
*/
export function extractCookieValue(setCookieHeader: string): string {
return setCookieHeader.split(';')[0];
}
+36 -6
View File
@@ -1,25 +1,55 @@
import { PRIVATE_API_PREFIX } from '../../../src/app.module';
import { password1, password2, TestSetup, username1, username2 } from '../../test-setup';
/*
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import request from 'supertest';
import { PRIVATE_API_PREFIX } from '../../../src/app.module';
import { password1, password2, TestSetup, username1, username2 } from '../../test-setup';
/**
* Extends a test agent to automatically include CSRF tokens in state-changing requests.
* This is done by overriding the relevant HTTP methods of the agent.
*
* @param agent The agent to extend.
* @returns The extended agent.
*/
export async function extendAgentWithCsrf(
agent: request.SuperAgentTest,
): Promise<request.SuperAgentTest> {
const csrfTokenResponse = await agent.get(`${PRIVATE_API_PREFIX}/csrf/token`).expect(200);
const csrfToken = csrfTokenResponse.body.token;
const originalPost = agent.post.bind(agent);
const originalPut = agent.put.bind(agent);
const originalPatch = agent.patch.bind(agent);
const originalDelete = agent.delete.bind(agent);
agent.post = (url: string) => originalPost(url).set('csrf-token', csrfToken);
agent.put = (url: string) => originalPut(url).set('csrf-token', csrfToken);
agent.patch = (url: string) => originalPatch(url).set('csrf-token', csrfToken);
agent.delete = (url: string) => originalDelete(url).set('csrf-token', csrfToken);
return agent;
}
export async function setupAgent(testSetup: TestSetup) {
const agentNotLoggedIn = request.agent(testSetup.app.getHttpServer());
const originalAgentNotLoggedIn = request.agent(testSetup.app.getHttpServer());
const agentNotLoggedIn = await extendAgentWithCsrf(originalAgentNotLoggedIn);
const agentGuestUser = request.agent(testSetup.app.getHttpServer());
const originalAgentGuestUser = request.agent(testSetup.app.getHttpServer());
const agentGuestUser = await extendAgentWithCsrf(originalAgentGuestUser);
await agentGuestUser.post(`${PRIVATE_API_PREFIX}/auth/guest/register`).send().expect(201);
const agentUser1 = request.agent(testSetup.app.getHttpServer());
const originalAgentUser1 = request.agent(testSetup.app.getHttpServer());
const agentUser1 = await extendAgentWithCsrf(originalAgentUser1);
await agentUser1
.post(`${PRIVATE_API_PREFIX}/auth/local/login`)
.send({ username: username1, password: password1 })
.expect(201);
const agentUser2 = request.agent(testSetup.app.getHttpServer());
const originalAgentUser2 = request.agent(testSetup.app.getHttpServer());
const agentUser2 = await extendAgentWithCsrf(originalAgentUser2);
await agentUser2
.post(`${PRIVATE_API_PREFIX}/auth/local/login`)
.send({ username: username2, password: password2 })
+6 -1
View File
@@ -3,6 +3,7 @@ import { AliasModule } from '../src/alias/alias.module';
import { AliasService } from '../src/alias/alias.service';
import { ApiTokenModule } from '../src/api-token/api-token.module';
import { ApiTokenService } from '../src/api-token/api-token.service';
import { CsrfGuard } from '../src/api/private/csrf/csrf.guard';
import { PrivateApiModule } from '../src/api/private/private-api.module';
import { PublicApiModule } from '../src/api/public/public-api.module';
import { ApiTokenGuard } from '../src/api/utils/guards/api-token.guard';
@@ -75,7 +76,7 @@ import { getCurrentDateTime } from '../src/utils/datetime';
*/
import { SpecialGroup } from '@hedgedoc/database';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { APP_INTERCEPTOR, APP_PIPE, RouterModule, Routes } from '@nestjs/core';
import { APP_GUARD, APP_INTERCEPTOR, APP_PIPE, RouterModule, Routes } from '@nestjs/core';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
import { Test, TestingModule, TestingModuleBuilder } from '@nestjs/testing';
@@ -301,6 +302,10 @@ export class TestSetupBuilder {
provide: APP_INTERCEPTOR,
useClass: ZodSerializerInterceptor,
},
{
provide: APP_GUARD,
useClass: CsrfGuard,
},
],
});
return testSetupBuilder;
+13
View File
@@ -0,0 +1,13 @@
/*
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { z } from 'zod'
export const CsrfTokenSchema = z.object({
token: z.string().describe('The CSRF token to use for state-changing requests'),
})
export type CsrfTokenInterface = z.infer<typeof CsrfTokenSchema>
+7
View File
@@ -0,0 +1,7 @@
/*
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
export * from './csrf-token.dto.js'
+1
View File
@@ -6,6 +6,7 @@
export * from './alias/index.js'
export * from './api-token/index.js'
export * from './auth/index.js'
export * from './csrf/index.js'
export * from './edit/index.js'
export * from './frontend-config/index.js'
export * from './group/index.js'
@@ -7,6 +7,7 @@ import { ApiError } from '../api-error'
import type { ApiErrorResponse } from '../api-error-response'
import { ApiResponse } from '../api-response'
import { defaultConfig, defaultHeaders } from '../default-config'
import { getCsrfToken } from '../../../redux/csrf-token/methods'
import deepmerge from 'deepmerge'
import { baseUrlFromEnvExtractor } from '../../../utils/base-url-from-env-extractor'
@@ -56,6 +57,12 @@ export abstract class ApiRequestBuilder<ResponseType> {
}
protected async sendRequestAndVerifyResponse(httpMethod: RequestInit['method']): Promise<ApiResponse<ResponseType>> {
// Add CSRF token for requests except GET, HEAD, OPTIONS (only in browser context)
if (typeof window !== 'undefined' && httpMethod && !['GET', 'HEAD', 'OPTIONS'].includes(httpMethod.toUpperCase())) {
const csrfToken = await getCsrfToken()
this.customRequestHeaders.set('csrf-token', csrfToken)
}
const response = await fetch(this.targetUrl, {
...this.customRequestOptions,
method: httpMethod,
@@ -4,6 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defaultConfig } from '../../default-config'
import { clearCsrfToken } from '../../../../redux/csrf-token/methods'
import { Mock } from 'ts-mockery'
/**
@@ -20,13 +21,34 @@ export const expectFetch = (
expectedOptions: RequestInit,
responseBody?: unknown
): void => {
clearCsrfToken()
global.fetch = jest.fn((fetchUrl: RequestInfo | URL, fetchOptions?: RequestInit): Promise<Response> => {
// Handle CSRF token requests
if (typeof fetchUrl === 'string' && fetchUrl.endsWith('/api/private/csrf/token')) {
return Promise.resolve(
Mock.of<Response>({
ok: true,
status: 200,
statusText: 'OK',
json: jest.fn(() => Promise.resolve({ token: 'mock-csrf-token' }))
})
)
}
expect(fetchUrl).toEqual(expectedUrl)
// Merge expected headers with CSRF token for non-GET requests
const expectedHeaders = expectedOptions.headers ? new Headers(expectedOptions.headers) : new Headers()
if (expectedOptions.method && expectedOptions.method !== 'GET' && expectedOptions.method !== 'HEAD') {
expectedHeaders.set('csrf-token', 'mock-csrf-token')
}
expect(fetchOptions).toStrictEqual({
...defaultConfig,
body: undefined,
headers: new Headers(),
...expectedOptions
...expectedOptions,
headers: expectedHeaders
})
return Promise.resolve(
Mock.of<Response>({
@@ -10,6 +10,7 @@ import { setUpI18n } from './setupI18n'
import { loadFromLocalStorage } from '../../../redux/editor-config/methods'
import { fetchAndSetUser } from '../../login-page/utils/fetch-and-set-user'
import { loginOrRegisterGuest } from './login-or-register-guest'
import { refreshCsrfToken } from '../../../redux/csrf-token/methods'
const logger = new Logger('Application Loader')
@@ -54,6 +55,10 @@ const fetchUserInformation = async (): Promise<void> => {
*/
export const createSetUpTaskList = (): InitTask[] => {
return [
{
name: 'Load CSRF token',
task: refreshCsrfToken
},
{
name: 'Load dark mode',
task: loadDarkMode
@@ -0,0 +1,11 @@
/*
* SPDX-FileCopyrightText: 2026 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { CsrfTokenState } from './types'
export const initialState: CsrfTokenState = {
token: null,
lastUpdatedAt: 0
}
+59
View File
@@ -0,0 +1,59 @@
/*
* SPDX-FileCopyrightText: 2026 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { store } from '..'
import { csrfTokenActionsCreator } from './slice'
import type { CsrfTokenInterface } from '@hedgedoc/commons'
const MAX_CSRF_TOKEN_LIFETIME = 3600000 // 1 hour in ms
/**
* Gets the current CSRF token from Redux store or fetches a new one if none available or older than an hour
*
* @returns The current CSRF token
* @throws Error if fetching the CSRF token fails
*/
export async function getCsrfToken(): Promise<string> {
if (typeof window === 'undefined') {
throw new Error('CSRF token cannot be fetched during SSR')
}
let currentToken = store.getState().csrfToken.token
const lastUpdatedAt = store.getState().csrfToken.lastUpdatedAt
if (currentToken === null || lastUpdatedAt < Date.now() - MAX_CSRF_TOKEN_LIFETIME) {
await refreshCsrfToken()
currentToken = store.getState().csrfToken.token
if (currentToken === null) {
throw new Error('Failed to fetch CSRF token')
}
}
return currentToken
}
/**
* Refreshes the CSRF token by fetching a new one and storing it in Redux.
* Note: This function uses a direct fetch call instead of ApiRequestBuilder
* to avoid a circular dependency (ApiRequestBuilder imports getCsrfToken).
*/
export async function refreshCsrfToken(): Promise<void> {
if (typeof window === 'undefined') {
return
}
const response = await fetch('/api/private/csrf/token', {
method: 'GET',
credentials: 'same-origin'
})
if (!response.ok) {
throw new Error(`Failed to fetch CSRF token: ${response.status}`)
}
const data = (await response.json()) as CsrfTokenInterface
store.dispatch(csrfTokenActionsCreator.setCsrfToken(data.token))
}
/**
* Clears the CSRF token from Redux store
*/
export function clearCsrfToken(): void {
store.dispatch(csrfTokenActionsCreator.clearCsrfToken())
}
+26
View File
@@ -0,0 +1,26 @@
/*
* SPDX-FileCopyrightText: 2026 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { PayloadAction } from '@reduxjs/toolkit'
import { createSlice } from '@reduxjs/toolkit'
import { initialState } from './initial-state'
const csrfTokenSlice = createSlice({
name: 'csrfToken',
initialState,
reducers: {
setCsrfToken: (state, action: PayloadAction<string>) => {
state.token = action.payload
state.lastUpdatedAt = Date.now()
},
clearCsrfToken: (state) => {
state.token = null
state.lastUpdatedAt = 0
}
}
})
export const csrfTokenActionsCreator = csrfTokenSlice.actions
export const csrfTokenReducer = csrfTokenSlice.reducer
+10
View File
@@ -0,0 +1,10 @@
/*
* SPDX-FileCopyrightText: 2026 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
export interface CsrfTokenState {
token: string | null
lastUpdatedAt: number
}
+3 -1
View File
@@ -13,6 +13,7 @@ import { realtimeStatusReducer } from './realtime/slice'
import { noteDetailsReducer } from './note-details/slice'
import { printModeReducer } from './print-mode/slice'
import { pinnedNotesReducer } from './pinned-notes/slice'
import { csrfTokenReducer } from './csrf-token/slice'
export const store = configureStore({
reducer: {
@@ -23,7 +24,8 @@ export const store = configureStore({
realtimeStatus: realtimeStatusReducer,
noteDetails: noteDetailsReducer,
printMode: printModeReducer,
pinnedNotes: pinnedNotesReducer
pinnedNotes: pinnedNotesReducer,
csrfToken: csrfTokenReducer
},
devTools: isDevMode
})
@@ -11,6 +11,7 @@ import { initialState as initialStateNoteDetails } from '../redux/note-details/i
import { initialState as initialStateRealtimeStatus } from '../redux/realtime/initial-state'
import { initialState as initialStateRendererStatus } from '../redux/renderer-status/initial-state'
import { initialState as initialStatePinnedNotes } from '../redux/pinned-notes/initial-state'
import { initialState as initialStateCsrfToken } from '../redux/csrf-token/initial-state'
import { type DeepPartial, AuthProviderType } from '@hedgedoc/commons'
jest.mock('../redux/editor-config/methods', () => ({
@@ -51,6 +52,10 @@ export const mockAppState = (state?: DeepPartial<ApplicationState>) => {
pinnedNotes: {
...initialStatePinnedNotes
},
csrfToken: {
...initialStateCsrfToken,
...state?.csrfToken
},
user: state?.user
? {
username: state.user.username ?? '',
+19
View File
@@ -2636,6 +2636,24 @@ __metadata:
languageName: node
linkType: hard
"@fastify/csrf-protection@npm:7.1.0":
version: 7.1.0
resolution: "@fastify/csrf-protection@npm:7.1.0"
dependencies:
"@fastify/csrf": "npm:^8.0.0"
"@fastify/error": "npm:^4.0.0"
fastify-plugin: "npm:^5.0.0"
checksum: 10c0/aaf230ca4da4394971ade980e0c18ac29aef33f3385e98b99a59abc28e234d2513e92f02e62111797b5954831f83ca03cc720d829f784a29fbda22dc6c38ab6d
languageName: node
linkType: hard
"@fastify/csrf@npm:^8.0.0":
version: 8.0.1
resolution: "@fastify/csrf@npm:8.0.1"
checksum: 10c0/b1dd899c63d76628b9d816e612d34b3b6f9435130e177997a1ea3cc7c019613fe85ea00afc9ec53e2fb519ad27a2e85d240c0e6bc5410ba699f4bb4d36004235
languageName: node
linkType: hard
"@fastify/deepmerge@npm:^3.0.0":
version: 3.2.0
resolution: "@fastify/deepmerge@npm:3.2.0"
@@ -2825,6 +2843,7 @@ __metadata:
dependencies:
"@azure/storage-blob": "npm:12.29.1"
"@fastify/cookie": "npm:11.0.2"
"@fastify/csrf-protection": "npm:7.1.0"
"@fastify/multipart": "npm:9.4.0"
"@fastify/secure-session": "npm:8.3.0"
"@fastify/session": "npm:11.1.1"