Tighten health endpoint hardening

This commit is contained in:
Koala
2026-06-03 10:59:08 +02:00
parent bb7bd21102
commit 602be3a724
9 changed files with 65 additions and 13 deletions
+1
View File
@@ -3,4 +3,5 @@ MIN_VERSION=1.0.0
MAX_ROOMS=1000
MAX_PEERS_PER_ROOM=25
# Optional: enables aggregate-only admin metrics on /health with Authorization: Bearer <token>
# Use a long random token, 32+ characters recommended.
ADMIN_METRICS_TOKEN=
+7 -2
View File
@@ -17,13 +17,16 @@ MAX_ROOMS=1000
MAX_PEERS_PER_ROOM=25
MIN_VERSION=1.0.0
# Optional: enables aggregate-only admin metrics on /health with Authorization: Bearer <token>
# Use a long random token, 32+ characters recommended.
ADMIN_METRICS_TOKEN=
```
### Health & Metrics
`GET /` and `GET /health` are IP-rate-limited. By default `/health` returns only basic service status, uptime, room count, connection count, and a timestamp.
`GET /` and `GET /health` are IP-rate-limited to 20 requests per minute per client IP. By default `/health` returns only basic service status, uptime, room count, connection count, and a timestamp.
If `ADMIN_METRICS_TOKEN` is set, requests with `Authorization: Bearer <token>` receive additional aggregate metrics such as total peers, average peers per room, max room size, active lobby count, rate-limit map sizes, and process memory usage. The metrics response does not include room IDs, peer IDs, usernames, IP addresses, media titles, or other user-level data.
If `ADMIN_METRICS_TOKEN` is set, requests with `Authorization: Bearer <token>` receive additional aggregate metrics such as total peers, average peers per room, max room size, active lobby count, rate-limit map sizes, and process memory usage. Wrong admin bearer attempts are separately limited to 5 requests per minute per client IP. The metrics response does not include room IDs, peer IDs, usernames, IP addresses, media titles, or other user-level data.
Use a long random `ADMIN_METRICS_TOKEN` of at least 32 characters. Shorter configured tokens still work, but the server logs a startup warning.
### Docker (Recommended)
The server is available as a pre-built image on GHCR.
@@ -45,7 +48,9 @@ npm start
## Security
- **Rate Limiting**: IP-based connection limits and socket-based event limits.
- **Health Endpoint Throttle**: `GET /` and `GET /health` are limited to 20 requests per minute per IP, with stricter throttling for wrong admin bearer attempts.
- **Room Discovery Throttle**: Room-list refreshes are rate-limited server-side to one request every 10 seconds per socket.
- **Token Handshake**: Requires a valid token defined in the root `shared/constants.js`.
- **Single Source of Truth**: The server imports constants directly from the root `shared/` directory.
- **In-Memory**: Rooms are automatically pruned after 2 hours of inactivity.
- **Reverse Proxy Boundary**: The server trusts one reverse proxy hop for client IP detection. Keep the Node port private/firewalled so clients can only reach it through Caddy or another trusted proxy.
+34 -3
View File
@@ -4,7 +4,12 @@ import { Server } from 'socket.io';
import crypto from 'crypto';
import dotenv from 'dotenv';
import { EVENTS, OFFICIAL_SERVER_TOKEN, PROTOCOL_VERSION } from '../shared/constants.js';
import { buildHealthPayload, checkCooldown, isAdminMetricsAuthorized } from './ops.js';
import {
buildHealthPayload,
checkCooldown,
isAdminMetricsAuthorized,
isAdminMetricsTokenStrong
} from './ops.js';
dotenv.config();
@@ -20,6 +25,12 @@ const MAX_PEERS_PER_ROOM = parseInt(process.env.MAX_PEERS_PER_ROOM) || 25;
const MIN_VERSION = process.env.MIN_VERSION || '1.0.0';
const ADMIN_METRICS_TOKEN = process.env.ADMIN_METRICS_TOKEN || '';
const ROOM_LIST_COOLDOWN_MS = 10000;
const HEALTH_RATE_LIMIT_PER_MINUTE = 20;
const ADMIN_METRICS_AUTH_RATE_LIMIT_PER_MINUTE = 5;
if (!isAdminMetricsTokenStrong(ADMIN_METRICS_TOKEN)) {
console.warn('[SECURITY] ADMIN_METRICS_TOKEN is set but shorter than 32 characters. Use a long random token.');
}
const app = express();
app.set('trust proxy', 1); // For real client IP through reverse proxy
@@ -38,7 +49,11 @@ app.get('/health', (req, res) => {
if (!checkHealthRate(clientIp)) {
return res.status(429).json({ error: 'Rate limited' });
}
const includeMetrics = isAdminMetricsAuthorized(req.get('authorization'), ADMIN_METRICS_TOKEN);
const authHeader = req.get('authorization');
const includeMetrics = isAdminMetricsAuthorized(authHeader, ADMIN_METRICS_TOKEN);
if (ADMIN_METRICS_TOKEN && authHeader && !includeMetrics && !checkAdminMetricsAuthRate(clientIp)) {
return res.status(429).json({ error: 'Rate limited' });
}
res.json(buildHealthPayload({
rooms,
connections: io.engine?.clientsCount ?? 0,
@@ -48,6 +63,7 @@ app.get('/health', (req, res) => {
connections: connectionCounts.size,
events: eventCounts.size,
health: healthCounts.size,
adminMetricsAuth: adminMetricsAuthCounts.size,
authFailures: failedAuthAttempts.size,
roomList: roomListCooldowns.size
}
@@ -149,6 +165,7 @@ setInterval(() => {
const eventCounts = new Map(); // socketId -> { count, resetTime }
const healthCounts = new Map(); // ip -> { count, resetTime }
const adminMetricsAuthCounts = new Map(); // ip -> { count, resetTime }
const roomListCooldowns = new Map(); // socketId -> last allowed timestamp
// Clean up connection counts and event counts to prevent memory leak
@@ -169,6 +186,11 @@ setInterval(() => {
healthCounts.delete(ip);
}
}
for (const [ip, entry] of adminMetricsAuthCounts.entries()) {
if (now > entry.resetTime) {
adminMetricsAuthCounts.delete(ip);
}
}
for (const [socketId] of roomListCooldowns.entries()) {
if (!io.sockets.sockets.has(socketId)) {
roomListCooldowns.delete(socketId);
@@ -200,7 +222,16 @@ function checkHealthRate(ip) {
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 60000; }
entry.count++;
healthCounts.set(ip, entry);
return entry.count <= 60;
return entry.count <= HEALTH_RATE_LIMIT_PER_MINUTE;
}
function checkAdminMetricsAuthRate(ip) {
const now = Date.now();
const entry = adminMetricsAuthCounts.get(ip) || { count: 0, resetTime: now + 60000 };
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 60000; }
entry.count++;
adminMetricsAuthCounts.set(ip, entry);
return entry.count <= ADMIN_METRICS_AUTH_RATE_LIMIT_PER_MINUTE;
}
/**
+5
View File
@@ -26,6 +26,10 @@ export function isAdminMetricsAuthorized(authHeader, adminToken) {
return crypto.timingSafeEqual(expectedBuffer, providedBuffer);
}
export function isAdminMetricsTokenStrong(adminToken, minLength = 32) {
return !adminToken || (typeof adminToken === 'string' && adminToken.length >= minLength);
}
export function buildHealthPayload({
rooms,
connections,
@@ -64,6 +68,7 @@ export function buildHealthPayload({
connections: rateLimitSizes.connections || 0,
events: rateLimitSizes.events || 0,
health: rateLimitSizes.health || 0,
adminMetricsAuth: rateLimitSizes.adminMetricsAuth || 0,
authFailures: rateLimitSizes.authFailures || 0,
roomList: rateLimitSizes.roomList || 0
},