update osm
This commit is contained in:
+209
-18
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user