diff --git a/server.cjs b/server.cjs index b31d012..bacae41 100644 --- a/server.cjs +++ b/server.cjs @@ -63,6 +63,9 @@ const MAX_TEAM_NAME_LENGTH = 80 const MAX_CACHE_ENTRIES = 200 const MAX_RATE_LIMIT_KEYS = 1000 const MAX_ANALYTICS_BODY_BYTES = 16 * 1024 +const MAX_UPSTREAM_BODY_BYTES = Number(process.env.MAX_UPSTREAM_BODY_BYTES || 1024 * 1024) +const SERVER_REQUEST_TIMEOUT_MS = Number(process.env.SERVER_REQUEST_TIMEOUT_MS || 30000) +const SERVER_HEADERS_TIMEOUT_MS = Number(process.env.SERVER_HEADERS_TIMEOUT_MS || 10000) const RUN_BACKGROUND_JOBS = !process.env.NODE_APP_INSTANCE || process.env.NODE_APP_INSTANCE === '0' const TRUST_PROXY = (() => { @@ -300,8 +303,16 @@ function requestJson(url, timeoutMs = 10000) { }, (response) => { const chunks = [] + let size = 0 - response.on('data', (chunk) => chunks.push(chunk)) + response.on('data', (chunk) => { + size += chunk.length + if (size > MAX_UPSTREAM_BODY_BYTES) { + req.destroy(new Error('Upstream response too large')) + return + } + chunks.push(chunk) + }) response.on('end', () => { const body = Buffer.concat(chunks).toString('utf8') const latency = Date.now() - startedAt @@ -1726,6 +1737,7 @@ function proxyRequest(req, res) { } const responseChunks = [] + let proxiedBytes = 0 const proxy = http.request( target, { @@ -1752,6 +1764,12 @@ function proxyRequest(req, res) { res.writeHead(proxyRes.statusCode || 502, headers) proxyRes.on('data', (chunk) => { + proxiedBytes += chunk.length + if (proxiedBytes > MAX_UPSTREAM_BODY_BYTES) { + proxy.destroy(new Error('Upstream response too large')) + res.destroy() + return + } if (cacheKey && (proxyRes.statusCode || 0) >= 200 && (proxyRes.statusCode || 0) < 300) { responseChunks.push(chunk) } @@ -1772,6 +1790,7 @@ function proxyRequest(req, res) { ) proxy.on('error', (error) => { + if (res.destroyed || res.headersSent) return sendJson(res, 502, { error: 'API proxy failed', detail: error.message }) }) @@ -1782,8 +1801,8 @@ function pagePublicOrigin(req) { const configured = PUBLIC_ORIGIN.split(',').map((origin) => origin.trim()).filter(Boolean)[0] if (configured) return configured.replace(/\/$/, '') - const host = req.headers['x-forwarded-host'] || req.headers.host || `localhost:${PORT}` - const proto = req.headers['x-forwarded-proto'] || (req.socket.encrypted ? 'https' : 'http') + const host = trustedForwardedHost(req) || `localhost:${PORT}` + const proto = trustedForwardedProto(req) || (req.socket.encrypted ? 'https' : 'http') return `${String(proto).split(',')[0].trim()}://${String(host).split(',')[0].trim()}`.replace(/\/$/, '') } @@ -1827,15 +1846,21 @@ function sendComingSoonPage(req, res) { } function serveStatic(req, res) { - const requestPath = decodeURIComponent(new URL(req.url, `http://localhost:${PORT}`).pathname) + let requestPath = '/' + try { + requestPath = decodeURIComponent(new URL(req.url, `http://localhost:${PORT}`).pathname) + } catch { + return send(res, 400, 'Bad request', { 'content-type': 'text/plain; charset=utf-8' }) + } if (COMING_SOON) { return sendComingSoonPage(req, res) } const relativePath = requestPath === '/' ? '/index.html' : requestPath - const filePath = path.normalize(path.join(DIST_DIR, relativePath)) + const filePath = path.resolve(DIST_DIR, `.${relativePath}`) + const relativeToDist = path.relative(DIST_DIR, filePath) - if (!filePath.startsWith(DIST_DIR)) { + if (relativeToDist.startsWith('..') || path.isAbsolute(relativeToDist)) { return send(res, 403, 'Forbidden', { 'content-type': 'text/plain; charset=utf-8' }) } @@ -2006,6 +2031,9 @@ const server = http.createServer((req, res) => { serveStatic(req, res) }) +server.requestTimeout = SERVER_REQUEST_TIMEOUT_MS +server.headersTimeout = SERVER_HEADERS_TIMEOUT_MS + server.listen(PORT, '0.0.0.0', () => { console.log(`tssbot-web serving http://localhost:${PORT}`) console.log(`proxying API requests to ${API_UPSTREAM}`) diff --git a/src/App.jsx b/src/App.jsx index e5bbdcb..e63d488 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -668,6 +668,7 @@ function AppContent() { const [uptime, setUptime] = useState({ status: 'idle', checks: [], history: [], updatedAt: null }) const [viewers, setViewers] = useState({ status: 'idle', data: null, error: null, updatedAt: null }) const [songOfDay, setSongOfDay] = useState({ status: 'idle', data: null, error: null }) + const [songOfDayRequest, setSongOfDayRequest] = useState(0) const [analyticsPreferences, setAnalyticsPreferences] = useState(() => storedAnalyticsPreferences()) const [theme, setTheme] = useState(() => storedThemePreference()) const [showFloatingNav, setShowFloatingNav] = useState(() => window.scrollY > 40) @@ -1010,7 +1011,6 @@ function AppContent() { useEffect(() => { if (route.page !== 'home') return - if (songOfDay.status === 'ready' || songOfDay.status === 'loading') return const controller = new AbortController() let timedOut = false @@ -1038,7 +1038,7 @@ function AppContent() { window.clearTimeout(timeout) controller.abort() } - }, [route.page, songOfDay.status]) + }, [route.page, songOfDayRequest]) useEffect(() => { if (route.page !== 'team' || !route.teamName) return @@ -1422,6 +1422,7 @@ function AppContent() { onTeamSearch={handleTeamSearch} searchPlaceholder={searchPlaceholder} setTeamQuery={setTeamQuery} + setSongOfDayRequest={setSongOfDayRequest} songOfDay={songOfDay} teamSuggestions={teamSuggestions} teams={teams} @@ -1795,6 +1796,7 @@ function Landing({ onTeamSearch, searchPlaceholder, setTeamQuery, + setSongOfDayRequest, songOfDay, teamSuggestions, teams, @@ -1865,7 +1867,10 @@ function Landing({ Search teams - + setSongOfDayRequest((value) => value + 1)} + songOfDay={songOfDay} + /> @@ -1892,7 +1897,7 @@ function Landing({ ) } -function SongOfDayCard({ songOfDay }) { +function SongOfDayCard({ onRetry, songOfDay }) { const track = songOfDay.data?.track const isLoading = songOfDay.status === 'loading' const message = @@ -1941,6 +1946,14 @@ function SongOfDayCard({ songOfDay }) { > Play + ) : songOfDay.status === 'error' ? ( + ) : null} diff --git a/webhook.cjs b/webhook.cjs index 2f6b4c5..5a8c2f1 100644 --- a/webhook.cjs +++ b/webhook.cjs @@ -43,10 +43,14 @@ const DISCORD_INCLUDE_PATCH = /^(1|true|yes)$/i.test(String(process.env.DISCORD_ const RESTART_TARGETS = (process.env.PM2_RESTART_TARGETS || 'tssbot-web') .split(',') .map((target) => target.trim()) + .filter((target) => /^[A-Za-z0-9_.:-]{1,80}$/.test(target)) .filter(Boolean) const DIST_DIR = path.join(__dirname, 'dist') const NEXT_DIST_DIR = path.join(__dirname, 'dist-next') const PREVIOUS_DIST_DIR = path.join(__dirname, 'dist-previous') +const MAX_WEBHOOK_BODY_BYTES = Number(process.env.WEBHOOK_MAX_BODY_BYTES || 1024 * 1024) +const WEBHOOK_REQUEST_TIMEOUT_MS = Number(process.env.WEBHOOK_REQUEST_TIMEOUT_MS || 30000) +const WEBHOOK_HEADERS_TIMEOUT_MS = Number(process.env.WEBHOOK_HEADERS_TIMEOUT_MS || 10000) const ALLOWED_REFS = new Set( (process.env.GITHUB_WEBHOOK_REFS || 'refs/heads/main') .split(',') @@ -420,8 +424,7 @@ async function deploy(push) { } } -http - .createServer((req, res) => { +const webhookServer = http.createServer((req, res) => { if (req.method === 'GET' && req.url === '/health') { json(res, 200, { ok: true, deploying, restart_targets: RESTART_TARGETS }) return @@ -433,8 +436,20 @@ http } const chunks = [] - req.on('data', (chunk) => chunks.push(chunk)) + let size = 0 + let tooLarge = false + req.on('data', (chunk) => { + size += chunk.length + if (size > MAX_WEBHOOK_BODY_BYTES) { + tooLarge = true + json(res, 413, { error: 'Webhook body too large' }) + req.destroy() + return + } + chunks.push(chunk) + }) req.on('end', () => { + if (tooLarge) return const rawBody = Buffer.concat(chunks) const push = safeJsonParse(rawBody) @@ -485,12 +500,16 @@ http deploying = false }) }) - }) - .listen(PORT, '0.0.0.0', () => { - console.log(`tssbot webhook listening on http://localhost:${PORT}/github`) - console.log(`restart targets: ${RESTART_TARGETS.join(', ')}`) - notifyDiscordRestart() - }) +}) + +webhookServer.requestTimeout = WEBHOOK_REQUEST_TIMEOUT_MS +webhookServer.headersTimeout = WEBHOOK_HEADERS_TIMEOUT_MS + +webhookServer.listen(PORT, '0.0.0.0', () => { + console.log(`tssbot webhook listening on http://localhost:${PORT}/github`) + console.log(`restart targets: ${RESTART_TARGETS.join(', ')}`) + notifyDiscordRestart() +}) setTimeout(() => { console.log('24 hour webhook refresh reached; exiting for PM2 restart')