rape
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 || '',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user