fix:/ add api protections :3
This commit is contained in:
@@ -33,8 +33,18 @@ npm run build
|
|||||||
pm2 start ecosystem.config.cjs
|
pm2 start ecosystem.config.cjs
|
||||||
```
|
```
|
||||||
|
|
||||||
The production server runs on <http://localhost:3001> and proxies `/api/*` plus
|
The production server runs on <http://localhost:3010>. It serves `/health`
|
||||||
`/health` to `API_UPSTREAM`, which defaults to `http://127.0.0.1:6000`.
|
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:
|
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
|
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
|
## GitHub webhook
|
||||||
|
|
||||||
The webhook process listens on port `3011` at `/github`. Configure GitHub to send
|
The webhook process listens on port `3011` at `/github`. Configure GitHub to send
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ module.exports = {
|
|||||||
NODE_ENV: 'production',
|
NODE_ENV: 'production',
|
||||||
PORT: process.env.PORT || 3010,
|
PORT: process.env.PORT || 3010,
|
||||||
API_UPSTREAM: process.env.API_UPSTREAM || 'http://127.0.0.1:6000',
|
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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ NODE_ENV=production
|
|||||||
|
|
||||||
PORT=3010
|
PORT=3010
|
||||||
API_UPSTREAM=http://127.0.0.1:6000
|
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
|
WEBHOOK_PORT=3011
|
||||||
GITHUB_WEBHOOK_SECRET=change-me
|
GITHUB_WEBHOOK_SECRET=change-me
|
||||||
|
|||||||
+196
-10
@@ -4,7 +4,14 @@ const path = require('node:path')
|
|||||||
|
|
||||||
const PORT = Number(process.env.PORT || 3001)
|
const PORT = Number(process.env.PORT || 3001)
|
||||||
const API_UPSTREAM = process.env.API_UPSTREAM || 'http://127.0.0.1:6000'
|
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 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 = {
|
const mimeTypes = {
|
||||||
'.css': 'text/css; charset=utf-8',
|
'.css': 'text/css; charset=utf-8',
|
||||||
@@ -23,30 +30,199 @@ function send(res, status, body, headers = {}) {
|
|||||||
res.end(body)
|
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) {
|
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(
|
const proxy = http.request(
|
||||||
target,
|
target,
|
||||||
{
|
{
|
||||||
method: req.method,
|
method: req.method,
|
||||||
headers: {
|
headers: {
|
||||||
...req.headers,
|
accept: 'application/json',
|
||||||
host: target.host,
|
host: target.host,
|
||||||
|
'user-agent': req.headers['user-agent'] || 'tssbot-web',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
(proxyRes) => {
|
(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)
|
proxyRes.pipe(res)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
proxy.on('error', (error) => {
|
proxy.on('error', (error) => {
|
||||||
send(
|
sendJson(res, 502, { error: 'API proxy failed', detail: error.message })
|
||||||
res,
|
|
||||||
502,
|
|
||||||
JSON.stringify({ error: 'API proxy failed', detail: error.message }),
|
|
||||||
{ 'content-type': 'application/json; charset=utf-8' },
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
req.pipe(proxy)
|
req.pipe(proxy)
|
||||||
@@ -85,7 +261,17 @@ function serveStatic(req, res) {
|
|||||||
|
|
||||||
http
|
http
|
||||||
.createServer((req, res) => {
|
.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)
|
proxyRequest(req, res)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user