mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2026-06-23 04:10:17 +00:00
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:
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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());
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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) |
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user