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.
|
* 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. */
|
/** 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);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue