rape
This commit is contained in:
@@ -120,8 +120,15 @@ VITE_STATIC_DATA=false
|
|||||||
VITE_SITE_GATE=false
|
VITE_SITE_GATE=false
|
||||||
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_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
|
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
|
||||||
recent games. `/health` includes a `public_data` block with the latest preload
|
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,
|
API_RATE_LIMIT_MAX: process.env.API_RATE_LIMIT_MAX || 120,
|
||||||
TRUST_PROXY: process.env.TRUST_PROXY || 'cloudflare',
|
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',
|
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 || '',
|
TURNSTILE_SECRET_KEY: process.env.TURNSTILE_SECRET_KEY || '',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ PUBLIC_DATA_PREWARM_INTERVAL_MS=300000
|
|||||||
PUBLIC_DATA_COLD_TIMEOUT_MS=8000
|
PUBLIC_DATA_COLD_TIMEOUT_MS=8000
|
||||||
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=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).
|
# 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.
|
# "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_VERIFY_TIMEOUT_MS = Number(process.env.TURNSTILE_VERIFY_TIMEOUT_MS || 5000)
|
||||||
const TURNSTILE_MAX_TOKEN_LENGTH = 2048
|
const TURNSTILE_MAX_TOKEN_LENGTH = 2048
|
||||||
const TURNSTILE_TOKEN_MAX_AGE_MS = 5 * 60 * 1000
|
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 DIST_DIR = path.join(__dirname, 'dist')
|
||||||
const VEHICLE_ICONS_DIR = path.resolve(
|
const VEHICLE_ICONS_DIR = path.resolve(
|
||||||
__dirname,
|
__dirname,
|
||||||
@@ -1173,6 +1174,16 @@ const TURNSTILE_SESSION_HMAC_KEY = TURNSTILE_SECRET_KEY
|
|||||||
.digest()
|
.digest()
|
||||||
: null
|
: 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) {
|
function signTurnstileSession(expiresAt) {
|
||||||
if (!TURNSTILE_SESSION_HMAC_KEY) return ''
|
if (!TURNSTILE_SESSION_HMAC_KEY) return ''
|
||||||
return crypto
|
return crypto
|
||||||
@@ -1211,6 +1222,86 @@ function parseRequestCookies(req) {
|
|||||||
return out
|
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) {
|
function isTurnstileSessionVerified(req) {
|
||||||
if (!TURNSTILE_SECRET_KEY) return true
|
if (!TURNSTILE_SECRET_KEY) return true
|
||||||
if (!TURNSTILE_SESSION_HMAC_KEY) return false
|
if (!TURNSTILE_SESSION_HMAC_KEY) return false
|
||||||
@@ -2378,10 +2469,12 @@ 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 } : {}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2777,6 +2870,10 @@ const server = http.createServer((req, res) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isProtectedDataPath(req) && !requireSiteSession(req, res)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (req.method === 'GET' && req.url === '/api/uptime') {
|
if (req.method === 'GET' && req.url === '/api/uptime') {
|
||||||
if (isRateLimited(req)) {
|
if (isRateLimited(req)) {
|
||||||
sendJson(res, 429, { error: 'Too many requests' }, { 'retry-after': String(Math.ceil(API_RATE_LIMIT_WINDOW_MS / 1000)) })
|
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) {
|
if (!TURNSTILE_SECRET_KEY) {
|
||||||
console.warn('TURNSTILE_SECRET_KEY is not set — Turnstile verification is disabled and gated endpoints will accept any request')
|
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) {
|
if (RUN_BACKGROUND_JOBS) {
|
||||||
startUptimeSampler()
|
startUptimeSampler()
|
||||||
fs.mkdirSync(PUBLIC_DATA_CACHE_DIR, { recursive: true })
|
fs.mkdirSync(PUBLIC_DATA_CACHE_DIR, { recursive: true })
|
||||||
|
|||||||
Reference in New Issue
Block a user