diff --git a/README.md b/README.md index 4b64962..680640d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs index 2dbb534..5f0b9b7 100644 --- a/ecosystem.config.cjs +++ b/ecosystem.config.cjs @@ -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 || '', }, }, diff --git a/example.env b/example.env index 25bee70..5e3eb21 100644 --- a/example.env +++ b/example.env @@ -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. diff --git a/server.cjs b/server.cjs index d1eb761..1673b92 100644 --- a/server.cjs +++ b/server.cjs @@ -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 })