fix:/ add api protections :3

This commit is contained in:
Heidi
2026-05-14 16:02:56 +01:00
parent 0ca2dcdcf2
commit a4931d3bbc
4 changed files with 231 additions and 12 deletions
+27 -2
View File
@@ -33,8 +33,18 @@ npm run build
pm2 start ecosystem.config.cjs
```
The production server runs on <http://localhost:3001> and proxies `/api/*` plus
`/health` to `API_UPSTREAM`, which defaults to `http://127.0.0.1:6000`.
The production server runs on <http://localhost:3010>. 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
+4
View File
@@ -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,
},
},
{
+4
View File
@@ -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
+196 -10
View File
@@ -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
}