From 1863f6784b561f1f3e85005f9ddfc3435116941d Mon Sep 17 00:00:00 2001 From: Nate Emmert Date: Mon, 15 Jun 2026 08:51:18 -0400 Subject: [PATCH] Fix XSS, validation, and CSS regressions; refactor proxy retry logic - Sanitize geocode suggestion rendering to prevent XSS - Add lat/lon validation and cache eviction to weather proxy endpoints - Extract shared fetchWithRetry helper, dedupe retry/timeout logic - Stop leaking upstream error details to clients - Fix radar provider metadata and drop dead nowcast field - Fix missing --surface-radius-sm var and duplicate/conflicting CSS rules --- app/public/app.js | 17 ++-- app/public/index.html | 2 +- app/public/style.css | 32 ++----- app/server.js | 197 +++++++++++++++++++----------------------- 4 files changed, 104 insertions(+), 144 deletions(-) diff --git a/app/public/app.js b/app/public/app.js index a3cc624..63421a3 100644 --- a/app/public/app.js +++ b/app/public/app.js @@ -425,7 +425,6 @@ async function fetchWeather(lat, lon, tz) { async function fetchAirQuality(lat, lon, tz) { const url = `/api/air-quality?lat=${lat}&lon=${lon}&tz=${encodeURIComponent(tz||'auto')}`; - console.log('Fetching air quality from:', url); const r = await fetch(url); if (!r.ok) throw new Error(`Air quality fetch failed with status ${r.status}`); return r.json(); @@ -641,13 +640,6 @@ function createRadar(mapEl, timelineEl, playBtn, tsEl, layerBtn) { maxNativeZoom: f.maxNativeZoom, isForecast: false, })); - (data.radar?.nowcast || []).forEach(f => radarFrames.push({ - time: f.time, - path: f.path, - url: f.url, - maxNativeZoom: f.maxNativeZoom, - isForecast: true, - })); (data.satellite?.infrared || []).forEach(f => satelliteFrames.push({ time: f.time, path: f.path, @@ -1076,7 +1068,7 @@ function renderWeather(data, city) { document.getElementById('cur-desc').textContent = w.desc; document.getElementById('cur-temp').textContent = tempDisplay(c.temperature_2m); document.querySelector('.deg').textContent = `°${tempUnit()}`; - document.querySelector('.feels').innerHTML = `${apparentLabel(c.temperature_2m, c.apparent_temperature)} ${tempDisplay(c.apparent_temperature)}°${tempUnit()}`; + document.getElementById('feels-line').innerHTML = `${apparentLabel(c.temperature_2m, c.apparent_temperature)} ${tempDisplay(c.apparent_temperature)}°${tempUnit()}`; document.getElementById('cur-hi').textContent = tempDisplay(d.temperature_2m_max[0]); document.getElementById('cur-lo').textContent = tempDisplay(d.temperature_2m_min[0]); document.getElementById('cur-precip').textContent = c.precipitation > 0 ? `${precipDisplay(c.precipitation)} ${state.units === 'us' ? 'in' : 'mm'} precip` : 'No precipitation'; @@ -1668,7 +1660,12 @@ searchInput.addEventListener('input', () => { results.forEach(r => { const li = document.createElement('li'); - li.innerHTML = `${r.name}${r.admin1 ? r.admin1 + ', ' : ''}${r.country || ''}`; + const nameText = document.createTextNode(r.name); + const countrySpan = document.createElement('span'); + countrySpan.className = 'sug-country'; + countrySpan.textContent = `${r.admin1 ? r.admin1 + ', ' : ''}${r.country || ''}`; + li.appendChild(nameText); + li.appendChild(countrySpan); li.addEventListener('click', () => { searchInput.value = r.name; hide(suggestEl); diff --git a/app/public/index.html b/app/public/index.html index e8378e2..50b771d 100644 --- a/app/public/index.html +++ b/app/public/index.html @@ -77,7 +77,7 @@
--°F
-
Feels like --°F
+
Feels like --°F
diff --git a/app/public/style.css b/app/public/style.css index 299f3cb..017d81c 100644 --- a/app/public/style.css +++ b/app/public/style.css @@ -19,6 +19,7 @@ --surface-border: 1px solid rgba(255, 255, 255, 0.08); --surface-radius-lg: 16px; --surface-radius-md: 12px; + --surface-radius-sm: 8px; --surface-shadow: 0 8px 18px rgba(0, 0, 0, 0.16); } @@ -338,6 +339,10 @@ a:hover { text-decoration: underline; } color: var(--ink-mid); cursor: pointer; transition: background 0.15s, color 0.15s; + min-height: 44px; + display: inline-flex; + align-items: center; + justify-content: center; } .tab:last-child { border-right: none; } @@ -498,25 +503,6 @@ a:hover { text-decoration: underline; } accent-color: var(--accent); } -.toggle-label { - display: inline-flex; - align-items: center; - gap: 0.45rem; - margin-bottom: 0.45rem; - font-size: 0.68rem; - color: var(--ink-mid); - cursor: pointer; - user-select: none; -} - -.toggle-label input { - accent-color: var(--accent); -} - -.toggle-label span { - min-width: 24px; -} - .saved-item { display: inline-flex; border: 1px solid var(--border); @@ -1581,8 +1567,6 @@ details[open] .drawer-chevron { transform: rotate(90deg); } color: var(--accent); } -.stat-label { font-size: 0.58rem; letter-spacing: 0.1em; color: var(--ink-light); text-transform: uppercase; margin-bottom: 0.3rem; } - /* ── Section heads ── */ .section-head { display: flex; @@ -1813,12 +1797,6 @@ details[open] .drawer-chevron { transform: rotate(90deg); } } /* ── Touch-friendly tap targets (min 44 × 44 px) ── */ -.tab { - min-height: 44px; - display: inline-flex; - align-items: center; - justify-content: center; -} .search-row input, .search-row button { min-height: 44px; } .radar-btn { width: 44px; height: 44px; font-size: 0.8rem; } diff --git a/app/server.js b/app/server.js index 4a09a86..041626f 100644 --- a/app/server.js +++ b/app/server.js @@ -4,6 +4,42 @@ const path = require('path'); const app = express(); const PORT = process.env.PORT || 3000; const weatherCache = new Map(); +const WEATHER_CACHE_MAX_ENTRIES = 500; + +function setCache(key, data) { + if (weatherCache.size >= WEATHER_CACHE_MAX_ENTRIES) { + const oldestKey = weatherCache.keys().next().value; + weatherCache.delete(oldestKey); + } + weatherCache.set(key, { data, at: Date.now() }); +} + +// Fetch with retry/timeout, shared by all upstream proxy calls. +async function fetchWithRetry(url, { attempts = 2, timeoutMs = 12000, retryDelayMs = 500, fetchOptions } = {}) { + let lastError = null; + for (let attempt = 1; attempt <= attempts; attempt++) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + try { + const r = await fetch(url, { ...fetchOptions, signal: controller.signal }); + clearTimeout(timeoutId); + if (!r.ok) throw new Error(`Upstream status ${r.status}`); + return r; + } catch (err) { + clearTimeout(timeoutId); + lastError = err; + if (attempt < attempts) { + await new Promise(resolve => setTimeout(resolve, retryDelayMs * attempt)); + } + } + } + throw lastError; +} + +function isValidCoord(value, min, max) { + const n = Number(value); + return Number.isFinite(n) && n >= min && n <= max; +} // Keep shell assets fresh so SW/app versions do not drift. app.use('/', (req, res, next) => { @@ -35,23 +71,11 @@ app.get('/api/geocode', async (req, res) => { const url = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(q)}&count=5&language=en&format=json`; let openMeteoData = null; - for (let attempt = 1; attempt <= 2; attempt++) { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 9000); - try { - const r = await fetch(url, { signal: controller.signal }); - clearTimeout(timeoutId); - if (!r.ok) throw new Error(`Open-Meteo geocode status ${r.status}`); - openMeteoData = await r.json(); - break; - } catch (err) { - clearTimeout(timeoutId); - if (attempt === 2) { - console.warn('Open-Meteo geocode failed after retries:', err.message); - } else { - await new Promise(resolve => setTimeout(resolve, 300 * attempt)); - } - } + try { + const r = await fetchWithRetry(url, { attempts: 2, timeoutMs: 9000, retryDelayMs: 300 }); + openMeteoData = await r.json(); + } catch (err) { + console.warn('Open-Meteo geocode failed after retries:', err.message); } if (openMeteoData?.results?.length) { @@ -60,17 +84,17 @@ app.get('/api/geocode', async (req, res) => { // Fallback to Nominatim when Open-Meteo geocoding is empty/unavailable. const nomParams = new URLSearchParams({ q: String(q), format: 'jsonv2', limit: '5', addressdetails: '1' }); - const nom = await fetch(`https://nominatim.openstreetmap.org/search?${nomParams}`, { - headers: { - 'User-Agent': 'weather-app-v2 (nate@necloud.us)', - Accept: 'application/json', + const nom = await fetchWithRetry(`https://nominatim.openstreetmap.org/search?${nomParams}`, { + attempts: 1, + timeoutMs: 9000, + fetchOptions: { + headers: { + 'User-Agent': 'weather-app-v2 (nate@necloud.us)', + Accept: 'application/json', + }, }, }); - if (!nom.ok) { - throw new Error(`Nominatim geocode status ${nom.status}`); - } - const nomData = await nom.json(); const results = (nomData || []).map(item => { const address = item.address || {}; @@ -86,7 +110,8 @@ app.get('/api/geocode', async (req, res) => { return res.json({ results }); } catch (e) { - res.status(500).json({ error: 'Geocoding failed', detail: e.message }); + console.error('Geocoding error:', e.message); + res.status(500).json({ error: 'Geocoding failed' }); } }); @@ -94,6 +119,9 @@ app.get('/api/geocode', async (req, res) => { app.get('/api/reverse-geocode', async (req, res) => { const { lat, lon } = req.query; if (!lat || !lon) return res.status(400).json({ error: 'Missing lat/lon' }); + if (!isValidCoord(lat, -90, 90) || !isValidCoord(lon, -180, 180)) { + return res.status(400).json({ error: 'Invalid lat/lon' }); + } try { const params = new URLSearchParams({ @@ -104,17 +132,17 @@ app.get('/api/reverse-geocode', async (req, res) => { addressdetails: '1', }); - const r = await fetch(`https://nominatim.openstreetmap.org/reverse?${params}`, { - headers: { - 'User-Agent': 'weather-app-v2 (nate@necloud.us)', - Accept: 'application/json', + const r = await fetchWithRetry(`https://nominatim.openstreetmap.org/reverse?${params}`, { + attempts: 1, + timeoutMs: 9000, + fetchOptions: { + headers: { + 'User-Agent': 'weather-app-v2 (nate@necloud.us)', + Accept: 'application/json', + }, }, }); - if (!r.ok) { - throw new Error(`Reverse geocoding failed with status ${r.status}`); - } - const data = await r.json(); const address = data.address || {}; const name = address.city || address.town || address.village || address.hamlet || address.municipality || address.county || address.state || data.name || 'Detected location'; @@ -129,7 +157,8 @@ app.get('/api/reverse-geocode', async (req, res) => { display_name: data.display_name || '', }); } catch (e) { - res.status(500).json({ error: 'Reverse geocoding failed', detail: e.message }); + console.error('Reverse geocoding error:', e.message); + res.status(500).json({ error: 'Reverse geocoding failed' }); } }); @@ -137,6 +166,9 @@ app.get('/api/reverse-geocode', async (req, res) => { app.get('/api/weather', async (req, res) => { const { lat, lon, tz, units } = req.query; if (!lat || !lon) return res.status(400).json({ error: 'Missing lat/lon' }); + if (!isValidCoord(lat, -90, 90) || !isValidCoord(lon, -180, 180)) { + return res.status(400).json({ error: 'Invalid lat/lon' }); + } const timezone = tz || 'auto'; const useMetric = units === 'metric'; const cacheKey = `${Number(lat).toFixed(3)},${Number(lon).toFixed(3)},${timezone},${units || 'us'}`; @@ -163,35 +195,10 @@ app.get('/api/weather', async (req, res) => { precipitation_unit: useMetric ? 'mm' : 'inch' }); - let lastError = null; - const upstreamTimeoutMs = 12000; - for (let attempt = 1; attempt <= 3; attempt++) { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), upstreamTimeoutMs); - try { - const r = await fetch(`https://api.open-meteo.com/v1/forecast?${params}`, { signal: controller.signal }); - clearTimeout(timeoutId); - const body = await r.text(); - - if (!r.ok) { - throw new Error(`Open-Meteo status ${r.status}: ${body.slice(0, 200)}`); - } - - const data = JSON.parse(body); - weatherCache.set(cacheKey, { data, at: Date.now() }); - return res.json(data); - } catch (err) { - clearTimeout(timeoutId); - lastError = err; - const reason = err?.name === 'AbortError' ? `timeout after ${upstreamTimeoutMs}ms` : (err?.message || 'unknown error'); - console.warn(`Weather upstream attempt ${attempt}/3 failed: ${reason}`); - if (attempt < 3) { - await new Promise(resolve => setTimeout(resolve, 500 * attempt)); - } - } - } - - throw lastError || new Error('Weather upstream failed'); + const r = await fetchWithRetry(`https://api.open-meteo.com/v1/forecast?${params}`, { attempts: 3, timeoutMs: 12000, retryDelayMs: 500 }); + const data = await r.json(); + setCache(cacheKey, data); + return res.json(data); } catch (e) { const cached = weatherCache.get(cacheKey); if (cached && Date.now() - cached.at < 45 * 60 * 1000) { @@ -200,7 +207,7 @@ app.get('/api/weather', async (req, res) => { return res.json(cached.data); } console.error('Weather proxy failed:', e.message); - res.status(500).json({ error: 'Weather fetch failed', detail: e.message }); + res.status(502).json({ error: 'Weather fetch failed' }); } }); @@ -208,6 +215,9 @@ app.get('/api/weather', async (req, res) => { app.get('/api/air-quality', async (req, res) => { const { lat, lon, tz } = req.query; if (!lat || !lon) return res.status(400).json({ error: 'Missing lat/lon' }); + if (!isValidCoord(lat, -90, 90) || !isValidCoord(lon, -180, 180)) { + return res.status(400).json({ error: 'Invalid lat/lon' }); + } try { const params = new URLSearchParams({ latitude: lat, @@ -216,30 +226,9 @@ app.get('/api/air-quality', async (req, res) => { current: ['us_aqi', 'pm2_5', 'alder_pollen', 'birch_pollen', 'grass_pollen', 'mugwort_pollen', 'olive_pollen', 'ragweed_pollen'].join(','), forecast_days: 1, }); - let lastError = null; - for (let attempt = 1; attempt <= 2; attempt++) { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 12000); - try { - const r = await fetch(`https://air-quality-api.open-meteo.com/v1/air-quality?${params}`, { - signal: controller.signal, - }); - clearTimeout(timeoutId); - if (!r.ok) { - throw new Error(`Open-Meteo status ${r.status}`); - } - const data = await r.json(); - return res.json(data); - } catch (err) { - clearTimeout(timeoutId); - lastError = err; - if (attempt < 2) { - await new Promise(resolve => setTimeout(resolve, 500)); - } - } - } - - throw lastError || new Error('Air quality upstream failed'); + const r = await fetchWithRetry(`https://air-quality-api.open-meteo.com/v1/air-quality?${params}`, { attempts: 2, timeoutMs: 12000, retryDelayMs: 500 }); + const data = await r.json(); + return res.json(data); } catch (e) { console.error('Air quality fetch error:', e); res.setHeader('X-Air-Quality-Fallback', 'empty'); @@ -251,23 +240,20 @@ app.get('/api/air-quality', async (req, res) => { app.get('/api/alerts', async (req, res) => { const { lat, lon } = req.query; if (!lat || !lon) return res.status(400).json({ error: 'Missing lat/lon' }); + if (!isValidCoord(lat, -90, 90) || !isValidCoord(lon, -180, 180)) { + return res.status(400).json({ error: 'Invalid lat/lon' }); + } try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 12000); - - const r = await fetch(`https://api.weather.gov/alerts/active?point=${lat},${lon}`, { - headers: { - 'User-Agent': 'weather-app-v2 (nate@necloud.us)', - Accept: 'application/geo+json', + const r = await fetchWithRetry(`https://api.weather.gov/alerts/active?point=${lat},${lon}`, { + attempts: 1, + timeoutMs: 12000, + fetchOptions: { + headers: { + 'User-Agent': 'weather-app-v2 (nate@necloud.us)', + Accept: 'application/geo+json', + }, }, - signal: controller.signal, }); - - clearTimeout(timeoutId); - - if (!r.ok) { - throw new Error(`NWS API returned ${r.status}`); - } const data = await r.json(); res.json(data); } catch (e) { @@ -308,10 +294,9 @@ app.get('/api/radar/times', async (req, res) => { }); res.json({ - provider: 'iem-radar-rainviewer-sat', + provider: 'iem-radar-sat', radar: { past: radarPast, - nowcast: [], }, satellite: { infrared: satelliteInfrared, @@ -319,7 +304,7 @@ app.get('/api/radar/times', async (req, res) => { }); } catch (e) { console.error('Radar config error:', e.message); - res.status(500).json({ error: 'Radar times failed', detail: e.message }); + res.status(500).json({ error: 'Radar times failed' }); } });