feat(security): add rate limiting

This adds rate-limiting using the @fastify/rate-limit module with sane
default values, configuration options, the possibility to disable limits
and differentiation between logged-in users and unauthenticated requests.

Signed-off-by: Erik Michelson <github@erik.michelson.eu>
This commit is contained in:
Erik Michelson
2026-01-29 02:25:21 +01:00
parent 66d052d611
commit a99f99d6ac
12 changed files with 675 additions and 9 deletions
+1
View File
@@ -26,6 +26,7 @@
"@fastify/cookie": "11.0.2",
"@fastify/csrf-protection": "7.1.0",
"@fastify/multipart": "9.4.0",
"@fastify/rate-limit": "10.3.0",
"@fastify/secure-session": "8.3.0",
"@fastify/session": "11.1.1",
"@fastify/static": "9.0.0",
+24 -1
View File
@@ -9,10 +9,12 @@ import { NestFastifyApplication } from '@nestjs/platform-fastify';
import { WsAdapter } from '@nestjs/platform-ws';
import fastifyMultipart from '@fastify/multipart';
import fastifyCsrfProtection from '@fastify/csrf-protection';
import fastifyRateLimit from '@fastify/rate-limit';
import { AppConfig } from './config/app.config';
import { AuthConfig } from './config/auth.config';
import { MediaConfig } from './config/media.config';
import { SecurityConfig } from './config/security.config';
import { ErrorExceptionMapping } from './errors/error-mapping';
import { ConsoleLoggerService } from './logger/console-logger.service';
import { runMigrations } from './migrate';
@@ -22,6 +24,12 @@ import { setupSessionMiddleware } from './utils/session';
import { setupValidationPipe } from './utils/setup-pipes';
import { setupPrivateApiDocs, setupPublicApiDocs } from './utils/swagger';
import { INestApplication } from '@nestjs/common';
import {
buildRateLimitResponse,
generateRateLimitKey,
getMaxLimitByRequestWithSecurityConfig,
getTimeWindowByRequestWithSecurityConfig,
} from './security/rate-limiting';
/**
* Common setup function which is called by main.ts and the E2E tests.
@@ -31,6 +39,7 @@ export async function setupApp(
appConfig: AppConfig,
authConfig: AuthConfig,
mediaConfig: MediaConfig,
securityConfig: SecurityConfig,
logger: ConsoleLoggerService,
): Promise<void> {
// Setup OpenAPI documentation
@@ -75,12 +84,26 @@ export async function setupApp(
});
logger.log('CSRF protection enabled', 'AppBootstrap');
// Setup rate limiting
await app.register(fastifyRateLimit, {
global: true,
hook: 'preHandler',
cache: 10000,
skipOnError: true,
keyGenerator: generateRateLimitKey,
max: getMaxLimitByRequestWithSecurityConfig(securityConfig),
timeWindow: getTimeWindowByRequestWithSecurityConfig(securityConfig),
errorResponseBuilder: buildRateLimitResponse,
allowList: securityConfig.rateLimit.bypass,
enableDraftSpec: true,
});
logger.log('Rate limiting enabled', 'AppBootstrap');
// Enable web security aspects
app.enableCors({
origin: appConfig.rendererBaseUrl,
});
logger.log(`Enabling CORS for '${appConfig.rendererBaseUrl}'`, 'AppBootstrap');
// TODO Add rate limiting (#442)
// TODO Add CSP (#1309)
// TODO Add common security headers (#201)
+3 -1
View File
@@ -1,5 +1,5 @@
/*
* 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
*/
@@ -27,6 +27,7 @@ import externalConfig from './config/external-services.config';
import { Loglevel } from './config/loglevel.enum';
import mediaConfig from './config/media.config';
import noteConfig from './config/note.config';
import securityConfig from './config/security.config';
import { eventModuleConfig } from './events';
import { ExploreModule } from './explore/explore.module';
import { FrontendConfigModule } from './frontend-config/frontend-config.module';
@@ -101,6 +102,7 @@ const routes: Routes = [
authConfig,
customizationConfig,
externalConfig,
securityConfig,
],
isGlobal: true,
}),
@@ -0,0 +1,41 @@
/*
* SPDX-FileCopyrightText: 2026 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { ConfigFactoryKeyHost, registerAs } from '@nestjs/config';
import { ConfigFactory } from '@nestjs/config/dist/interfaces';
import { SecurityConfig } from '../security.config';
export function createDefaultMockSecurityConfig(): SecurityConfig {
return {
rateLimit: {
publicApi: {
max: 150,
window: 300,
},
authenticated: {
max: 900,
window: 300,
},
unauthenticated: {
max: 100,
window: 300,
},
auth: {
max: 20,
window: 600,
},
bypass: [],
},
};
}
export function registerSecurityConfig(
securityConfig: SecurityConfig,
): ConfigFactory<SecurityConfig> & ConfigFactoryKeyHost<SecurityConfig> {
return registerAs('securityConfig', (): SecurityConfig => securityConfig);
}
export default registerSecurityConfig(createDefaultMockSecurityConfig());
+286
View File
@@ -0,0 +1,286 @@
/*
* SPDX-FileCopyrightText: 2026 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import mockedEnv from 'mocked-env';
import securityConfig from './security.config';
describe('securityConfig: rate limiting', () => {
const completeRateLimitConfig = {
/* oxlint-disable @typescript-eslint/naming-convention */
HD_SECURITY_RATE_LIMIT_PUBLIC_API_MAX: '150',
HD_SECURITY_RATE_LIMIT_PUBLIC_API_WINDOW: '300',
HD_SECURITY_RATE_LIMIT_AUTHENTICATED_MAX: '600',
HD_SECURITY_RATE_LIMIT_AUTHENTICATED_WINDOW: '300',
HD_SECURITY_RATE_LIMIT_UNAUTHENTICATED_MAX: '100',
HD_SECURITY_RATE_LIMIT_UNAUTHENTICATED_WINDOW: '300',
HD_SECURITY_RATE_LIMIT_AUTH_MAX: '20',
HD_SECURITY_RATE_LIMIT_AUTH_WINDOW: '600',
HD_SECURITY_RATE_LIMIT_BYPASS: '127.0.0.1,::1',
/* oxlint-enable @typescript-eslint/naming-convention */
};
describe('is correctly parsed', () => {
it('when given correct and complete environment variables', () => {
const restore = mockedEnv(
{
...completeRateLimitConfig,
},
{
clear: true,
},
);
const config = securityConfig();
expect(config.rateLimit.publicApi.max).toEqual(150);
expect(config.rateLimit.publicApi.window).toEqual(300);
expect(config.rateLimit.authenticated.max).toEqual(600);
expect(config.rateLimit.authenticated.window).toEqual(300);
expect(config.rateLimit.unauthenticated.max).toEqual(100);
expect(config.rateLimit.unauthenticated.window).toEqual(300);
expect(config.rateLimit.auth.max).toEqual(20);
expect(config.rateLimit.auth.window).toEqual(600);
expect(config.rateLimit.bypass).toEqual(['127.0.0.1', '::1']);
restore();
});
it('when no environment variables are set (uses defaults)', () => {
const restore = mockedEnv(
{},
{
clear: true,
},
);
const config = securityConfig();
expect(config.rateLimit.publicApi.max).toEqual(150);
expect(config.rateLimit.publicApi.window).toEqual(300);
expect(config.rateLimit.authenticated.max).toEqual(900);
expect(config.rateLimit.authenticated.window).toEqual(300);
expect(config.rateLimit.unauthenticated.max).toEqual(100);
expect(config.rateLimit.unauthenticated.window).toEqual(300);
expect(config.rateLimit.auth.max).toEqual(20);
expect(config.rateLimit.auth.window).toEqual(600);
expect(config.rateLimit.bypass).toEqual([]);
restore();
});
it('when max is set to 0 (disables rate limiting)', () => {
const restore = mockedEnv(
{
/* oxlint-disable @typescript-eslint/naming-convention */
HD_SECURITY_RATE_LIMIT_PUBLIC_API_MAX: '0',
/* oxlint-enable @typescript-eslint/naming-convention */
},
{
clear: true,
},
);
const config = securityConfig();
expect(config.rateLimit.publicApi.max).toEqual(0);
restore();
});
it('when bypass is set with a single IPv4 address', () => {
const restore = mockedEnv(
{
/* oxlint-disable @typescript-eslint/naming-convention */
HD_SECURITY_RATE_LIMIT_BYPASS: '192.168.1.1',
/* oxlint-enable @typescript-eslint/naming-convention */
},
{
clear: true,
},
);
const config = securityConfig();
expect(config.rateLimit.bypass).toEqual(['192.168.1.1']);
restore();
});
it('when bypass is set with multiple IP addresses', () => {
const restore = mockedEnv(
{
/* oxlint-disable @typescript-eslint/naming-convention */
HD_SECURITY_RATE_LIMIT_BYPASS: '127.0.0.1,::1,192.168.1.1',
/* oxlint-enable @typescript-eslint/naming-convention */
},
{
clear: true,
},
);
const config = securityConfig();
expect(config.rateLimit.bypass).toEqual(['127.0.0.1', '::1', '192.168.1.1']);
restore();
});
it('when bypass is set with IPv6 addresses', () => {
const restore = mockedEnv(
{
/* oxlint-disable @typescript-eslint/naming-convention */
HD_SECURITY_RATE_LIMIT_BYPASS: '::1,fe80::1',
/* oxlint-enable @typescript-eslint/naming-convention */
},
{
clear: true,
},
);
const config = securityConfig();
expect(config.rateLimit.bypass).toEqual(['::1', 'fe80::1']);
restore();
});
});
describe('throws error', () => {
let spyConsoleError: jest.SpyInstance;
let spyProcessExit: jest.Mock;
let originalProcess: typeof process;
beforeEach(() => {
spyConsoleError = jest.spyOn(console, 'error');
spyProcessExit = jest.fn();
originalProcess = global.process;
global.process = {
...originalProcess,
exit: spyProcessExit,
} as unknown as typeof global.process;
});
afterEach(() => {
global.process = originalProcess;
spyConsoleError.mockRestore();
});
it('when max is negative', () => {
const restore = mockedEnv(
{
/* oxlint-disable @typescript-eslint/naming-convention */
HD_SECURITY_RATE_LIMIT_PUBLIC_API_MAX: '-1',
/* oxlint-enable @typescript-eslint/naming-convention */
},
{
clear: true,
},
);
securityConfig();
expect(spyConsoleError.mock.calls[0][0]).toContain(
'HD_SECURITY_RATE_LIMIT_PUBLIC_API_MAX: Number must be greater than or equal to 0',
);
expect(spyProcessExit).toHaveBeenCalledWith(1);
restore();
});
it('when window is zero', () => {
const restore = mockedEnv(
{
/* oxlint-disable @typescript-eslint/naming-convention */
HD_SECURITY_RATE_LIMIT_PUBLIC_API_WINDOW: '0',
/* oxlint-enable @typescript-eslint/naming-convention */
},
{
clear: true,
},
);
securityConfig();
expect(spyConsoleError.mock.calls[0][0]).toContain(
'HD_SECURITY_RATE_LIMIT_PUBLIC_API_WINDOW: Number must be greater than 0',
);
expect(spyProcessExit).toHaveBeenCalledWith(1);
restore();
});
it('when window is negative', () => {
const restore = mockedEnv(
{
/* oxlint-disable @typescript-eslint/naming-convention */
HD_SECURITY_RATE_LIMIT_AUTHENTICATED_WINDOW: '-300',
/* oxlint-enable @typescript-eslint/naming-convention */
},
{
clear: true,
},
);
securityConfig();
expect(spyConsoleError.mock.calls[0][0]).toContain(
'HD_SECURITY_RATE_LIMIT_AUTHENTICATED_WINDOW: Number must be greater than 0',
);
expect(spyProcessExit).toHaveBeenCalledWith(1);
restore();
});
it('when max is not an integer', () => {
const restore = mockedEnv(
{
/* oxlint-disable @typescript-eslint/naming-convention */
HD_SECURITY_RATE_LIMIT_AUTH_MAX: '20.5',
/* oxlint-enable @typescript-eslint/naming-convention */
},
{
clear: true,
},
);
securityConfig();
expect(spyConsoleError.mock.calls[0][0]).toContain(
'HD_SECURITY_RATE_LIMIT_AUTH_MAX: Expected integer',
);
expect(spyProcessExit).toHaveBeenCalledWith(1);
restore();
});
it('when window is not an integer', () => {
const restore = mockedEnv(
{
/* oxlint-disable @typescript-eslint/naming-convention */
HD_SECURITY_RATE_LIMIT_AUTH_WINDOW: '600.7',
/* oxlint-enable @typescript-eslint/naming-convention */
},
{
clear: true,
},
);
securityConfig();
expect(spyConsoleError.mock.calls[0][0]).toContain(
'HD_SECURITY_RATE_LIMIT_AUTH_WINDOW: Expected integer',
);
expect(spyProcessExit).toHaveBeenCalledWith(1);
restore();
});
it('when bypass contains an invalid IP address', () => {
const restore = mockedEnv(
{
/* oxlint-disable @typescript-eslint/naming-convention */
HD_SECURITY_RATE_LIMIT_BYPASS: '127.0.0.1,invalid-ip',
/* oxlint-enable @typescript-eslint/naming-convention */
},
{
clear: true,
},
);
securityConfig();
expect(spyConsoleError.mock.calls[0][0]).toContain(
'HD_SECURITY_RATE_LIMIT_BYPASS[1]: Invalid ip',
);
expect(spyProcessExit).toHaveBeenCalledWith(1);
restore();
});
it('when bypass contains a malformed IPv4 address', () => {
const restore = mockedEnv(
{
/* oxlint-disable @typescript-eslint/naming-convention */
HD_SECURITY_RATE_LIMIT_BYPASS: '999.999.999.999',
/* oxlint-enable @typescript-eslint/naming-convention */
},
{
clear: true,
},
);
securityConfig();
expect(spyConsoleError.mock.calls[0][0]).toContain(
'HD_SECURITY_RATE_LIMIT_BYPASS[0]: Invalid ip',
);
expect(spyProcessExit).toHaveBeenCalledWith(1);
restore();
});
});
});
+106
View File
@@ -0,0 +1,106 @@
/*
* SPDX-FileCopyrightText: 2026 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { registerAs } from '@nestjs/config';
import z from 'zod';
import { parseOptionalNumber, printConfigErrorAndExit } from './utils';
import { buildErrorMessage, extractDescriptionFromZodIssue } from './zod-error-message';
const securityConfigSchema = z.object({
rateLimit: z.object({
publicApi: z.object({
max: z
.number()
.int()
.nonnegative()
.default(150)
.describe('HD_SECURITY_RATE_LIMIT_PUBLIC_API_MAX'),
window: z
.number()
.int()
.positive()
.default(300)
.describe('HD_SECURITY_RATE_LIMIT_PUBLIC_API_WINDOW'),
}),
authenticated: z.object({
max: z
.number()
.int()
.nonnegative()
.default(900)
.describe('HD_SECURITY_RATE_LIMIT_AUTHENTICATED_MAX'),
window: z
.number()
.int()
.positive()
.default(300)
.describe('HD_SECURITY_RATE_LIMIT_AUTHENTICATED_WINDOW'),
}),
unauthenticated: z.object({
max: z
.number()
.int()
.nonnegative()
.default(100)
.describe('HD_SECURITY_RATE_LIMIT_UNAUTHENTICATED_MAX'),
window: z
.number()
.int()
.positive()
.default(300)
.describe('HD_SECURITY_RATE_LIMIT_UNAUTHENTICATED_WINDOW'),
}),
auth: z.object({
max: z.number().int().nonnegative().default(20).describe('HD_SECURITY_RATE_LIMIT_AUTH_MAX'),
window: z
.number()
.int()
.positive()
.default(600)
.describe('HD_SECURITY_RATE_LIMIT_AUTH_WINDOW'),
}),
bypass: z
.array(z.string().ip())
.optional()
.default([])
.describe('HD_SECURITY_RATE_LIMIT_BYPASS'),
}),
});
export type SecurityConfig = z.infer<typeof securityConfigSchema>;
export default registerAs('securityConfig', () => {
const securityConfig = securityConfigSchema.safeParse({
rateLimit: {
publicApi: {
max: parseOptionalNumber(process.env.HD_SECURITY_RATE_LIMIT_PUBLIC_API_MAX),
window: parseOptionalNumber(process.env.HD_SECURITY_RATE_LIMIT_PUBLIC_API_WINDOW),
},
authenticated: {
max: parseOptionalNumber(process.env.HD_SECURITY_RATE_LIMIT_AUTHENTICATED_MAX),
window: parseOptionalNumber(process.env.HD_SECURITY_RATE_LIMIT_AUTHENTICATED_WINDOW),
},
unauthenticated: {
max: parseOptionalNumber(process.env.HD_SECURITY_RATE_LIMIT_UNAUTHENTICATED_MAX),
window: parseOptionalNumber(process.env.HD_SECURITY_RATE_LIMIT_UNAUTHENTICATED_WINDOW),
},
auth: {
max: parseOptionalNumber(process.env.HD_SECURITY_RATE_LIMIT_AUTH_MAX),
window: parseOptionalNumber(process.env.HD_SECURITY_RATE_LIMIT_AUTH_WINDOW),
},
bypass: process.env.HD_SECURITY_RATE_LIMIT_BYPASS?.split(',') ?? [],
},
});
if (securityConfig.error) {
const errorMessages = securityConfig.error.errors.map((issue) =>
extractDescriptionFromZodIssue(issue, 'HD_SECURITY'),
);
const errorMessage = buildErrorMessage(errorMessages);
return printConfigErrorAndExit(errorMessage);
}
return securityConfig.data;
});
+6 -4
View File
@@ -1,5 +1,5 @@
/*
* 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
*/
@@ -13,6 +13,7 @@ import { AppConfig } from './config/app.config';
import { AuthConfig } from './config/auth.config';
import { Loglevel } from './config/loglevel.enum';
import { MediaConfig } from './config/media.config';
import { SecurityConfig } from './config/security.config';
import { ConsoleLoggerService } from './logger/console-logger.service';
import { isDevMode } from './utils/dev-mode';
@@ -46,16 +47,17 @@ async function bootstrap(): Promise<void> {
const appConfig = configService.get<AppConfig>('appConfig');
const authConfig = configService.get<AuthConfig>('authConfig');
const mediaConfig = configService.get<MediaConfig>('mediaConfig');
const securityConfig = configService.get<SecurityConfig>('securityConfig');
if (!appConfig || !authConfig || !mediaConfig) {
logger.error('Could not initialize config, aborting.', 'AppBootstrap');
if (!appConfig || !authConfig || !mediaConfig || !securityConfig) {
logger.error('Could not initialize config, aborting.', undefined, 'AppBootstrap');
await app.close();
process.exit(1);
}
// Call common setup function which handles the rest
// Setup code must be added there!
await setupApp(app, appConfig, authConfig, mediaConfig, logger);
await setupApp(app, appConfig, authConfig, mediaConfig, securityConfig, logger);
// Start the server
await app.listen(appConfig.backendPort);
+119
View File
@@ -0,0 +1,119 @@
/*
* SPDX-FileCopyrightText: 2026 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { FastifyRequest } from 'fastify';
import type { SecurityConfig } from '../config/security.config';
import type { RequestWithSession } from '../api/utils/request.type';
import type { errorResponseBuilderContext } from '@fastify/rate-limit';
interface RateLimitConfig {
max?: number;
window?: number;
}
/**
* Extracts the user ID from the session if present.
*
* @param req The incoming Fastify request
* @returns The user ID if authenticated, undefined otherwise
*/
function getUserIdFromSession(req: FastifyRequest): number | undefined {
return (req as RequestWithSession).session?.userId;
}
/**
* Generates a rate-limiting key based on the request. It uses the user ID if the user is authenticated,
* and falls back to the IP address for unauthenticated requests. The IP address is determined by
* fastify's trustProxy option.
*
* @param req The incoming request object from which the key is generated.
* @returns The generated rate-limiting key based on the user ID or IP address.
*/
export function generateRateLimitKey(req: FastifyRequest): string {
const userId = getUserIdFromSession(req);
if (userId !== undefined) {
return `user:${userId}`;
}
return `ip:${req.ip}`;
}
/**
* Retrieves the rate limit configuration based on the incoming request.
*
* @param req The request object of the incoming HTTP request.
* @param securityConfig The security configuration containing rate limit settings.
* @returns the appropriate rate limit configuration for the request.
*/
function getRateLimitConfigByRequest(
req: FastifyRequest,
securityConfig: SecurityConfig,
): RateLimitConfig {
const path = req.routeOptions?.url ?? req.url;
const userId = getUserIdFromSession(req);
// Auth endpoints
if (path.includes('/api/private/auth/')) {
return securityConfig.rateLimit.auth;
}
// Public API, authenticated
if (path.startsWith('/api/v2') && userId !== undefined) {
return securityConfig.rateLimit.publicApi;
}
// Private API, authenticated
if (path.startsWith('/api/private') && userId !== undefined) {
return securityConfig.rateLimit.authenticated;
}
// Unauthenticated limits otherwise
return securityConfig.rateLimit.unauthenticated;
}
/**
* Creates a function that retrieves the rate limit time window in milliseconds for a given request.
*
* @param securityConfig The security configuration containing rate limit settings.
* @return A function that takes a Fastify request and returns the time window in milliseconds.
*/
export function getTimeWindowByRequestWithSecurityConfig(
securityConfig: SecurityConfig,
): (req: FastifyRequest, key: string) => number {
return (req: FastifyRequest, _: string): number => {
const configValue = getRateLimitConfigByRequest(req, securityConfig).window ?? 0;
return configValue * 1000;
};
}
/**
* Creates a function that retrieves the rate limit maximum value for a given request.
*
* @param securityConfig The security configuration containing rate limit settings.
* @return A function that takes a Fastify request and returns the max requests, or Infinity if no limit is set.
*/
export function getMaxLimitByRequestWithSecurityConfig(
securityConfig: SecurityConfig,
): (req: FastifyRequest, key: string) => number {
return (req: FastifyRequest, _: string): number => {
const limit = getRateLimitConfigByRequest(req, securityConfig).max ?? 0;
return limit === 0 ? Infinity : limit;
};
}
/**
* Builds a response object for rate-limiting scenarios.
*
* @param _ The fastify request object, unused
* @param context The rate limiter context containing data about the current request
* @returns The fastify error response
*/
export function buildRateLimitResponse(_: FastifyRequest, context: errorResponseBuilderContext) {
return {
statusCode: 429,
error: 'Too Many Requests',
message: `Rate limit exceeded. Please try again later (${context.after}).`,
expiresIn: context.ttl,
};
}
+26 -3
View File
@@ -1,3 +1,9 @@
/*
* SPDX-FileCopyrightText: 2026 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { HttpServer } from '@nestjs/common';
import { AliasModule } from '../src/alias/alias.module';
import { AliasService } from '../src/alias/alias.service';
@@ -46,7 +52,12 @@ import {
createDefaultMockNoteConfig,
registerNoteConfig,
} from '../src/config/mock/note.config.mock';
import {
createDefaultMockSecurityConfig,
registerSecurityConfig,
} from '../src/config/mock/security.config.mock';
import { NoteConfig } from '../src/config/note.config';
import { SecurityConfig } from '../src/config/security.config';
import { ApiTokenWithSecretDto } from '../src/dtos/api-token-with-secret.dto';
import { eventModuleConfig } from '../src/events';
import { ExploreService } from '../src/explore/explore.service';
@@ -94,6 +105,7 @@ interface CreateTestSetupParameters {
externalServicesConfigMock?: ExternalServicesConfig;
mediaConfigMock?: MediaConfig;
noteConfigMock?: NoteConfig;
securityConfigMock?: SecurityConfig;
}
export class TestSetup {
@@ -267,6 +279,7 @@ export class TestSetupBuilder {
),
registerMediaConfig(mocks?.mediaConfigMock ?? createDefaultMockMediaConfig()),
registerNoteConfig(mocks?.noteConfigMock ?? createDefaultMockNoteConfig()),
registerSecurityConfig(mocks?.securityConfigMock ?? createDefaultMockSecurityConfig()),
],
}),
KnexModule.forRoot({
@@ -347,11 +360,21 @@ export class TestSetupBuilder {
new FastifyAdapter({ ignoreTrailingSlash: true }) as HttpServer,
) as NestFastifyApplication;
const appConfig = this.testSetup.configService.get<AppConfig>('appConfig');
const authConfig = this.testSetup.configService.get<AuthConfig>('authConfig');
const mediaConfig = this.testSetup.configService.get<MediaConfig>('mediaConfig');
const securityConfig = this.testSetup.configService.get<SecurityConfig>('securityConfig');
if (!appConfig || !authConfig || !mediaConfig || !securityConfig) {
throw new Error('Could not initialize config in test setup');
}
await setupApp(
this.testSetup.app,
this.testSetup.configService.get<AppConfig>('appConfig'),
this.testSetup.configService.get<AuthConfig>('authConfig'),
this.testSetup.configService.get<MediaConfig>('mediaConfig'),
appConfig,
authConfig,
mediaConfig,
securityConfig,
await this.testSetup.app.resolve(ConsoleLoggerService),
);
@@ -0,0 +1,50 @@
<!--
SPDX-FileCopyrightText: 2026 The HedgeDoc developers (see AUTHORS file)
SPDX-License-Identifier: CC-BY-SA-4.0
-->
# Security
This page describes security-related configuration options for HedgeDoc.
## Rate Limiting
HedgeDoc implements rate limiting to protect against abuse and brute-force attacks. Rate limiting
applies different limits based on the authentication level and endpoint type.
1. **Public API**: For requests to the public API using a valid API-token
2. **Authenticated**: For requests to the app by logged-in users
3. **Unauthenticated**: For requests to the app or API by unauthenticated users or guests
4. **Auth**: For requests to the auth endpoints (login, register, etc.)
Rate limits are tracked differently based on authentication state:
- **Authenticated requests** (session or API token): Tracked per user ID
- **Unauthenticated requests**: Tracked per IP address
When a rate limit is exceeded, the server responds with the HTTP 429 status code and includes
information about the limit and when to retry in the headers `X-RateLimit-Limit`,
`X-RateLimit-Remaining`, `X-RateLimit-Reset`, and `Retry-After`.
### Configuration
Each rate limit tier can be configured with two values:
- `*_MAX`: Maximum number of requests allowed
- `*_WINDOW`: Time window in seconds for the limit
Setting a `*_MAX` value to `0` effectively disables rate limiting for that tier
(not recommended for production).
| environment variable | default | description |
|-------------------------------------------------|---------|--------------------------------------------------------------------|
| `HD_SECURITY_RATE_LIMIT_PUBLIC_API_MAX` | 150 | Number of maximum requests for the public API with auth token |
| `HD_SECURITY_RATE_LIMIT_PUBLIC_API_WINDOW` | 300 | Time window in seconds for public API limit |
| `HD_SECURITY_RATE_LIMIT_AUTHENTICATED_MAX` | 900 | Maximum requests for authenticated usage |
| `HD_SECURITY_RATE_LIMIT_AUTHENTICATED_WINDOW` | 300 | Time window in seconds for authenticated usage |
| `HD_SECURITY_RATE_LIMIT_UNAUTHENTICATED_MAX` | 100 | Maximum requests for unauthenticated usage |
| `HD_SECURITY_RATE_LIMIT_UNAUTHENTICATED_WINDOW` | 300 | Time window in seconds for unauthenticated usage |
| `HD_SECURITY_RATE_LIMIT_AUTH_MAX` | 20 | Maximum of auth request attempts |
| `HD_SECURITY_RATE_LIMIT_AUTH_WINDOW` | 600 | Time window in seconds for auth request attempts |
| `HD_SECURITY_RATE_LIMIT_BYPASS` | *none* | Bypass rate limiting for these IP addresses (comma-separated list) |
+1
View File
@@ -45,6 +45,7 @@ nav:
- 'Local accounts': references/config/auth/local.md
- LDAP: references/config/auth/ldap.md
- 'OpenID Connect (OIDC)': references/config/auth/oidc.md
- Security: references/config/security.md
- Customization: references/config/customization.md
- Media Backends:
- Azure: references/config/media/azure.md
+12
View File
@@ -2726,6 +2726,17 @@ __metadata:
languageName: node
linkType: hard
"@fastify/rate-limit@npm:10.3.0":
version: 10.3.0
resolution: "@fastify/rate-limit@npm:10.3.0"
dependencies:
"@lukeed/ms": "npm:^2.0.2"
fastify-plugin: "npm:^5.0.0"
toad-cache: "npm:^3.7.0"
checksum: 10c0/dc29c4e088362cc144a9782d86d50bc8bbd440d10017c394660ff26bd7468feb90ba412e101b3e312bfdbb7a515798ffd73801ff32a36d0fedf40032ff081dcb
languageName: node
linkType: hard
"@fastify/secure-session@npm:8.3.0":
version: 8.3.0
resolution: "@fastify/secure-session@npm:8.3.0"
@@ -2845,6 +2856,7 @@ __metadata:
"@fastify/cookie": "npm:11.0.2"
"@fastify/csrf-protection": "npm:7.1.0"
"@fastify/multipart": "npm:9.4.0"
"@fastify/rate-limit": "npm:10.3.0"
"@fastify/secure-session": "npm:8.3.0"
"@fastify/session": "npm:11.1.1"
"@fastify/static": "npm:9.0.0"