Cache health responses server-side

This commit is contained in:
Koala
2026-06-03 11:04:45 +02:00
parent 602be3a724
commit 4f4cdcd8c0
6 changed files with 57 additions and 20 deletions
+1 -1
View File
@@ -74,7 +74,7 @@ KoalaSync's security is grounded in its architecture:
- **RAM-only relay**: No database, no persistent logs. All session data evaporates on disconnect.
- **Keyed SHA-256 room password hashes**: Plaintext passwords are never stored. Room passwords are held only as in-memory HMAC-SHA256 hashes for the short room lifetime, with brute-force protection: 5 attempts → 15-minute IP lockout.
- **Rate limiting**: Connection rate (IP-based, 60s window), health endpoint rate (20 requests/minute/IP), wrong admin-metrics bearer attempts (5 requests/minute/IP), and event rate (per-socket, 10s window).
- **Rate limiting**: Connection rate (IP-based, 60s window), health endpoint rate (10 requests/minute/IP), wrong admin-metrics bearer attempts (5 requests/minute/IP), and event rate (per-socket, 10s window). Health-style JSON responses are cached server-side for 60 seconds and refreshed lazily on request.
- **Reverse proxy boundary**: The relay trusts one proxy hop for client IP detection. In production, keep the Node server reachable only through Caddy or another trusted reverse proxy.
- **URL-hash credential isolation**: Invitation credentials live in the URL fragment (`#join:...`) — never sent to the web server.
- **Strict CSP**: `default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'none'`.
+1 -1
View File
@@ -58,7 +58,7 @@ KoalaSync uses a megaphone routing approach to minimize server logic:
## 7. Security & Stability
- **Service Worker Lifecycle**: Uses `chrome.alarms` (30s interval) to prevent the Manifest V3 service worker from suspending while in an active room. On wake, runtime state is restored from `chrome.storage.session` via `ensureState()`.
- **Reconnect Visualization**: Badge shows "..." (orange) during reconnect. Popup displays "Reconnecting..." with attempt counter.
- **Rate Limiting**: Server-side per-socket and per-IP rate limits to prevent sync-spamming or simple DoS. Public health endpoints are limited to 20 requests/minute/IP, wrong admin-metrics bearer attempts to 5 requests/minute/IP, and room discovery to one request every 10 seconds per socket. Real client IP is taken from the trusted reverse proxy hop, so the Node port must stay private behind Caddy or another trusted proxy.
- **Rate Limiting**: Server-side per-socket and per-IP rate limits to prevent sync-spamming or simple DoS. Public health endpoints are limited to 10 requests/minute/IP and cached server-side for 60 seconds, wrong admin-metrics bearer attempts to 5 requests/minute/IP, and room discovery to one request every 10 seconds per socket. Real client IP is taken from the trusted reverse proxy hop, so the Node port must stay private behind Caddy or another trusted proxy.
- **Room Creation Lock**: Per-room mutex prevents race conditions when multiple peers join a new room simultaneously.
- **CORS**: Allows `chrome-extension://` origins for WebSocket fallback compatibility.
- **Message Buffer**: `maxHttpBufferSize` set to 4KB to accommodate large `JOIN_ROOM` payloads.
+10
View File
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import {
buildHealthPayload,
checkCooldown,
getCachedPayload,
isAdminMetricsAuthorized,
isAdminMetricsTokenStrong
} from '../server/ops.js';
@@ -31,6 +32,15 @@ assert.equal(checkCooldown(cooldowns, 'socket-1', 10_000, 100_000), true, 'first
assert.equal(checkCooldown(cooldowns, 'socket-1', 10_000, 105_000), false, 'second cooldown check inside window fails');
assert.equal(checkCooldown(cooldowns, 'socket-1', 10_000, 110_000), true, 'cooldown check after window passes');
const cache = new Map();
let buildCalls = 0;
const firstCached = getCachedPayload(cache, 'basic-health', 60_000, () => ({ value: ++buildCalls }), 1_000);
const secondCached = getCachedPayload(cache, 'basic-health', 60_000, () => ({ value: ++buildCalls }), 30_000);
const expiredCached = getCachedPayload(cache, 'basic-health', 60_000, () => ({ value: ++buildCalls }), 61_001);
assert.deepEqual(firstCached, { value: 1 }, 'cache should return the builder payload on first request');
assert.strictEqual(secondCached, firstCached, 'cache should reuse payloads inside the ttl');
assert.deepEqual(expiredCached, { value: 2 }, 'cache should rebuild payloads after ttl expiry');
const roomA = { peers: new Set(['a', 'b']), activeLobby: null };
const roomB = { peers: new Set(['c', 'd', 'e']), activeLobby: { expectedTitle: 'Episode 2' } };
const rooms = new Map([['room-a', roomA], ['room-b', roomB]]);
+2 -2
View File
@@ -22,7 +22,7 @@ ADMIN_METRICS_TOKEN=
```
### Health & Metrics
`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.
`GET /` and `GET /health` are IP-rate-limited to 10 requests per minute per client IP. These health-style responses are cached server-side for 60 seconds and refreshed lazily on request. 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. 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.
@@ -48,7 +48,7 @@ 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.
- **Health Endpoint Throttle**: `GET /` and `GET /health` are limited to 10 requests per minute per IP, with 60-second lazy server-side response caching and 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.
+20 -4
View File
@@ -7,6 +7,7 @@ import { EVENTS, OFFICIAL_SERVER_TOKEN, PROTOCOL_VERSION } from '../shared/const
import {
buildHealthPayload,
checkCooldown,
getCachedPayload,
isAdminMetricsAuthorized,
isAdminMetricsTokenStrong
} from './ops.js';
@@ -25,8 +26,9 @@ 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 HEALTH_RATE_LIMIT_PER_MINUTE = 10;
const ADMIN_METRICS_AUTH_RATE_LIMIT_PER_MINUTE = 5;
const HEALTH_RESPONSE_CACHE_TTL_MS = 60000;
if (!isAdminMetricsTokenStrong(ADMIN_METRICS_TOKEN)) {
console.warn('[SECURITY] ADMIN_METRICS_TOKEN is set but shorter than 32 characters. Use a long random token.');
@@ -35,13 +37,21 @@ if (!isAdminMetricsTokenStrong(ADMIN_METRICS_TOKEN)) {
const app = express();
app.set('trust proxy', 1); // For real client IP through reverse proxy
const healthResponseCache = new Map();
// Health Check with Rate Limiting
app.get('/', (req, res) => {
const clientIp = req.ip;
if (!checkHealthRate(clientIp)) {
return res.status(429).json({ error: 'Too many requests. Try again later.' });
}
res.json({ status: 'online', service: 'KoalaSync Relay' });
res.set('Cache-Control', 'no-store');
res.json(getCachedPayload(
healthResponseCache,
'root',
HEALTH_RESPONSE_CACHE_TTL_MS,
() => ({ status: 'online', service: 'KoalaSync Relay' })
));
});
app.get('/health', (req, res) => {
@@ -54,7 +64,12 @@ app.get('/health', (req, res) => {
if (ADMIN_METRICS_TOKEN && authHeader && !includeMetrics && !checkAdminMetricsAuthRate(clientIp)) {
return res.status(429).json({ error: 'Rate limited' });
}
res.json(buildHealthPayload({
res.set('Cache-Control', 'no-store');
res.json(getCachedPayload(
healthResponseCache,
includeMetrics ? 'health-admin' : 'health-basic',
HEALTH_RESPONSE_CACHE_TTL_MS,
() => buildHealthPayload({
rooms,
connections: io.engine?.clientsCount ?? 0,
includeMetrics,
@@ -67,7 +82,8 @@ app.get('/health', (req, res) => {
authFailures: failedAuthAttempts.size,
roomList: roomListCooldowns.size
}
}));
})
));
});
const httpServer = createServer(app);
+11
View File
@@ -9,6 +9,17 @@ export function checkCooldown(cooldowns, key, windowMs, now = Date.now()) {
return true;
}
export function getCachedPayload(cache, key, ttlMs, buildPayload, now = Date.now()) {
const cached = cache.get(key);
if (cached && now - cached.createdAt < ttlMs) {
return cached.payload;
}
const payload = buildPayload();
cache.set(key, { createdAt: now, payload });
return payload;
}
export function isAdminMetricsAuthorized(authHeader, adminToken) {
if (!adminToken || typeof adminToken !== 'string') return false;
if (!authHeader || typeof authHeader !== 'string') return false;