This commit is contained in:
Clippii
2026-06-28 12:56:48 +01:00
parent 92c7185036
commit 2737cc7c03
4 changed files with 111 additions and 0 deletions
+7
View File
@@ -120,8 +120,15 @@ VITE_STATIC_DATA=false
VITE_SITE_GATE=false
API_RATE_LIMIT_WINDOW_MS=60000
API_RATE_LIMIT_MAX=120
SITE_SESSION_SECRET=long-random-shared-secret
SITE_SESSION_TTL_SECONDS=43200
```
HTML responses set a signed, HttpOnly site-session cookie. `/api/*` and `/data/*`
requests must present that cookie and same-origin browser request metadata, so the
data is served to active 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
signalling PM2 `ready`: team leaderboard, player leaderboard, home teams, and
recent games. `/health` includes a `public_data` block with the latest preload
+2
View File
@@ -55,6 +55,8 @@ module.exports = {
API_RATE_LIMIT_MAX: process.env.API_RATE_LIMIT_MAX || 120,
TRUST_PROXY: process.env.TRUST_PROXY || 'cloudflare',
TRUSTED_UPSTREAM_IPS: process.env.TRUSTED_UPSTREAM_IPS || '127.0.0.1,::1,::ffff:127.0.0.1',
SITE_SESSION_SECRET: process.env.SITE_SESSION_SECRET || process.env.API_SESSION_SECRET || process.env.TURNSTILE_SECRET_KEY || '',
SITE_SESSION_TTL_SECONDS: process.env.SITE_SESSION_TTL_SECONDS || 43200,
TURNSTILE_SECRET_KEY: process.env.TURNSTILE_SECRET_KEY || '',
},
},
+2
View File
@@ -40,6 +40,8 @@ PUBLIC_DATA_PREWARM_INTERVAL_MS=300000
PUBLIC_DATA_COLD_TIMEOUT_MS=8000
API_RATE_LIMIT_WINDOW_MS=60000
API_RATE_LIMIT_MAX=120
SITE_SESSION_SECRET=change-me-to-a-long-random-secret
SITE_SESSION_TTL_SECONDS=43200
# Proxy trust. Set to "cloudflare" when the app is fronted by Cloudflare (possibly via nginx).
# "generic" trusts X-Forwarded-* without CF-specific headers. "none" ignores all proxy headers.
+100
View File
@@ -64,6 +64,7 @@ const TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/sit
const TURNSTILE_VERIFY_TIMEOUT_MS = Number(process.env.TURNSTILE_VERIFY_TIMEOUT_MS || 5000)
const TURNSTILE_MAX_TOKEN_LENGTH = 2048
const TURNSTILE_TOKEN_MAX_AGE_MS = 5 * 60 * 1000
const SITE_SESSION_SECRET = process.env.SITE_SESSION_SECRET || process.env.API_SESSION_SECRET || TURNSTILE_SECRET_KEY
const DIST_DIR = path.join(__dirname, 'dist')
const VEHICLE_ICONS_DIR = path.resolve(
__dirname,
@@ -1173,6 +1174,16 @@ const TURNSTILE_SESSION_HMAC_KEY = TURNSTILE_SECRET_KEY
.digest()
: null
const SITE_SESSION_COOKIE_BASE = 'tssbot_site'
const SITE_SESSION_COOKIE_HOST = `__Host-${SITE_SESSION_COOKIE_BASE}`
const SITE_SESSION_TTL_SECONDS = Number(process.env.SITE_SESSION_TTL_SECONDS || 12 * 60 * 60)
const SITE_SESSION_HMAC_KEY = SITE_SESSION_SECRET
? crypto
.createHash('sha256')
.update(`tssbot-site-session|${SITE_SESSION_SECRET}`)
.digest()
: null
function signTurnstileSession(expiresAt) {
if (!TURNSTILE_SESSION_HMAC_KEY) return ''
return crypto
@@ -1211,6 +1222,86 @@ function parseRequestCookies(req) {
return out
}
function signSiteSession(expiresAt, nonce) {
if (!SITE_SESSION_HMAC_KEY) return ''
return crypto
.createHmac('sha256', SITE_SESSION_HMAC_KEY)
.update(`${expiresAt}.${nonce}`)
.digest('base64url')
}
function buildSiteSessionCookie(req) {
if (!SITE_SESSION_HMAC_KEY) return ''
const expiresAt = Math.floor(Date.now() / 1000) + SITE_SESSION_TTL_SECONDS
const nonce = crypto.randomBytes(16).toString('base64url')
const signature = signSiteSession(expiresAt, nonce)
const value = `${expiresAt}.${nonce}.${signature}`
const isHttps = isHttpsRequest(req)
const name = isHttps ? SITE_SESSION_COOKIE_HOST : SITE_SESSION_COOKIE_BASE
const parts = [
`${name}=${value}`,
'Path=/',
`Max-Age=${SITE_SESSION_TTL_SECONDS}`,
'HttpOnly',
'SameSite=Strict',
]
if (isHttps) parts.push('Secure')
return parts.join('; ')
}
function isSiteSessionVerified(req) {
if (!SITE_SESSION_HMAC_KEY) return false
const cookies = parseRequestCookies(req)
const cookie = cookies[SITE_SESSION_COOKIE_HOST] || cookies[SITE_SESSION_COOKIE_BASE]
if (!cookie) return false
const parts = cookie.split('.')
if (parts.length !== 3) return false
const [expiresAtStr, nonce, signature] = parts
const expiresAt = Number(expiresAtStr)
if (!Number.isFinite(expiresAt) || expiresAt < Math.floor(Date.now() / 1000)) return false
if (!/^[A-Za-z0-9_-]{16,64}$/.test(nonce)) return false
const expected = signSiteSession(expiresAt, nonce)
if (!expected || signature.length !== expected.length) return false
try {
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
} catch {
return false
}
}
function isProtectedDataPath(req) {
if (!req.url) return false
try {
const pathname = new URL(req.url, `http://${req.headers.host || 'localhost'}`).pathname
return pathname.startsWith('/api/') || pathname.startsWith('/data/')
} catch {
return req.url.startsWith('/api/') || req.url.startsWith('/data/')
}
}
function requireSiteSession(req, res) {
if (!isProtectedDataPath(req)) return true
if (!SITE_SESSION_HMAC_KEY) {
sendJson(res, 503, { error: 'Site session signing is not configured' })
return false
}
if (!isSameOriginRequest(req)) {
sendJson(res, 403, { error: 'Data access is restricted to this site' })
return false
}
if (!isSiteSessionVerified(req)) {
sendJson(res, 403, { error: 'Site session required' })
return false
}
return true
}
function isTurnstileSessionVerified(req) {
if (!TURNSTILE_SECRET_KEY) return true
if (!TURNSTILE_SESSION_HMAC_KEY) return false
@@ -2378,10 +2469,12 @@ function htmlWithSeo(req, data) {
function sendHtml(req, res, data, status = 200) {
const html = htmlWithSeo(req, data)
const siteSessionCookie = buildSiteSessionCookie(req)
send(res, status, html, {
...securityHeaders(req, { html: true }),
'content-type': mimeTypes['.html'],
'cache-control': 'no-cache',
...(siteSessionCookie ? { 'set-cookie': siteSessionCookie } : {}),
})
}
@@ -2777,6 +2870,10 @@ const server = http.createServer((req, res) => {
return
}
if (isProtectedDataPath(req) && !requireSiteSession(req, res)) {
return
}
if (req.method === 'GET' && req.url === '/api/uptime') {
if (isRateLimited(req)) {
sendJson(res, 429, { error: 'Too many requests' }, { 'retry-after': String(Math.ceil(API_RATE_LIMIT_WINDOW_MS / 1000)) })
@@ -2965,6 +3062,9 @@ server.listen(PORT, '0.0.0.0', async () => {
if (!TURNSTILE_SECRET_KEY) {
console.warn('TURNSTILE_SECRET_KEY is not set — Turnstile verification is disabled and gated endpoints will accept any request')
}
if (!SITE_SESSION_HMAC_KEY) {
console.warn('SITE_SESSION_SECRET, API_SESSION_SECRET, or TURNSTILE_SECRET_KEY must be set for protected API/data access')
}
if (RUN_BACKGROUND_JOBS) {
startUptimeSampler()
fs.mkdirSync(PUBLIC_DATA_CACHE_DIR, { recursive: true })