diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 5f15b9d..382417d 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,7 +1,11 @@ { "permissions": { "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)" ] } } diff --git a/README.md b/README.md index 26280aa..7783257 100644 --- a/README.md +++ b/README.md @@ -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 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: @@ -68,6 +72,22 @@ API_RATE_LIMIT_WINDOW_MS=60000 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 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 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 GITHUB_WEBHOOK_SECRET=your-secret pm2 start ecosystem.config.cjs ``` 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: diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs index 92df633..dd2b57c 100644 --- a/ecosystem.config.cjs +++ b/ecosystem.config.cjs @@ -16,6 +16,9 @@ module.exports = { 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_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', WEBHOOK_PORT: process.env.WEBHOOK_PORT || 3011, 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', DISCORD_WEBHOOK_URL: process.env.DISCORD_WEBHOOK_URL || '', + DISCORD_INCLUDE_PATCH: process.env.DISCORD_INCLUDE_PATCH || 'false', }, }, ], diff --git a/example.env b/example.env index 219531c..ab8b74d 100644 --- a/example.env +++ b/example.env @@ -14,12 +14,26 @@ API_CACHE_TTL_MS=15000 API_RATE_LIMIT_WINDOW_MS=60000 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 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 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. # 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= TURNSTILE_SECRET_KEY= diff --git a/server.cjs b/server.cjs index 1526a0c..c5063ab 100644 --- a/server.cjs +++ b/server.cjs @@ -60,6 +60,85 @@ const MAX_CACHE_ENTRIES = 200 const MAX_RATE_LIMIT_KEYS = 1000 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 = { '.css': 'text/css; charset=utf-8', '.html': 'text/html; charset=utf-8', @@ -75,7 +154,8 @@ const mimeTypes = { } 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) } @@ -363,17 +443,57 @@ function startUptimeSampler() { }, 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) { const origins = PUBLIC_ORIGIN.split(',') .map((origin) => origin.trim()) .filter(Boolean) if (!origins.length) { - const host = req.headers['x-forwarded-host'] || req.headers.host - const proto = req.headers['x-forwarded-proto'] || (req.socket.encrypted ? 'https' : 'http') + const host = trustedForwardedHost(req) + const proto = trustedForwardedProto(req) || (req.socket.encrypted ? 'https' : 'http') if (host) { - origins.push(`${proto}://${String(host).split(',')[0].trim()}`) + origins.push(`${proto}://${host}`) } } @@ -406,8 +526,14 @@ function isSameOriginRequest(req) { } function clientIp(req) { - const forwardedFor = req.headers['x-forwarded-for'] - if (forwardedFor) return String(forwardedFor).split(',')[0].trim() + if (requestPeerIsTrusted(req)) { + 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' } @@ -428,6 +554,7 @@ function headerValue(req, name, maxLength = 200) { } function countryFromHeaders(req) { + if (!requestPeerIsTrusted(req)) return '' const raw = headerValue(req, 'cf-ipcountry', 12) || headerValue(req, 'x-vercel-ip-country', 12) || @@ -445,6 +572,9 @@ function numberHeader(req, name, min, max) { } function locationFromHeaders(req) { + if (!requestPeerIsTrusted(req) || TRUST_PROXY !== 'cloudflare') { + return { country: countryFromHeaders(req), region: '', city: '', latitude: null, longitude: null } + } return { country: countryFromHeaders(req), 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) { - const metadata = payload.metadata && typeof payload.metadata === 'object' ? payload.metadata : {} + const metadata = pruneAnalyticsMetadata(payload.metadata) const preferences = metadata.preferences && typeof metadata.preferences === 'object' ? 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_HMAC_KEY = crypto - .createHash('sha256') - .update(`tssbot-turnstile-session|${TURNSTILE_SECRET_KEY}`) - .digest() +const TURNSTILE_SESSION_HMAC_KEY = TURNSTILE_SECRET_KEY + ? crypto + .createHash('sha256') + .update(`tssbot-turnstile-session|${TURNSTILE_SECRET_KEY}`) + .digest() + : null function signTurnstileSession(expiresAt) { + if (!TURNSTILE_SESSION_HMAC_KEY) return '' return crypto .createHmac('sha256', TURNSTILE_SESSION_HMAC_KEY) .update(String(expiresAt)) @@ -551,13 +723,14 @@ function signTurnstileSession(expiresAt) { } function buildTurnstileSessionCookie(req) { + if (!TURNSTILE_SESSION_HMAC_KEY) return '' const expiresAt = Math.floor(Date.now() / 1000) + TURNSTILE_SESSION_TTL_SECONDS const signature = signTurnstileSession(expiresAt) const value = `${expiresAt}.${signature}` - const proto = req.headers['x-forwarded-proto'] || (req.socket.encrypted ? 'https' : 'http') - const isHttps = String(proto).split(',')[0].trim() === 'https' + const isHttps = isHttpsRequest(req) + const name = isHttps ? TURNSTILE_SESSION_COOKIE_HOST : TURNSTILE_SESSION_COOKIE_BASE const parts = [ - `${TURNSTILE_SESSION_COOKIE}=${value}`, + `${name}=${value}`, 'Path=/', `Max-Age=${TURNSTILE_SESSION_TTL_SECONDS}`, 'HttpOnly', @@ -581,8 +754,10 @@ function parseRequestCookies(req) { function isTurnstileSessionVerified(req) { if (!TURNSTILE_SECRET_KEY) return true + if (!TURNSTILE_SESSION_HMAC_KEY) return false 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 const dot = cookie.indexOf('.') if (dot < 1) return false @@ -591,7 +766,7 @@ function isTurnstileSessionVerified(req) { const expiresAt = Number(expiresAtStr) if (!Number.isFinite(expiresAt) || expiresAt < Math.floor(Date.now() / 1000)) return false const expected = signTurnstileSession(expiresAt) - if (signature.length !== expected.length) return false + if (!expected || signature.length !== expected.length) return false try { return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)) } catch { @@ -1372,12 +1547,15 @@ function proxyRequest(req, res) { (proxyRes) => { const headers = { ...proxyRes.headers, + ...securityHeaders(req), 'cache-control': 'private, max-age=15', - 'x-content-type-options': 'nosniff', } delete headers['access-control-allow-origin'] 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) @@ -1420,6 +1598,7 @@ function pagePublicOrigin(req) { function sendHtml(req, res, data, status = 200) { const html = data.toString('utf8').replaceAll('__PUBLIC_ORIGIN__', pagePublicOrigin(req)) send(res, status, html, { + ...securityHeaders(req, { html: true }), 'content-type': mimeTypes['.html'], 'cache-control': 'no-cache', }) @@ -1468,6 +1647,10 @@ const server = http.createServer((req, res) => { } 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() .then((data) => sendJson(res, 200, data)) .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 (isRateLimited(req)) { + sendJson(res, 429, { error: 'Too many requests' }, { 'retry-after': String(Math.ceil(API_RATE_LIMIT_WINDOW_MS / 1000)) }) + return + } try { sendJson(res, 200, viewerDashboard()) } catch (error) { @@ -1555,6 +1742,10 @@ const server = http.createServer((req, res) => { } 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) }) return } diff --git a/webhook.cjs b/webhook.cjs index c33af2f..8971bcb 100644 --- a/webhook.cjs +++ b/webhook.cjs @@ -39,10 +39,18 @@ loadEnvFile() const PORT = Number(process.env.WEBHOOK_PORT || 3011) const SECRET = process.env.GITHUB_WEBHOOK_SECRET || '' 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') .split(',') .map((target) => target.trim()) .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 let deploying = false @@ -86,7 +94,10 @@ function validGitSha(value) { } 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 const expected = `sha256=${crypto.createHmac('sha256', SECRET).update(rawBody).digest('hex')}` @@ -160,12 +171,14 @@ function runCapture(command, args, options = {}) { } async function ensureBuildDependencies() { - await run('npm', ['install', '--production=false', '--include=dev', '--include=optional'], { + await run('npm', ['ci', '--include=dev', '--include=optional'], { env: { NODE_ENV: 'development', npm_config_include: 'dev,optional', npm_config_omit: '', npm_config_production: 'false', + npm_config_fund: 'false', + npm_config_audit: 'false', }, }) @@ -301,7 +314,7 @@ function diffFields(diff) { if (diff.summary) { 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 }) } @@ -388,6 +401,25 @@ http 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) { json(res, 202, { queued: false, deploying: true }) return