security: document rate limiting (#2780)
Some checks failed
CodeQL / Analyze (push) Has been cancelled
Edge / Build Docker (push) Has been cancelled
Edge / Build Docker-1 (push) Has been cancelled
Lint / Check Docs (push) Has been cancelled
Edge / Merge & Deploy Docker (push) Has been cancelled
Edge / Build & Deploy Docs (push) Has been cancelled
Lint / Lint (push) Has been cancelled
Lint / Lint-1 (push) Has been cancelled
Lint / Lint-2 (push) Has been cancelled

document rate limiting
This commit is contained in:
Bernd Storath 2026-08-27 14:57:02 +02:00 committed by GitHub
parent 82d0ae969c
commit 5c38c1427a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 96 additions and 4 deletions

View file

@ -11,7 +11,7 @@ You can set these environment variables to configure the container. They are not
| `INSECURE` | `false` | `true` | If access over http is allowed |
| `DISABLE_IPV6` | `false` | `true` | If IPv6 support should be disabled |
| `DISABLE_VERSION_CHECK` | `false` | `true` | If wg-easy should check for new updates |
| `TRUSTED_PROXIES` | | `172.18.0.2,fd00:1234::/64` | Proxy IP addresses or CIDRs allowed to forward the request host |
| `TRUSTED_PROXIES` | | `172.18.0.2,fd00:1234::/64` | Proxy IP addresses or CIDRs allowed to forward request metadata |
## Trusted Proxies
@ -25,7 +25,9 @@ environment:
```
Only add the source addresses used by your reverse proxy. wg-easy only uses
`X-Forwarded-Host` when the request comes from one of these addresses. The
`X-Forwarded-Host` and `X-Forwarded-For` when the request comes from one of
these addresses. The forwarded client address is included in security logs so
that tools such as CrowdSec can identify failed authentication attempts. The
request protocol remains controlled by `INSECURE`. Invalid addresses prevent
wg-easy from starting so that configuration errors are not silently ignored.
Restart the container after changing this setting.

View file

@ -101,3 +101,17 @@ sudo docker compose up -d
```
You can now access `wg-easy` at [https://wg-easy.example.com](https://wg-easy.example.com) and start the setup.
## Rate Limiting
wg-easy does not implement rate limiting. Configure Caddy, a Caddy rate
limiting module, or an external security tool such as CrowdSec to limit requests
to these paths:
- `/api/auth/password`
- `/api/auth/verify-2fa`
- `/cnf/*`
Choose limits appropriate for your deployment. When using wg-easy security logs
for detection, configure [`TRUSTED_PROXIES`](../../advanced/config/optional-config.md#trusted-proxies)
so that logged events contain the original client IP address.

View file

@ -22,7 +22,7 @@ File: `/etc/docker/containers/traefik/docker-compose.yml`
```yaml
services:
traefik:
image: traefik:3.3
image: traefik:3.7
container_name: traefik
restart: unless-stopped
ports:
@ -184,3 +184,36 @@ sudo docker compose up -d
```
You can now access `wg-easy` at `https://wg-easy.$example.com$` and start the setup.
## Rate Limiting
wg-easy does not implement rate limiting. Configure a Traefik rate limit
middleware or an external security tool such as CrowdSec to limit requests to
these paths:
- `/api/auth/password`
- `/api/auth/verify-2fa`
- `/cnf/*`
Choose limits appropriate for your deployment. When using wg-easy security logs
for detection, configure [`TRUSTED_PROXIES`](../../advanced/config/optional-config.md#trusted-proxies)
so that logged events contain the original client IP address.
For example, add these labels to the `wg-easy` service to allow an average of
five requests per minute per client IP, with a burst of five requests:
```yaml
labels:
- 'traefik.http.routers.wg-easy-rate-limit.rule=Host(`wg-easy.$example.com$`) && (Path(`/api/auth/password`) || Path(`/api/auth/verify-2fa`) || PathPrefix(`/cnf/`))'
- 'traefik.http.routers.wg-easy-rate-limit.entrypoints=websecure'
- 'traefik.http.routers.wg-easy-rate-limit.service=wg-easy'
- 'traefik.http.routers.wg-easy-rate-limit.middlewares=wg-easy-rate-limit'
- 'traefik.http.routers.wg-easy-rate-limit.priority=100'
- 'traefik.http.middlewares.wg-easy-rate-limit.ratelimit.average=5'
- 'traefik.http.middlewares.wg-easy-rate-limit.ratelimit.period=1m'
- 'traefik.http.middlewares.wg-easy-rate-limit.ratelimit.burst=5'
```
The dedicated router applies the [rate limit middleware](https://doc.traefik.io/traefik/v3.7/reference/routing-configuration/http/middlewares/ratelimit/)
only to the sensitive paths. Adjust `average`, `period`, and `burst` for your
deployment.

View file

@ -2,6 +2,7 @@ import { createError, defineEventHandler, readValidatedBody } from 'h3';
import Database from '#server/utils/Database';
import { SERVER_DEBUG, WG_ENV } from '#server/utils/config';
import { logSecurityEvent } from '#server/utils/securityLogger';
import { useWGSession } from '#server/utils/session';
import { assertUnreachable, validateZod } from '#server/utils/types';
import { UserLoginSchema } from '#db/repositories/user/types';
@ -28,6 +29,7 @@ export default defineEventHandler(async (event) => {
if (!result.success) {
switch (result.error) {
case 'INCORRECT_CREDENTIALS':
logSecurityEvent(event, 'password', username);
throw createError({
statusCode: 401,
statusMessage: 'Invalid username or password',

View file

@ -1,6 +1,7 @@
import { createError, defineEventHandler, readValidatedBody } from 'h3';
import Database from '#server/utils/Database';
import { logSecurityEvent } from '#server/utils/securityLogger';
import { useWGSession } from '#server/utils/session';
import { assertUnreachable, validateZod } from '#server/utils/types';
import { Verify2faSchema } from '#db/repositories/user/types';
@ -34,6 +35,7 @@ export default defineEventHandler(async (event) => {
switch (totpStatus) {
case 'INVALID_TOTP_CODE':
logSecurityEvent(event, '2fa');
return { status: 'INVALID_TOTP_CODE' as const };
case 'USER_DISABLED':
throw createError({

View file

@ -7,6 +7,7 @@ import {
import Database from '#server/utils/Database';
import WireGuard from '#server/utils/WireGuard';
import { logSecurityEvent } from '#server/utils/securityLogger';
import { validateZod } from '#server/utils/types';
import { OneTimeLinkGetSchema } from '#db/repositories/oneTimeLink/types';
@ -18,6 +19,7 @@ export default defineEventHandler(async (event) => {
const otl = await Database.oneTimeLinks.getByOtl(oneTimeLink);
if (!otl) {
logSecurityEvent(event, 'one-time-link');
throw createError({
statusCode: 404,
statusMessage: 'Invalid One Time Link',

View file

@ -0,0 +1,17 @@
import type { H3Event } from 'h3';
import { WG_ENV } from '#server/utils/config';
import { getTrustedRequestIP } from '#server/utils/trustedProxy';
type SecurityEvent = 'password' | '2fa' | 'one-time-link';
export function logSecurityEvent(
event: H3Event,
type: SecurityEvent,
username?: string
) {
const user = username ? ` username=${JSON.stringify(username)}` : '';
const ip = getTrustedRequestIP(event, WG_ENV.TRUSTED_PROXIES) ?? 'unknown';
console.warn(`Security failure: type=${type}${user} ip=${ip}`);
}

View file

@ -61,6 +61,15 @@ export function getTrustedRequestURL(
});
}
export function getTrustedRequestIP(
event: H3Event,
trustedProxies: readonly string[]
): string | undefined {
return getRequestIP(event, {
xForwardedFor: isRequestFromTrustedProxy(event, trustedProxies),
});
}
function isRequestFromTrustedProxy(
event: H3Event,
trustedProxies: readonly string[]

View file

@ -4,6 +4,7 @@ import { createEvent, type H3Event } from 'h3';
import { describe, expect, test } from 'vitest';
import {
getTrustedRequestIP,
getTrustedRequestHost,
getTrustedRequestURL,
parseTrustedProxies,
@ -35,9 +36,11 @@ describe('trusted request helpers', () => {
const event = createTestEvent('192.0.2.10', {
host: 'wg-easy:51821',
'x-forwarded-host': 'vpn.example.com',
'x-forwarded-for': '198.51.100.25',
'x-forwarded-proto': 'https',
});
expect(getTrustedRequestIP(event, ['10.0.0.0/8'])).toBe('192.0.2.10');
expect(getTrustedRequestHost(event, ['10.0.0.0/8'])).toBe('wg-easy:51821');
expect(getTrustedRequestURL(event, ['10.0.0.0/8']).origin).toBe(
'http://wg-easy:51821'
@ -50,9 +53,11 @@ describe('trusted request helpers', () => {
const event = createTestEvent('10.0.0.2', {
host: 'wg-easy:51821',
'x-forwarded-host': 'vpn.example.com:8443, wg-easy:51821',
'x-forwarded-for': '198.51.100.25',
'x-forwarded-proto': 'HTTPS, http',
});
expect(getTrustedRequestIP(event, ['10.0.0.0/8'])).toBe('198.51.100.25');
expect(getTrustedRequestHost(event, ['10.0.0.0/8'])).toBe(
'vpn.example.com:8443'
);
@ -64,10 +69,16 @@ describe('trusted request helpers', () => {
);
expect(event.node.req.headers['x-forwarded-proto']).toBe('HTTPS, http');
});
test('returns undefined when no request address is available', () => {
const event = createTestEvent(undefined, {});
expect(getTrustedRequestIP(event, ['10.0.0.0/8'])).toBeUndefined();
});
});
function createTestEvent(
remoteAddress: string,
remoteAddress: string | undefined,
headers: IncomingMessage['headers']
): H3Event {
const request = {