update osm
This commit is contained in:
@@ -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)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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=
|
||||||
|
|||||||
+209
-18
@@ -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
|
||||||
.createHash('sha256')
|
? crypto
|
||||||
.update(`tssbot-turnstile-session|${TURNSTILE_SECRET_KEY}`)
|
.createHash('sha256')
|
||||||
.digest()
|
.update(`tssbot-turnstile-session|${TURNSTILE_SECRET_KEY}`)
|
||||||
|
.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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user