From a4931d3bbc4dad22e8c6859a4af847326fd48a05 Mon Sep 17 00:00:00 2001 From: Heidi Date: Thu, 14 May 2026 16:02:56 +0100 Subject: [PATCH] fix:/ add api protections :3 --- README.md | 29 +++++- ecosystem.config.cjs | 4 + example.env | 4 + server.cjs | 206 ++++++++++++++++++++++++++++++++++++++++--- 4 files changed, 231 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index a2f2585..9fe45cf 100644 --- a/README.md +++ b/README.md @@ -33,8 +33,18 @@ npm run build pm2 start ecosystem.config.cjs ``` -The production server runs on and proxies `/api/*` plus -`/health` to `API_UPSTREAM`, which defaults to `http://127.0.0.1:6000`. +The production server runs on . It serves `/health` +locally and only proxies the API routes used by the app: + +- `GET /api/tss/leaderboard/teams?limit=1..100` +- `GET /api/tss/teams/resolve?name=...` +- `GET /api/tss/teams/:team` +- `GET /api/tss/teams/:team/history` +- `GET /api/tss/teams/:team/games` + +The proxy blocks cross-origin/API-navigation requests, strips CORS headers from +the upstream response, rate limits callers, and caches successful GET responses +briefly so public page traffic does not hammer the upstream API. Override the API target before starting PM2 if needed: @@ -42,6 +52,21 @@ Override the API target before starting PM2 if needed: API_UPSTREAM=http://127.0.0.1:8080 pm2 start ecosystem.config.cjs ``` +Set `PUBLIC_ORIGIN` to the public site origin in production, especially behind a +reverse proxy: + +```sh +PUBLIC_ORIGIN=https://your-domain.example pm2 start ecosystem.config.cjs +``` + +Optional API protection tuning: + +```sh +API_CACHE_TTL_MS=15000 +API_RATE_LIMIT_WINDOW_MS=60000 +API_RATE_LIMIT_MAX=120 +``` + ## GitHub webhook The webhook process listens on port `3011` at `/github`. Configure GitHub to send diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs index ad19f4e..4872326 100644 --- a/ecosystem.config.cjs +++ b/ecosystem.config.cjs @@ -8,6 +8,10 @@ module.exports = { NODE_ENV: 'production', PORT: process.env.PORT || 3010, API_UPSTREAM: process.env.API_UPSTREAM || 'http://127.0.0.1:6000', + PUBLIC_ORIGIN: process.env.PUBLIC_ORIGIN || '', + API_CACHE_TTL_MS: process.env.API_CACHE_TTL_MS || 15000, + API_RATE_LIMIT_WINDOW_MS: process.env.API_RATE_LIMIT_WINDOW_MS || 60000, + API_RATE_LIMIT_MAX: process.env.API_RATE_LIMIT_MAX || 120, }, }, { diff --git a/example.env b/example.env index ab79ded..bcf00b4 100644 --- a/example.env +++ b/example.env @@ -2,6 +2,10 @@ NODE_ENV=production PORT=3010 API_UPSTREAM=http://127.0.0.1:6000 +PUBLIC_ORIGIN=https://example.com +API_CACHE_TTL_MS=15000 +API_RATE_LIMIT_WINDOW_MS=60000 +API_RATE_LIMIT_MAX=120 WEBHOOK_PORT=3011 GITHUB_WEBHOOK_SECRET=change-me diff --git a/server.cjs b/server.cjs index fffc4f6..d6c18ea 100644 --- a/server.cjs +++ b/server.cjs @@ -4,7 +4,14 @@ const path = require('node:path') const PORT = Number(process.env.PORT || 3001) const API_UPSTREAM = process.env.API_UPSTREAM || 'http://127.0.0.1:6000' +const PUBLIC_ORIGIN = process.env.PUBLIC_ORIGIN || '' +const API_CACHE_TTL_MS = Number(process.env.API_CACHE_TTL_MS || 15000) +const API_RATE_LIMIT_WINDOW_MS = Number(process.env.API_RATE_LIMIT_WINDOW_MS || 60000) +const API_RATE_LIMIT_MAX = Number(process.env.API_RATE_LIMIT_MAX || 120) const DIST_DIR = path.join(__dirname, 'dist') +const MAX_TEAM_NAME_LENGTH = 80 +const MAX_CACHE_ENTRIES = 200 +const MAX_RATE_LIMIT_KEYS = 1000 const mimeTypes = { '.css': 'text/css; charset=utf-8', @@ -23,30 +30,199 @@ function send(res, status, body, headers = {}) { res.end(body) } +const jsonHeaders = { + 'content-type': 'application/json; charset=utf-8', + 'cache-control': 'no-store', + 'x-content-type-options': 'nosniff', +} + +const apiCache = new Map() +const rateLimits = new Map() + +function sendJson(res, status, body, headers = {}) { + send(res, status, JSON.stringify(body), { ...jsonHeaders, ...headers }) +} + +function publicOrigins(req) { + const origins = PUBLIC_ORIGIN.split(',') + .map((origin) => origin.trim()) + .filter(Boolean) + + if (!origins.length) { + const host = req.headers['x-forwarded-host'] || req.headers.host + const proto = req.headers['x-forwarded-proto'] || (req.socket.encrypted ? 'https' : 'http') + + if (host) { + origins.push(`${proto}://${String(host).split(',')[0].trim()}`) + } + } + + return origins +} + +function isSameOriginRequest(req) { + const origins = publicOrigins(req) + const origin = req.headers.origin + const referer = req.headers.referer + const fetchSite = req.headers['sec-fetch-site'] + const fetchDest = req.headers['sec-fetch-dest'] + + if (fetchDest === 'document') return false + if (origin && !origins.includes(origin)) return false + + if (referer) { + try { + if (!origins.includes(new URL(referer).origin)) return false + } catch { + return false + } + } + + if (fetchSite) { + return fetchSite === 'same-origin' + } + + return Boolean(origin || referer) +} + +function clientIp(req) { + const forwardedFor = req.headers['x-forwarded-for'] + if (forwardedFor) return String(forwardedFor).split(',')[0].trim() + return req.socket.remoteAddress || 'unknown' +} + +function isRateLimited(req) { + const now = Date.now() + const ip = clientIp(req) + const current = rateLimits.get(ip) + + if (!current || current.resetAt <= now) { + rateLimits.set(ip, { count: 1, resetAt: now + API_RATE_LIMIT_WINDOW_MS }) + return false + } + + current.count += 1 + return current.count > API_RATE_LIMIT_MAX +} + +function pruneMaps() { + const now = Date.now() + + for (const [key, value] of apiCache) { + if (value.expiresAt <= now || apiCache.size > MAX_CACHE_ENTRIES) apiCache.delete(key) + } + + for (const [key, value] of rateLimits) { + if (value.resetAt <= now || rateLimits.size > MAX_RATE_LIMIT_KEYS) rateLimits.delete(key) + } +} + +function allowedApiTarget(req) { + if (req.method !== 'GET' && req.method !== 'HEAD') return null + + const requestUrl = new URL(req.url, `http://${req.headers.host || 'localhost'}`) + const url = new URL(`${requestUrl.pathname}${requestUrl.search}`, API_UPSTREAM) + const params = requestUrl.searchParams + const pathname = requestUrl.pathname + + if (pathname === '/api/tss/leaderboard/teams') { + const keys = [...params.keys()] + const limit = Number(params.get('limit') || 100) + if (keys.some((key) => key !== 'limit') || !Number.isInteger(limit) || limit < 1 || limit > 100) { + return null + } + return url + } + + if (pathname === '/api/tss/teams/resolve') { + const keys = [...params.keys()] + const name = params.get('name') || '' + if (keys.some((key) => key !== 'name') || name.length < 2 || name.length > MAX_TEAM_NAME_LENGTH) { + return null + } + return url + } + + const teamMatch = pathname.match(/^\/api\/tss\/teams\/([^/]+)(?:\/(history|games))?$/) + if (!teamMatch || [...params.keys()].length) return null + + try { + const teamName = decodeURIComponent(teamMatch[1]) + if (!teamName || teamName.length > MAX_TEAM_NAME_LENGTH) return null + } catch { + return null + } + + return url +} + function proxyRequest(req, res) { - const target = new URL(req.url, API_UPSTREAM) + pruneMaps() + + if (!isSameOriginRequest(req)) { + return sendJson(res, 403, { error: 'API access is restricted to this site' }) + } + + if (isRateLimited(req)) { + return sendJson(res, 429, { error: 'Too many API requests' }, { 'retry-after': String(Math.ceil(API_RATE_LIMIT_WINDOW_MS / 1000)) }) + } + + const target = allowedApiTarget(req) + if (!target) { + return sendJson(res, 404, { error: 'API route not found' }) + } + + const cacheKey = req.method === 'GET' ? target.toString() : '' + const cached = cacheKey ? apiCache.get(cacheKey) : null + if (cached && cached.expiresAt > Date.now()) { + return send(res, 200, cached.body, cached.headers) + } + + const responseChunks = [] const proxy = http.request( target, { method: req.method, headers: { - ...req.headers, + accept: 'application/json', host: target.host, + 'user-agent': req.headers['user-agent'] || 'tssbot-web', }, }, (proxyRes) => { - res.writeHead(proxyRes.statusCode || 502, proxyRes.headers) + const headers = { + ...proxyRes.headers, + 'cache-control': 'private, max-age=15', + 'x-content-type-options': 'nosniff', + } + + delete headers['access-control-allow-origin'] + delete headers['access-control-allow-credentials'] + + res.writeHead(proxyRes.statusCode || 502, headers) + + proxyRes.on('data', (chunk) => { + if (cacheKey && (proxyRes.statusCode || 0) >= 200 && (proxyRes.statusCode || 0) < 300) { + responseChunks.push(chunk) + } + }) + + proxyRes.on('end', () => { + if (cacheKey && responseChunks.length) { + apiCache.set(cacheKey, { + body: Buffer.concat(responseChunks), + headers, + expiresAt: Date.now() + API_CACHE_TTL_MS, + }) + } + }) + proxyRes.pipe(res) }, ) proxy.on('error', (error) => { - send( - res, - 502, - JSON.stringify({ error: 'API proxy failed', detail: error.message }), - { 'content-type': 'application/json; charset=utf-8' }, - ) + sendJson(res, 502, { error: 'API proxy failed', detail: error.message }) }) req.pipe(proxy) @@ -85,7 +261,17 @@ function serveStatic(req, res) { http .createServer((req, res) => { - if (req.url === '/health' || req.url.startsWith('/api/')) { + if (req.url === '/health') { + sendJson(res, 200, { ok: true }) + return + } + + if (req.method === 'OPTIONS' && req.url.startsWith('/api/')) { + sendJson(res, 403, { error: 'CORS requests are not allowed' }) + return + } + + if (req.url.startsWith('/api/')) { proxyRequest(req, res) return }