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:
parent
df6b6fdf92
commit
538ef67b0b
2 changed files with 40 additions and 18 deletions
|
|
@ -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, '"')
|
||||
.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 = `
|
||||
<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 class="font-semibold">${weatherData.weather.temperature}°C</div>
|
||||
<div class="text-xs text-gray-500">${weatherData.weather.weatherDescription}</div>
|
||||
<div class="font-semibold">${escapeHtml(weatherData.weather.temperature)}°C</div>
|
||||
<div class="text-xs text-gray-500">${escapeHtml(weatherData.weather.weatherDescription)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-xs font-semibold" style="color: ${weatherData.skateability.color}">
|
||||
${weatherData.skateability.label}
|
||||
<div class="text-xs font-semibold" style="color: ${escapeHtml(weatherData.skateability.color)}">
|
||||
${escapeHtml(weatherData.skateability.label)}
|
||||
</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>
|
||||
`;
|
||||
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 = `
|
||||
<div>💨 Wind: ${weatherData.weather.windSpeed} km/h ${weatherData.weather.windDirectionLabel}</div>
|
||||
<div>💧 Humidity: ${weatherData.weather.humidity}%</div>
|
||||
${weatherData.airQuality ? `<div>🌫️ Air Quality: ${weatherData.airQuality.aqiLabel} (${weatherData.airQuality.aqi})</div>` : ''}
|
||||
<div>💨 Wind: ${escapeHtml(weatherData.weather.windSpeed)} km/h ${escapeHtml(weatherData.weather.windDirectionLabel)}</div>
|
||||
<div>💧 Humidity: ${escapeHtml(weatherData.weather.humidity)}%</div>
|
||||
${weatherData.airQuality ? `<div>🌫️ Air Quality: ${escapeHtml(weatherData.airQuality.aqiLabel)} (${escapeHtml(weatherData.airQuality.aqi)})</div>` : ''}
|
||||
`;
|
||||
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 = `
|
||||
<div class="font-semibold text-gray-700">${dayName}</div>
|
||||
<div class="text-lg my-1">${day.weatherIcon}</div>
|
||||
<div class="text-gray-600">${day.maxTemp}°/${day.minTemp}°</div>
|
||||
${day.precipitationProbability > 0 ? `<div class="text-blue-600">${day.precipitationProbability}% 🌧️</div>` : ''}
|
||||
<div class="font-semibold text-gray-700">${escapeHtml(dayName)}</div>
|
||||
<div class="text-lg my-1">${escapeHtml(day.weatherIcon)}</div>
|
||||
<div class="text-gray-600">${escapeHtml(day.maxTemp)}°/${escapeHtml(day.minTemp)}°</div>
|
||||
${day.precipitationProbability > 0 ? `<div class="text-blue-600">${escapeHtml(day.precipitationProbability)}% 🌧️</div>` : ''}
|
||||
`;
|
||||
forecastGrid.appendChild(dayDiv);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string> {
|
||||
// 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<string> {
|
||||
const primary = await tryOpenRouter(messages, maxTokens);
|
||||
|
|
|
|||
Loading…
Reference in a new issue