update osm

This commit is contained in:
Heidi
2026-05-16 09:35:51 +01:00
parent f36bdf3738
commit e44b263f2e
6 changed files with 310 additions and 25 deletions
+5 -1
View File
@@ -1,7 +1,11 @@
{ {
"permissions": { "permissions": {
"allow": [ "allow": [
"Bash(npm run *)" "Bash(npm run *)",
"Bash(npm audit *)",
"Bash(node --check server.cjs)",
"Bash(node --check webhook.cjs)",
"Bash(node --check ecosystem.config.cjs)"
] ]
} }
} }
+41 -3
View File
@@ -45,7 +45,11 @@ locally and only proxies the API routes used by the app:
The proxy blocks cross-origin/API-navigation requests, strips CORS headers from The proxy blocks cross-origin/API-navigation requests, strips CORS headers from
the upstream response, rate limits callers, and caches successful GET responses the upstream response, rate limits callers, and caches successful GET responses
briefly so public page traffic does not hammer the upstream API. briefly so public page traffic does not hammer the upstream API. All responses
ship `X-Content-Type-Options`, `X-Frame-Options: DENY`, `Referrer-Policy`,
`Permissions-Policy`, `Cross-Origin-Opener-Policy`, `Cross-Origin-Resource-Policy`,
HSTS (over HTTPS), and HTML responses include a Content Security Policy that
allows only Cloudflare Turnstile and the CARTO basemap tiles.
Override the API target before starting PM2 if needed: Override the API target before starting PM2 if needed:
@@ -68,6 +72,22 @@ API_RATE_LIMIT_WINDOW_MS=60000
API_RATE_LIMIT_MAX=120 API_RATE_LIMIT_MAX=120
``` ```
## Reverse proxy / Cloudflare
The server only honours `CF-Connecting-IP`, `X-Forwarded-For`, `X-Forwarded-Proto`,
and the Cloudflare geolocation headers when the immediate TCP peer is listed in
`TRUSTED_UPSTREAM_IPS`. Bind the server to `127.0.0.1` (or leave it on `0.0.0.0`
behind a firewall) and front it with nginx/Cloudflare for the configuration to
take effect:
```sh
TRUST_PROXY=cloudflare
TRUSTED_UPSTREAM_IPS=127.0.0.1,::1,::ffff:127.0.0.1
```
Set `TRUST_PROXY=none` if the server is exposed directly. Without it, an attacker
that reaches the app port can spoof client-IP headers to bypass rate limiting.
## Uptime snapshots ## Uptime snapshots
The production server samples uptime every 30 minutes and exposes the history at The production server samples uptime every 30 minutes and exposes the history at
@@ -120,14 +140,32 @@ setup, and actual data fields.
The webhook process listens on port `3011` at `/github`. Configure GitHub to send The webhook process listens on port `3011` at `/github`. Configure GitHub to send
push events there. push events there.
Set a webhook secret before starting PM2 if you want signature validation: A webhook secret is required — without `GITHUB_WEBHOOK_SECRET`, the webhook
rejects every request:
```sh ```sh
GITHUB_WEBHOOK_SECRET=your-secret pm2 start ecosystem.config.cjs GITHUB_WEBHOOK_SECRET=your-secret pm2 start ecosystem.config.cjs
``` ```
On PowerShell, set `$env:GITHUB_WEBHOOK_SECRET = "your-secret"` before starting On PowerShell, set `$env:GITHUB_WEBHOOK_SECRET = "your-secret"` before starting
PM2, or put the value directly in `ecosystem.config.cjs`. PM2, or put the value in a `.env` file in the project root (recommended over
inlining the secret in a shell command, which writes it to shell history).
The webhook only deploys pushes whose `ref` is in `GITHUB_WEBHOOK_REFS`
(default `refs/heads/main`). Optionally pin the repository:
```sh
GITHUB_WEBHOOK_REFS=refs/heads/main
GITHUB_WEBHOOK_REPOSITORY=owner/repo
```
Deploys run `npm ci` (not `npm install`) so an attacker who compromises a
dependency cannot quietly add new packages — the lockfile is the source of
truth.
Set `DISCORD_INCLUDE_PATCH=true` only if the Discord channel is private; by
default the patch preview is omitted from Discord notifications to avoid
leaking diff contents.
The default deploy flow is: The default deploy flow is:
+6
View File
@@ -16,6 +16,9 @@ module.exports = {
API_CACHE_TTL_MS: process.env.API_CACHE_TTL_MS || 15000, API_CACHE_TTL_MS: process.env.API_CACHE_TTL_MS || 15000,
API_RATE_LIMIT_WINDOW_MS: process.env.API_RATE_LIMIT_WINDOW_MS || 60000, API_RATE_LIMIT_WINDOW_MS: process.env.API_RATE_LIMIT_WINDOW_MS || 60000,
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',
TRUSTED_UPSTREAM_IPS: process.env.TRUSTED_UPSTREAM_IPS || '127.0.0.1,::1,::ffff:127.0.0.1',
TURNSTILE_SECRET_KEY: process.env.TURNSTILE_SECRET_KEY || '',
}, },
}, },
{ {
@@ -27,8 +30,11 @@ module.exports = {
NODE_ENV: 'production', NODE_ENV: 'production',
WEBHOOK_PORT: process.env.WEBHOOK_PORT || 3011, WEBHOOK_PORT: process.env.WEBHOOK_PORT || 3011,
GITHUB_WEBHOOK_SECRET: process.env.GITHUB_WEBHOOK_SECRET || '', GITHUB_WEBHOOK_SECRET: process.env.GITHUB_WEBHOOK_SECRET || '',
GITHUB_WEBHOOK_REFS: process.env.GITHUB_WEBHOOK_REFS || 'refs/heads/main',
GITHUB_WEBHOOK_REPOSITORY: process.env.GITHUB_WEBHOOK_REPOSITORY || '',
PM2_RESTART_TARGETS: process.env.PM2_RESTART_TARGETS || 'tssbot-web', PM2_RESTART_TARGETS: process.env.PM2_RESTART_TARGETS || 'tssbot-web',
DISCORD_WEBHOOK_URL: process.env.DISCORD_WEBHOOK_URL || '', DISCORD_WEBHOOK_URL: process.env.DISCORD_WEBHOOK_URL || '',
DISCORD_INCLUDE_PATCH: process.env.DISCORD_INCLUDE_PATCH || 'false',
}, },
}, },
], ],
+14
View File
@@ -14,12 +14,26 @@ API_CACHE_TTL_MS=15000
API_RATE_LIMIT_WINDOW_MS=60000 API_RATE_LIMIT_WINDOW_MS=60000
API_RATE_LIMIT_MAX=120 API_RATE_LIMIT_MAX=120
# 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.
# Only honors trusted headers when the immediate TCP peer is in TRUSTED_UPSTREAM_IPS.
TRUST_PROXY=cloudflare
TRUSTED_UPSTREAM_IPS=127.0.0.1,::1,::ffff:127.0.0.1
WEBHOOK_PORT=3011 WEBHOOK_PORT=3011
GITHUB_WEBHOOK_SECRET=change-me GITHUB_WEBHOOK_SECRET=change-me
# Comma-separated refs allowed to trigger a deploy. Defaults to refs/heads/main.
GITHUB_WEBHOOK_REFS=refs/heads/main
# Optional: refuse pushes whose repository.full_name does not match (e.g. "owner/repo").
GITHUB_WEBHOOK_REPOSITORY=
PM2_RESTART_TARGETS=tssbot-web PM2_RESTART_TARGETS=tssbot-web
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/... DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
# Set to "true" only if the Discord channel is private. Default omits the patch preview
# from Discord notifications to avoid leaking diffs (including any inline secrets).
DISCORD_INCLUDE_PATCH=true
# Cloudflare Turnstile. VITE_TURNSTILE_SITE_KEY is the public site key baked into the client bundle by Vite. # Cloudflare Turnstile. VITE_TURNSTILE_SITE_KEY is the public site key baked into the client bundle by Vite.
# 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.
# If left blank, the gated deletion endpoint is open (no challenge enforced). Set both to enforce.
VITE_TURNSTILE_SITE_KEY= VITE_TURNSTILE_SITE_KEY=
TURNSTILE_SECRET_KEY= TURNSTILE_SECRET_KEY=
+206 -15
View File
@@ -60,6 +60,85 @@ const MAX_CACHE_ENTRIES = 200
const MAX_RATE_LIMIT_KEYS = 1000 const MAX_RATE_LIMIT_KEYS = 1000
const MAX_ANALYTICS_BODY_BYTES = 16 * 1024 const MAX_ANALYTICS_BODY_BYTES = 16 * 1024
const TRUST_PROXY = (() => {
const raw = String(process.env.TRUST_PROXY ?? 'cloudflare').trim().toLowerCase()
if (raw === '' || raw === 'false' || raw === '0' || raw === 'none') return 'none'
if (raw === 'cloudflare' || raw === 'cf') return 'cloudflare'
if (raw === 'true' || raw === '1' || raw === 'any' || raw === 'generic') return 'generic'
return 'none'
})()
const TRUSTED_UPSTREAM_IPS = new Set(
String(process.env.TRUSTED_UPSTREAM_IPS ?? '127.0.0.1,::1,::ffff:127.0.0.1')
.split(',')
.map((ip) => ip.trim())
.filter(Boolean),
)
function requestPeerIsTrusted(req) {
if (TRUST_PROXY === 'none') return false
if (!TRUSTED_UPSTREAM_IPS.size) return true
const peer = req.socket.remoteAddress || ''
return TRUSTED_UPSTREAM_IPS.has(peer)
}
const ANALYTICS_METADATA_ALLOWED_KEYS = new Set([
'preferences',
'request',
'screen',
'viewport',
'pixelRatio',
'colorDepth',
'network',
'privacy',
'capability',
'capabilities',
'memory',
'cpu',
'touch',
'hardware',
'locale',
'referrer',
])
const SECURITY_HEADERS_BASE = {
'x-content-type-options': 'nosniff',
'x-frame-options': 'DENY',
'referrer-policy': 'strict-origin-when-cross-origin',
'permissions-policy': 'accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=(), interest-cohort=()',
'cross-origin-opener-policy': 'same-origin',
'cross-origin-resource-policy': 'same-origin',
}
const CSP_DIRECTIVES = [
"default-src 'self'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
"object-src 'none'",
"script-src 'self' https://challenges.cloudflare.com",
"script-src-elem 'self' https://challenges.cloudflare.com",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob: https://*.basemaps.cartocdn.com https://basemaps.cartocdn.com",
"font-src 'self' data:",
"connect-src 'self' https://challenges.cloudflare.com",
"frame-src https://challenges.cloudflare.com",
"worker-src 'self' blob:",
"manifest-src 'self'",
'upgrade-insecure-requests',
].join('; ')
function securityHeaders(req, { html = false } = {}) {
const headers = { ...SECURITY_HEADERS_BASE }
if (isHttpsRequest(req)) {
headers['strict-transport-security'] = 'max-age=31536000; includeSubDomains'
}
if (html) {
headers['content-security-policy'] = CSP_DIRECTIVES
}
return headers
}
const mimeTypes = { const mimeTypes = {
'.css': 'text/css; charset=utf-8', '.css': 'text/css; charset=utf-8',
'.html': 'text/html; charset=utf-8', '.html': 'text/html; charset=utf-8',
@@ -75,7 +154,8 @@ const mimeTypes = {
} }
function send(res, status, body, headers = {}) { function send(res, status, body, headers = {}) {
res.writeHead(status, headers) const merged = { ...securityHeaders(res.req || { socket: {}, headers: {} }), ...headers }
res.writeHead(status, merged)
res.end(body) res.end(body)
} }
@@ -363,17 +443,57 @@ function startUptimeSampler() {
}, UPTIME_SAMPLE_INTERVAL_MS).unref() }, UPTIME_SAMPLE_INTERVAL_MS).unref()
} }
function trustedHeaderFirst(req, name) {
const value = req.headers[name]
if (!value) return ''
return String(Array.isArray(value) ? value[0] : value).split(',')[0].trim()
}
function trustedForwardedProto(req) {
if (!requestPeerIsTrusted(req)) {
return req.socket.encrypted ? 'https' : 'http'
}
if (TRUST_PROXY === 'cloudflare') {
const cfVisitor = trustedHeaderFirst(req, 'cf-visitor')
if (cfVisitor) {
try {
const parsed = JSON.parse(cfVisitor)
if (parsed && typeof parsed.scheme === 'string') return parsed.scheme.toLowerCase()
} catch {
// ignore malformed cf-visitor
}
}
}
return (
trustedHeaderFirst(req, 'x-forwarded-proto').toLowerCase() ||
(req.socket.encrypted ? 'https' : 'http')
)
}
function trustedForwardedHost(req) {
if (requestPeerIsTrusted(req) && TRUST_PROXY === 'generic') {
const value = trustedHeaderFirst(req, 'x-forwarded-host')
if (value) return value
}
return trustedHeaderFirst(req, 'host')
}
function isHttpsRequest(req) {
if (req.socket.encrypted) return true
return trustedForwardedProto(req) === 'https'
}
function publicOrigins(req) { function publicOrigins(req) {
const origins = PUBLIC_ORIGIN.split(',') const origins = PUBLIC_ORIGIN.split(',')
.map((origin) => origin.trim()) .map((origin) => origin.trim())
.filter(Boolean) .filter(Boolean)
if (!origins.length) { if (!origins.length) {
const host = req.headers['x-forwarded-host'] || req.headers.host const host = trustedForwardedHost(req)
const proto = req.headers['x-forwarded-proto'] || (req.socket.encrypted ? 'https' : 'http') const proto = trustedForwardedProto(req) || (req.socket.encrypted ? 'https' : 'http')
if (host) { if (host) {
origins.push(`${proto}://${String(host).split(',')[0].trim()}`) origins.push(`${proto}://${host}`)
} }
} }
@@ -406,8 +526,14 @@ function isSameOriginRequest(req) {
} }
function clientIp(req) { function clientIp(req) {
const forwardedFor = req.headers['x-forwarded-for'] if (requestPeerIsTrusted(req)) {
if (forwardedFor) return String(forwardedFor).split(',')[0].trim() if (TRUST_PROXY === 'cloudflare') {
const cf = trustedHeaderFirst(req, 'cf-connecting-ip')
if (cf) return cf
}
const forwarded = trustedHeaderFirst(req, 'x-forwarded-for')
if (forwarded) return forwarded
}
return req.socket.remoteAddress || 'unknown' return req.socket.remoteAddress || 'unknown'
} }
@@ -428,6 +554,7 @@ function headerValue(req, name, maxLength = 200) {
} }
function countryFromHeaders(req) { function countryFromHeaders(req) {
if (!requestPeerIsTrusted(req)) return ''
const raw = const raw =
headerValue(req, 'cf-ipcountry', 12) || headerValue(req, 'cf-ipcountry', 12) ||
headerValue(req, 'x-vercel-ip-country', 12) || headerValue(req, 'x-vercel-ip-country', 12) ||
@@ -445,6 +572,9 @@ function numberHeader(req, name, min, max) {
} }
function locationFromHeaders(req) { function locationFromHeaders(req) {
if (!requestPeerIsTrusted(req) || TRUST_PROXY !== 'cloudflare') {
return { country: countryFromHeaders(req), region: '', city: '', latitude: null, longitude: null }
}
return { return {
country: countryFromHeaders(req), country: countryFromHeaders(req),
region: headerValue(req, 'cf-region', 120), region: headerValue(req, 'cf-region', 120),
@@ -454,8 +584,46 @@ function locationFromHeaders(req) {
} }
} }
function sanitizeAnalyticsValue(value, depth = 0) {
if (depth > 3) return null
if (value == null) return null
if (typeof value === 'string') return value.slice(0, 500)
if (typeof value === 'number') return Number.isFinite(value) ? value : null
if (typeof value === 'boolean') return value
if (Array.isArray(value)) {
return value.slice(0, 24).map((item) => sanitizeAnalyticsValue(item, depth + 1)).filter((item) => item !== null)
}
if (typeof value === 'object') {
const out = {}
let kept = 0
for (const [key, val] of Object.entries(value)) {
if (kept >= 32) break
const cleanKey = String(key).replace(/[^A-Za-z0-9_\-]/g, '').slice(0, 60)
if (!cleanKey) continue
const cleanVal = sanitizeAnalyticsValue(val, depth + 1)
if (cleanVal === null) continue
out[cleanKey] = cleanVal
kept += 1
}
return out
}
return null
}
function pruneAnalyticsMetadata(raw) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {}
const out = {}
for (const [key, value] of Object.entries(raw)) {
if (!ANALYTICS_METADATA_ALLOWED_KEYS.has(key)) continue
const sanitized = sanitizeAnalyticsValue(value, 0)
if (sanitized === null) continue
out[key] = sanitized
}
return out
}
function analyticsMetadata(req, payload) { function analyticsMetadata(req, payload) {
const metadata = payload.metadata && typeof payload.metadata === 'object' ? payload.metadata : {} const metadata = pruneAnalyticsMetadata(payload.metadata)
const preferences = metadata.preferences && typeof metadata.preferences === 'object' const preferences = metadata.preferences && typeof metadata.preferences === 'object'
? metadata.preferences ? metadata.preferences
: {} : {}
@@ -536,14 +704,18 @@ function readJsonBody(req) {
}) })
} }
const TURNSTILE_SESSION_COOKIE = 'tssbot_turnstile' const TURNSTILE_SESSION_COOKIE_BASE = 'tssbot_turnstile'
const TURNSTILE_SESSION_COOKIE_HOST = `__Host-${TURNSTILE_SESSION_COOKIE_BASE}`
const TURNSTILE_SESSION_TTL_SECONDS = 30 * 60 const TURNSTILE_SESSION_TTL_SECONDS = 30 * 60
const TURNSTILE_SESSION_HMAC_KEY = crypto const TURNSTILE_SESSION_HMAC_KEY = TURNSTILE_SECRET_KEY
? crypto
.createHash('sha256') .createHash('sha256')
.update(`tssbot-turnstile-session|${TURNSTILE_SECRET_KEY}`) .update(`tssbot-turnstile-session|${TURNSTILE_SECRET_KEY}`)
.digest() .digest()
: null
function signTurnstileSession(expiresAt) { function signTurnstileSession(expiresAt) {
if (!TURNSTILE_SESSION_HMAC_KEY) return ''
return crypto return crypto
.createHmac('sha256', TURNSTILE_SESSION_HMAC_KEY) .createHmac('sha256', TURNSTILE_SESSION_HMAC_KEY)
.update(String(expiresAt)) .update(String(expiresAt))
@@ -551,13 +723,14 @@ function signTurnstileSession(expiresAt) {
} }
function buildTurnstileSessionCookie(req) { function buildTurnstileSessionCookie(req) {
if (!TURNSTILE_SESSION_HMAC_KEY) return ''
const expiresAt = Math.floor(Date.now() / 1000) + TURNSTILE_SESSION_TTL_SECONDS const expiresAt = Math.floor(Date.now() / 1000) + TURNSTILE_SESSION_TTL_SECONDS
const signature = signTurnstileSession(expiresAt) const signature = signTurnstileSession(expiresAt)
const value = `${expiresAt}.${signature}` const value = `${expiresAt}.${signature}`
const proto = req.headers['x-forwarded-proto'] || (req.socket.encrypted ? 'https' : 'http') const isHttps = isHttpsRequest(req)
const isHttps = String(proto).split(',')[0].trim() === 'https' const name = isHttps ? TURNSTILE_SESSION_COOKIE_HOST : TURNSTILE_SESSION_COOKIE_BASE
const parts = [ const parts = [
`${TURNSTILE_SESSION_COOKIE}=${value}`, `${name}=${value}`,
'Path=/', 'Path=/',
`Max-Age=${TURNSTILE_SESSION_TTL_SECONDS}`, `Max-Age=${TURNSTILE_SESSION_TTL_SECONDS}`,
'HttpOnly', 'HttpOnly',
@@ -581,8 +754,10 @@ function parseRequestCookies(req) {
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
const cookies = parseRequestCookies(req) const cookies = parseRequestCookies(req)
const cookie = cookies[TURNSTILE_SESSION_COOKIE] const cookie =
cookies[TURNSTILE_SESSION_COOKIE_HOST] || cookies[TURNSTILE_SESSION_COOKIE_BASE]
if (!cookie) return false if (!cookie) return false
const dot = cookie.indexOf('.') const dot = cookie.indexOf('.')
if (dot < 1) return false if (dot < 1) return false
@@ -591,7 +766,7 @@ function isTurnstileSessionVerified(req) {
const expiresAt = Number(expiresAtStr) const expiresAt = Number(expiresAtStr)
if (!Number.isFinite(expiresAt) || expiresAt < Math.floor(Date.now() / 1000)) return false if (!Number.isFinite(expiresAt) || expiresAt < Math.floor(Date.now() / 1000)) return false
const expected = signTurnstileSession(expiresAt) const expected = signTurnstileSession(expiresAt)
if (signature.length !== expected.length) return false if (!expected || signature.length !== expected.length) return false
try { try {
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)) return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
} catch { } catch {
@@ -1372,12 +1547,15 @@ function proxyRequest(req, res) {
(proxyRes) => { (proxyRes) => {
const headers = { const headers = {
...proxyRes.headers, ...proxyRes.headers,
...securityHeaders(req),
'cache-control': 'private, max-age=15', 'cache-control': 'private, max-age=15',
'x-content-type-options': 'nosniff',
} }
delete headers['access-control-allow-origin'] delete headers['access-control-allow-origin']
delete headers['access-control-allow-credentials'] delete headers['access-control-allow-credentials']
delete headers['access-control-allow-methods']
delete headers['access-control-allow-headers']
delete headers['access-control-expose-headers']
res.writeHead(proxyRes.statusCode || 502, headers) res.writeHead(proxyRes.statusCode || 502, headers)
@@ -1420,6 +1598,7 @@ function pagePublicOrigin(req) {
function sendHtml(req, res, data, status = 200) { function sendHtml(req, res, data, status = 200) {
const html = data.toString('utf8').replaceAll('__PUBLIC_ORIGIN__', pagePublicOrigin(req)) const html = data.toString('utf8').replaceAll('__PUBLIC_ORIGIN__', pagePublicOrigin(req))
send(res, status, html, { send(res, status, html, {
...securityHeaders(req, { html: true }),
'content-type': mimeTypes['.html'], 'content-type': mimeTypes['.html'],
'cache-control': 'no-cache', 'cache-control': 'no-cache',
}) })
@@ -1468,6 +1647,10 @@ const server = http.createServer((req, res) => {
} }
if (req.method === 'GET' && req.url === '/api/uptime') { 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)) })
return
}
uptimeHistory() uptimeHistory()
.then((data) => sendJson(res, 200, data)) .then((data) => sendJson(res, 200, data))
.catch((error) => sendJson(res, 500, { error: 'Uptime history unavailable', detail: error.message })) .catch((error) => sendJson(res, 500, { error: 'Uptime history unavailable', detail: error.message }))
@@ -1475,6 +1658,10 @@ const server = http.createServer((req, res) => {
} }
if (req.method === 'GET' && req.url === '/api/viewers') { if (req.method === 'GET' && req.url === '/api/viewers') {
if (isRateLimited(req)) {
sendJson(res, 429, { error: 'Too many requests' }, { 'retry-after': String(Math.ceil(API_RATE_LIMIT_WINDOW_MS / 1000)) })
return
}
try { try {
sendJson(res, 200, viewerDashboard()) sendJson(res, 200, viewerDashboard())
} catch (error) { } catch (error) {
@@ -1555,6 +1742,10 @@ const server = http.createServer((req, res) => {
} }
if (req.method === 'GET' && req.url === '/api/turnstile/session') { if (req.method === 'GET' && req.url === '/api/turnstile/session') {
if (!isSameOriginRequest(req)) {
sendJson(res, 403, { error: 'Turnstile session check is restricted to this site' })
return
}
sendJson(res, 200, { verified: isTurnstileSessionVerified(req) }) sendJson(res, 200, { verified: isTurnstileSessionVerified(req) })
return return
} }
+35 -3
View File
@@ -39,10 +39,18 @@ loadEnvFile()
const PORT = Number(process.env.WEBHOOK_PORT || 3011) const PORT = Number(process.env.WEBHOOK_PORT || 3011)
const SECRET = process.env.GITHUB_WEBHOOK_SECRET || '' const SECRET = process.env.GITHUB_WEBHOOK_SECRET || ''
const DISCORD_WEBHOOK_URL = process.env.DISCORD_WEBHOOK_URL || '' const DISCORD_WEBHOOK_URL = process.env.DISCORD_WEBHOOK_URL || ''
const DISCORD_INCLUDE_PATCH = /^(1|true|yes)$/i.test(String(process.env.DISCORD_INCLUDE_PATCH || ''))
const RESTART_TARGETS = (process.env.PM2_RESTART_TARGETS || 'tssbot-web') const RESTART_TARGETS = (process.env.PM2_RESTART_TARGETS || 'tssbot-web')
.split(',') .split(',')
.map((target) => target.trim()) .map((target) => target.trim())
.filter(Boolean) .filter(Boolean)
const ALLOWED_REFS = new Set(
(process.env.GITHUB_WEBHOOK_REFS || 'refs/heads/main')
.split(',')
.map((ref) => ref.trim())
.filter(Boolean),
)
const ALLOWED_REPOSITORY = (process.env.GITHUB_WEBHOOK_REPOSITORY || '').trim()
const RESTART_AFTER_MS = 24 * 60 * 60 * 1000 const RESTART_AFTER_MS = 24 * 60 * 60 * 1000
let deploying = false let deploying = false
@@ -86,7 +94,10 @@ function validGitSha(value) {
} }
function verifySignature(rawBody, signature) { function verifySignature(rawBody, signature) {
if (!SECRET) return true if (!SECRET) {
console.error('GITHUB_WEBHOOK_SECRET is not set — rejecting webhook')
return false
}
if (!signature || !signature.startsWith('sha256=')) return false if (!signature || !signature.startsWith('sha256=')) return false
const expected = `sha256=${crypto.createHmac('sha256', SECRET).update(rawBody).digest('hex')}` const expected = `sha256=${crypto.createHmac('sha256', SECRET).update(rawBody).digest('hex')}`
@@ -160,12 +171,14 @@ function runCapture(command, args, options = {}) {
} }
async function ensureBuildDependencies() { async function ensureBuildDependencies() {
await run('npm', ['install', '--production=false', '--include=dev', '--include=optional'], { await run('npm', ['ci', '--include=dev', '--include=optional'], {
env: { env: {
NODE_ENV: 'development', NODE_ENV: 'development',
npm_config_include: 'dev,optional', npm_config_include: 'dev,optional',
npm_config_omit: '', npm_config_omit: '',
npm_config_production: 'false', npm_config_production: 'false',
npm_config_fund: 'false',
npm_config_audit: 'false',
}, },
}) })
@@ -301,7 +314,7 @@ function diffFields(diff) {
if (diff.summary) { if (diff.summary) {
fields.push({ name: 'Diff summary', value: codeBlock(diff.summary, 'diff'), inline: false }) fields.push({ name: 'Diff summary', value: codeBlock(diff.summary, 'diff'), inline: false })
} }
if (diff.patch) { if (DISCORD_INCLUDE_PATCH && diff.patch) {
fields.push({ name: 'Patch preview', value: codeBlock(diff.patch, 'diff'), inline: false }) fields.push({ name: 'Patch preview', value: codeBlock(diff.patch, 'diff'), inline: false })
} }
@@ -388,6 +401,25 @@ http
return return
} }
const pushRef = String(push?.ref || '')
if (!ALLOWED_REFS.has(pushRef)) {
json(res, 202, { skipped: true, reason: `Ignoring ref ${pushRef || '(missing)'}` })
return
}
if (ALLOWED_REPOSITORY) {
const repoFullName = String(push?.repository?.full_name || '')
if (repoFullName !== ALLOWED_REPOSITORY) {
json(res, 202, { skipped: true, reason: `Ignoring repository ${repoFullName || '(missing)'}` })
return
}
}
if (push?.deleted) {
json(res, 202, { skipped: true, reason: 'Ignoring branch-delete push' })
return
}
if (deploying) { if (deploying) {
json(res, 202, { queued: false, deploying: true }) json(res, 202, { queued: false, deploying: true })
return return