mirror of
https://github.com/wg-easy/wg-easy.git
synced 2026-09-01 18:30:25 +00:00
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
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:
parent
82d0ae969c
commit
5c38c1427a
9 changed files with 96 additions and 4 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
17
src/server/utils/securityLogger.ts
Normal file
17
src/server/utils/securityLogger.ts
Normal 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}`);
|
||||
}
|
||||
|
|
@ -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[]
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue