Security: remove hardcoded OpenRouter key (C1) + escape weather HTML (H1)

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 <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-08-18 17:04:53 +07:00
parent df6b6fdf92
commit 538ef67b0b
2 changed files with 40 additions and 18 deletions

View file

@ -22,6 +22,20 @@ import { sanitizeUrl } from '@/lib/sanitizeUrl';
* an `href`/`src`. See .agents/skills/nostr-security. * 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
/** A popup detail row: an emoji/icon prefix + untrusted text set via textContent. */ /** A popup detail row: an emoji/icon prefix + untrusted text set via textContent. */
function popupRow(prefix: string, text: string): HTMLElement { function popupRow(prefix: string, text: string): HTMLElement {
const row = document.createElement('div'); const row = document.createElement('div');
@ -516,17 +530,17 @@ function createPopupContent(
currentWeather.className = 'flex items-center justify-between mb-2'; currentWeather.className = 'flex items-center justify-between mb-2';
currentWeather.innerHTML = ` currentWeather.innerHTML = `
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="text-2xl">${weatherData.weather.weatherIcon}</span> <span class="text-2xl">${escapeHtml(weatherData.weather.weatherIcon)}</span>
<div> <div>
<div class="font-semibold">${weatherData.weather.temperature}°C</div> <div class="font-semibold">${escapeHtml(weatherData.weather.temperature)}°C</div>
<div class="text-xs text-gray-500">${weatherData.weather.weatherDescription}</div> <div class="text-xs text-gray-500">${escapeHtml(weatherData.weather.weatherDescription)}</div>
</div> </div>
</div> </div>
<div class="text-right"> <div class="text-right">
<div class="text-xs font-semibold" style="color: ${weatherData.skateability.color}"> <div class="text-xs font-semibold" style="color: ${escapeHtml(weatherData.skateability.color)}">
${weatherData.skateability.label} ${escapeHtml(weatherData.skateability.label)}
</div> </div>
<div class="text-xs text-gray-500">${weatherData.skateability.score}/100</div> <div class="text-xs text-gray-500">${escapeHtml(weatherData.skateability.score)}/100</div>
</div> </div>
`; `;
weatherSection.appendChild(currentWeather); weatherSection.appendChild(currentWeather);
@ -535,9 +549,9 @@ function createPopupContent(
const details = document.createElement('div'); const details = document.createElement('div');
details.className = 'text-xs space-y-1 text-gray-600'; details.className = 'text-xs space-y-1 text-gray-600';
details.innerHTML = ` details.innerHTML = `
<div>💨 Wind: ${weatherData.weather.windSpeed} km/h ${weatherData.weather.windDirectionLabel}</div> <div>💨 Wind: ${escapeHtml(weatherData.weather.windSpeed)} km/h ${escapeHtml(weatherData.weather.windDirectionLabel)}</div>
<div>💧 Humidity: ${weatherData.weather.humidity}%</div> <div>💧 Humidity: ${escapeHtml(weatherData.weather.humidity)}%</div>
${weatherData.airQuality ? `<div>🌫️ Air Quality: ${weatherData.airQuality.aqiLabel} (${weatherData.airQuality.aqi})</div>` : ''} ${weatherData.airQuality ? `<div>🌫️ Air Quality: ${escapeHtml(weatherData.airQuality.aqiLabel)} (${escapeHtml(weatherData.airQuality.aqi)})</div>` : ''}
`; `;
weatherSection.appendChild(details); weatherSection.appendChild(details);
@ -560,10 +574,10 @@ function createPopupContent(
const date = new Date(day.date); const date = new Date(day.date);
const dayName = date.toLocaleDateString('en-US', { weekday: 'short' }); const dayName = date.toLocaleDateString('en-US', { weekday: 'short' });
dayDiv.innerHTML = ` dayDiv.innerHTML = `
<div class="font-semibold text-gray-700">${dayName}</div> <div class="font-semibold text-gray-700">${escapeHtml(dayName)}</div>
<div class="text-lg my-1">${day.weatherIcon}</div> <div class="text-lg my-1">${escapeHtml(day.weatherIcon)}</div>
<div class="text-gray-600">${day.maxTemp}°/${day.minTemp}°</div> <div class="text-gray-600">${escapeHtml(day.maxTemp)}°/${escapeHtml(day.minTemp)}°</div>
${day.precipitationProbability > 0 ? `<div class="text-blue-600">${day.precipitationProbability}% 🌧️</div>` : ''} ${day.precipitationProbability > 0 ? `<div class="text-blue-600">${escapeHtml(day.precipitationProbability)}% 🌧️</div>` : ''}
`; `;
forecastGrid.appendChild(dayDiv); forecastGrid.appendChild(dayDiv);
}); });

View file

@ -4,8 +4,13 @@
* surface can reuse the same model list + fallback behaviour. * surface can reuse the same model list + fallback behaviour.
*/ */
// ─── OpenRouter free models — no credits, no NIP-98, works for everyone ─────── // ─── OpenRouter free models ──────────────────────────────────────────────────
const OPENROUTER_API_KEY = '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'; // 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'; const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
// Try multiple free models in order — if one is rate-limited, fall back. // 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 ''. */ /** Try OpenRouter's free models, respecting 429 backoff. Returns text or ''. */
async function tryOpenRouter(messages: ChatTurn[], maxTokens: number): Promise<string> { async function tryOpenRouter(messages: ChatTurn[], maxTokens: number): Promise<string> {
// No key configured → skip OpenRouter entirely (keyless backup handles it).
if (!OPENROUTER_API_KEY) return '';
for (const model of FREE_MODELS) { for (const model of FREE_MODELS) {
for (let attempt = 0; attempt < 2; attempt++) { for (let attempt = 0; attempt < 2; attempt++) {
try { try {
@ -105,9 +112,10 @@ async function tryPollinations(messages: ChatTurn[], maxTokens: number): Promise
/** /**
* Call the AI with multi-provider fallback + rate-limit backoff. * Call the AI with multi-provider fallback + rate-limit backoff.
* *
* Order: OpenRouter free models (with 429 retry) → keyless Pollinations backup. * Order: OpenRouter free models (only if VITE_OPENROUTER_API_KEY is set, with
* Throws only if BOTH providers fail, so the caller can fall back to local * 429 retry) → keyless Pollinations backup. Throws only if all available
* knowledge (static FAQ / glossary). * providers fail, so the caller can fall back to local knowledge
* (static FAQ / glossary).
*/ */
export async function callSasquatchAI(messages: ChatTurn[], maxTokens = 400): Promise<string> { export async function callSasquatchAI(messages: ChatTurn[], maxTokens = 400): Promise<string> {
const primary = await tryOpenRouter(messages, maxTokens); const primary = await tryOpenRouter(messages, maxTokens);