updates to api protections

This commit is contained in:
2026-06-28 16:01:57 +01:00
parent a274313ad1
commit 109eeebfb1
4 changed files with 32 additions and 10 deletions
+6 -5
View File
@@ -117,17 +117,18 @@ PUBLIC_DATA_CACHE_STALE_MS=86400000
PUBLIC_DATA_PREWARM_INTERVAL_MS=300000 PUBLIC_DATA_PREWARM_INTERVAL_MS=300000
PUBLIC_DATA_COLD_TIMEOUT_MS=8000 PUBLIC_DATA_COLD_TIMEOUT_MS=8000
VITE_STATIC_DATA=false VITE_STATIC_DATA=false
VITE_SITE_GATE=false VITE_SITE_GATE=true
API_RATE_LIMIT_WINDOW_MS=60000 API_RATE_LIMIT_WINDOW_MS=60000
API_RATE_LIMIT_MAX=120 API_RATE_LIMIT_MAX=120
SITE_SESSION_SECRET=long-random-shared-secret SITE_SESSION_SECRET=long-random-shared-secret
SITE_SESSION_TTL_SECONDS=43200 SITE_SESSION_TTL_SECONDS=43200
``` ```
HTML responses set a signed, HttpOnly site-session cookie. `/api/*` and `/data/*` Successful Turnstile verification sets signed, HttpOnly Turnstile and site-session
requests must present that cookie and same-origin browser request metadata, so the cookies. `/api/*` and `/data/*` requests must present those cookies plus
data is served to active site sessions instead of as an open public API. All PM2 same-origin browser request metadata, so the data is served to verified active
web instances must share the same `SITE_SESSION_SECRET`. site sessions instead of as an open public API. All PM2 web instances must share
the same `SITE_SESSION_SECRET`.
On startup, the web server preloads the critical public snapshots before On startup, the web server preloads the critical public snapshots before
signalling PM2 `ready`: team leaderboard, player leaderboard, home teams, and signalling PM2 `ready`: team leaderboard, player leaderboard, home teams, and
+1 -1
View File
@@ -64,5 +64,5 @@ DISCORD_INCLUDE_PATCH=true
# TURNSTILE_SECRET_KEY is the server-only secret used to call the Siteverify endpoint. # TURNSTILE_SECRET_KEY is the server-only secret used to call the Siteverify endpoint.
VITE_TURNSTILE_SITE_KEY= VITE_TURNSTILE_SITE_KEY=
TURNSTILE_SECRET_KEY= TURNSTILE_SECRET_KEY=
VITE_SITE_GATE=false VITE_SITE_GATE=true
VITE_STATIC_DATA=false VITE_STATIC_DATA=false
+4 -1
View File
@@ -59,7 +59,10 @@ const liveRefreshMs = 15000
const siteVersion = import.meta.env.VITE_SITE_VERSION || '1.0.0' const siteVersion = import.meta.env.VITE_SITE_VERSION || '1.0.0'
const turnstileSiteKey = import.meta.env.VITE_TURNSTILE_SITE_KEY || '' const turnstileSiteKey = import.meta.env.VITE_TURNSTILE_SITE_KEY || ''
const siteGateEnabled = String(import.meta.env.VITE_SITE_GATE || 'false').toLowerCase() === 'true' const siteGateSetting = import.meta.env.VITE_SITE_GATE
const siteGateEnabled = siteGateSetting == null
? Boolean(turnstileSiteKey)
: String(siteGateSetting).toLowerCase() === 'true'
const staticDataBase = (import.meta.env.VITE_STATIC_DATA_BASE || '/data').replace(/\/+$/, '') const staticDataBase = (import.meta.env.VITE_STATIC_DATA_BASE || '/data').replace(/\/+$/, '')
const staticDataEnabled = String(import.meta.env.VITE_STATIC_DATA || 'false').toLowerCase() === 'true' const staticDataEnabled = String(import.meta.env.VITE_STATIC_DATA || 'false').toLowerCase() === 'true'
const missingStaticDataPaths = new Set() const missingStaticDataPaths = new Set()
+21 -3
View File
@@ -1281,8 +1281,19 @@ function isProtectedDataPath(req) {
} }
} }
function isSiteSessionBootstrapPath(req) {
if (!req.url) return false
try {
const pathname = new URL(req.url, `http://${req.headers.host || 'localhost'}`).pathname
return pathname === '/api/turnstile/verify' || pathname === '/api/turnstile/session'
} catch {
return req.url === '/api/turnstile/verify' || req.url === '/api/turnstile/session'
}
}
function requireSiteSession(req, res) { function requireSiteSession(req, res) {
if (!isProtectedDataPath(req)) return true if (!isProtectedDataPath(req)) return true
if (isSiteSessionBootstrapPath(req)) return true
if (!SITE_SESSION_HMAC_KEY) { if (!SITE_SESSION_HMAC_KEY) {
sendJson(res, 503, { error: 'Site session signing is not configured' }) sendJson(res, 503, { error: 'Site session signing is not configured' })
@@ -1299,6 +1310,11 @@ function requireSiteSession(req, res) {
return false return false
} }
if (!isTurnstileSessionVerified(req)) {
sendJson(res, 403, { error: 'Turnstile session required' })
return false
}
return true return true
} }
@@ -2469,12 +2485,10 @@ function htmlWithSeo(req, data) {
function sendHtml(req, res, data, status = 200) { function sendHtml(req, res, data, status = 200) {
const html = htmlWithSeo(req, data) const html = htmlWithSeo(req, data)
const siteSessionCookie = buildSiteSessionCookie(req)
send(res, status, html, { send(res, status, html, {
...securityHeaders(req, { html: true }), ...securityHeaders(req, { html: true }),
'content-type': mimeTypes['.html'], 'content-type': mimeTypes['.html'],
'cache-control': 'no-cache', 'cache-control': 'no-cache',
...(siteSessionCookie ? { 'set-cookie': siteSessionCookie } : {}),
}) })
} }
@@ -2978,7 +2992,11 @@ const server = http.createServer((req, res) => {
sendJson(res, 403, { error: 'Turnstile verification failed', detail: verification.error }) sendJson(res, 403, { error: 'Turnstile verification failed', detail: verification.error })
return return
} }
const headers = TURNSTILE_SECRET_KEY ? { 'set-cookie': buildTurnstileSessionCookie(req) } : {} const cookies = [
buildTurnstileSessionCookie(req),
buildSiteSessionCookie(req),
].filter(Boolean)
const headers = cookies.length ? { 'set-cookie': cookies } : {}
send(res, 200, JSON.stringify({ success: true, ttl: TURNSTILE_SESSION_TTL_SECONDS }), { send(res, 200, JSON.stringify({ success: true, ttl: TURNSTILE_SESSION_TTL_SECONDS }), {
...jsonHeaders, ...jsonHeaders,
...headers, ...headers,