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
+209 -18
View File
@@ -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
}