From 538ef67b0b25acb9b52f800ef7a36ccc60fbfc07 Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Tue, 18 Aug 2026 17:04:53 +0700 Subject: [PATCH] Security: remove hardcoded OpenRouter key (C1) + escape weather HTML (H1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the two genuinely exploitable findings from the OpenCode audit: C1 (critical) — sasquatchBrain.ts shipped a hardcoded OpenRouter API key in the client bundle, extractable and abusable by anyone. Removed it; OpenRouter is now opt-in via VITE_OPENROUTER_API_KEY and skipped when unset. Sasquatch still works for everyone via the existing keyless Pollinations fallback. (The leaked key must also be rotated/revoked on openrouter.ai.) H1 (high) — SpotMap popup interpolated external weather-API data directly into innerHTML. Added an escapeHtml() helper and applied it to every weather/AQI/ forecast value so a compromised weather API can't inject HTML/JS. Triage of the rest: C3 (crew agent derived key) is a documented, deliberate tradeoff — agent holds no funds and only speaks for itself. H2 (chart.tsx dangerouslySetInnerHTML) is the stock shadcn chart with app-controlled config, not user input. H3/H4 (GitHub Actions) don't apply — no .github/ in this repo. C2 (NWC in localStorage) is standard for web Nostr clients. npm audit fix + key rotation are operator steps done outside the app. Co-authored-by: shakespeare.diy --- src/components/SpotMap.tsx | 40 +++++++++++++++++++++++++------------- src/lib/sasquatchBrain.ts | 18 ++++++++++++----- 2 files changed, 40 insertions(+), 18 deletions(-) diff --git a/src/components/SpotMap.tsx b/src/components/SpotMap.tsx index 9a062c4..bf9abcc 100644 --- a/src/components/SpotMap.tsx +++ b/src/components/SpotMap.tsx @@ -22,6 +22,20 @@ import { sanitizeUrl } from '@/lib/sanitizeUrl'; * an `href`/`src`. See .agents/skills/nostr-security. */ +/** + * Escape a value for safe interpolation into an innerHTML template literal. + * Weather/air-quality fields come from an external API, so they're untrusted — + * a compromised or malicious upstream could otherwise inject HTML/JS. + */ +function escapeHtml(value: unknown): string { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + /** A popup detail row: an emoji/icon prefix + untrusted text set via textContent. */ function popupRow(prefix: string, text: string): HTMLElement { const row = document.createElement('div'); @@ -516,17 +530,17 @@ function createPopupContent( currentWeather.className = 'flex items-center justify-between mb-2'; currentWeather.innerHTML = `
- ${weatherData.weather.weatherIcon} + ${escapeHtml(weatherData.weather.weatherIcon)}
-
${weatherData.weather.temperature}°C
-
${weatherData.weather.weatherDescription}
+
${escapeHtml(weatherData.weather.temperature)}°C
+
${escapeHtml(weatherData.weather.weatherDescription)}
-
- ${weatherData.skateability.label} +
+ ${escapeHtml(weatherData.skateability.label)}
-
${weatherData.skateability.score}/100
+
${escapeHtml(weatherData.skateability.score)}/100
`; weatherSection.appendChild(currentWeather); @@ -535,9 +549,9 @@ function createPopupContent( const details = document.createElement('div'); details.className = 'text-xs space-y-1 text-gray-600'; details.innerHTML = ` -
💨 Wind: ${weatherData.weather.windSpeed} km/h ${weatherData.weather.windDirectionLabel}
-
💧 Humidity: ${weatherData.weather.humidity}%
- ${weatherData.airQuality ? `
🌫️ Air Quality: ${weatherData.airQuality.aqiLabel} (${weatherData.airQuality.aqi})
` : ''} +
💨 Wind: ${escapeHtml(weatherData.weather.windSpeed)} km/h ${escapeHtml(weatherData.weather.windDirectionLabel)}
+
💧 Humidity: ${escapeHtml(weatherData.weather.humidity)}%
+ ${weatherData.airQuality ? `
🌫️ Air Quality: ${escapeHtml(weatherData.airQuality.aqiLabel)} (${escapeHtml(weatherData.airQuality.aqi)})
` : ''} `; weatherSection.appendChild(details); @@ -560,10 +574,10 @@ function createPopupContent( const date = new Date(day.date); const dayName = date.toLocaleDateString('en-US', { weekday: 'short' }); dayDiv.innerHTML = ` -
${dayName}
-
${day.weatherIcon}
-
${day.maxTemp}°/${day.minTemp}°
- ${day.precipitationProbability > 0 ? `
${day.precipitationProbability}% 🌧️
` : ''} +
${escapeHtml(dayName)}
+
${escapeHtml(day.weatherIcon)}
+
${escapeHtml(day.maxTemp)}°/${escapeHtml(day.minTemp)}°
+ ${day.precipitationProbability > 0 ? `
${escapeHtml(day.precipitationProbability)}% 🌧️
` : ''} `; forecastGrid.appendChild(dayDiv); }); diff --git a/src/lib/sasquatchBrain.ts b/src/lib/sasquatchBrain.ts index 485457a..3ce45f0 100644 --- a/src/lib/sasquatchBrain.ts +++ b/src/lib/sasquatchBrain.ts @@ -4,8 +4,13 @@ * surface can reuse the same model list + fallback behaviour. */ -// ─── OpenRouter free models — no credits, no NIP-98, works for everyone ─────── -const OPENROUTER_API_KEY = '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'; +// ─── OpenRouter free models ────────────────────────────────────────────────── +// SECURITY: never hardcode an API key here. A key committed to source is shipped +// in the production JS bundle and can be extracted + abused by anyone. Instead, +// OpenRouter is OPT-IN via an optional build-time env var. When it's not set +// (the default), we skip OpenRouter entirely and use the keyless Pollinations +// backup below — so Sasquatch still works for everyone with zero secrets shipped. +const OPENROUTER_API_KEY: string | undefined = import.meta.env.VITE_OPENROUTER_API_KEY; const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions'; // Try multiple free models in order — if one is rate-limited, fall back. @@ -35,6 +40,8 @@ const POLLINATIONS_MODELS = ['openai', 'mistral']; /** Try OpenRouter's free models, respecting 429 backoff. Returns text or ''. */ async function tryOpenRouter(messages: ChatTurn[], maxTokens: number): Promise { + // No key configured → skip OpenRouter entirely (keyless backup handles it). + if (!OPENROUTER_API_KEY) return ''; for (const model of FREE_MODELS) { for (let attempt = 0; attempt < 2; attempt++) { try { @@ -105,9 +112,10 @@ async function tryPollinations(messages: ChatTurn[], maxTokens: number): Promise /** * Call the AI with multi-provider fallback + rate-limit backoff. * - * Order: OpenRouter free models (with 429 retry) → keyless Pollinations backup. - * Throws only if BOTH providers fail, so the caller can fall back to local - * knowledge (static FAQ / glossary). + * Order: OpenRouter free models (only if VITE_OPENROUTER_API_KEY is set, with + * 429 retry) → keyless Pollinations backup. Throws only if all available + * providers fail, so the caller can fall back to local knowledge + * (static FAQ / glossary). */ export async function callSasquatchAI(messages: ChatTurn[], maxTokens = 400): Promise { const primary = await tryOpenRouter(messages, maxTokens);