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
This commit is contained in:
Nate Emmert committed 2026-06-15 08:51:18 -04:00
1 parent 49b54e0fe4
commit 1863f6784b
4 files changed
+104 -144

No files matched your search

+91 -106
View File
@@ -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' });
}
});