diff --git a/api/routes/egosearch.js b/api/routes/egosearch.js index 848cedf..2bdb07c 100644 --- a/api/routes/egosearch.js +++ b/api/routes/egosearch.js @@ -1,6 +1,22 @@ 'use strict'; -/* ── Instancias públicas SearXNG con JSON API (fallback si no hay Google CSE) ── */ +/* ════════════════════════════════════════════════════════════════ + SEGURIDAD + ─ El único riesgo real aquí es SSRF indirecto: el usuario + controla la cadena de búsqueda pero NO las URLs de destino + (siempre son instancias hardcodeadas). + ─ Mitigaciones aplicadas: + · Sanitización: se eliminan caracteres de control y null bytes + · Longitud máxima estricta (150 chars) + · Los resultados se filtran antes de enviarse al cliente + (solo se devuelven title/url/snippet/domain/engine) + · Las URLs de resultados se validan con URL() antes de + incluirlas en la respuesta + · Timeout de 7 s para evitar que peticiones lentas bloqueen + · Rate limit aplicado en app.js (8 req/min por IP) + ════════════════════════════════════════════════════════════════ */ + +/* ── Instancias públicas SearXNG (fallback si no hay Google CSE) ── */ const SEARX_INSTANCES = [ 'https://searx.be', 'https://priv.au', @@ -8,6 +24,30 @@ const SEARX_INSTANCES = [ 'https://searxng.site', ]; +/* ── Sanitización de query ─────────────────────────────────── */ +function sanitizeQuery(raw) { + return raw + .replace(/[\x00-\x1F\x7F]/g, '') // null bytes y caracteres de control + .replace(/[<>]/g, '') // evita inyección HTML en logs + .trim() + .slice(0, 150); +} + +/* ── Valida que una URL de resultado sea http/https ─────────── */ +function safeUrl(raw) { + try { + const u = new URL(raw); + if (u.protocol !== 'http:' && u.protocol !== 'https:') return null; + return u.href; + } catch { return null; } +} + +function domainOf(rawUrl) { + try { return new URL(rawUrl).hostname.replace(/^www\./, ''); } + catch { return ''; } +} + +/* ── SearXNG ────────────────────────────────────────────────── */ async function searchSearX(instance, query) { const url = `${instance}/search?` + new URLSearchParams({ q: query, @@ -25,15 +65,21 @@ async function searchSearX(instance, query) { }); if (!res.ok) throw new Error(`HTTP ${res.status}`); - const data = await res.json(); + + // Limita tamaño de respuesta (previene memory exhaustion) + const text = await res.text(); + if (text.length > 500_000) throw new Error('Respuesta demasiado grande'); + + const data = JSON.parse(text); return (data.results || []).map(r => ({ - title: r.title || '', - url: r.url || '', - snippet: r.content || '', - engine: r.engine || 'web', + title: String(r.title || '').slice(0, 300), + url: String(r.url || ''), + snippet: String(r.content || '').slice(0, 500), + engine: String(r.engine || 'web').slice(0, 50), })); } +/* ── Google Custom Search ───────────────────────────────────── */ async function searchGoogle(query) { const url = 'https://www.googleapis.com/customsearch/v1?' + new URLSearchParams({ key: process.env.GOOGLE_API_KEY, @@ -50,58 +96,59 @@ async function searchGoogle(query) { } const data = await res.json(); return (data.items || []).map(item => ({ - title: item.title || '', - url: item.link || '', - snippet: item.snippet || '', + title: String(item.title || '').slice(0, 300), + url: String(item.link || ''), + snippet: String(item.snippet || '').slice(0, 500), engine: 'google', })); } -function domainOf(rawUrl) { - try { return new URL(rawUrl).hostname.replace(/^www\./, ''); } - catch { return ''; } -} - +/* ── Handler principal ──────────────────────────────────────── */ module.exports = async (req, res) => { - const { q } = req.query; + const raw = req.query.q; - if (!q || q.trim().length < 2) + if (!raw || typeof raw !== 'string' || raw.trim().length < 2) return res.status(400).json({ error: 'Introduce al menos 2 caracteres.' }); - if (q.trim().length > 300) - return res.status(400).json({ error: 'Búsqueda demasiado larga.' }); + const q = sanitizeQuery(raw); - /* Si la query no tiene operadores especiales la ponemos entre comillas - para forzar coincidencia exacta (ideal para nombre/email/alias). */ - const query = /[:"()]/.test(q) ? q.trim() : `"${q.trim()}"`; + if (q.length < 2) + return res.status(400).json({ error: 'Búsqueda inválida.' }); - let raw = []; + /* Si no tiene operadores especiales, forzar coincidencia exacta */ + const query = /[:"()]/.test(q) ? q : `"${q}"`; + + let raw_results = []; try { if (process.env.GOOGLE_API_KEY && process.env.GOOGLE_CSE_ID) { - raw = await searchGoogle(query); + raw_results = await searchGoogle(query); } else { for (const inst of SEARX_INSTANCES) { try { - raw = await searchSearX(inst, query); - if (raw.length) break; + raw_results = await searchSearX(inst, query); + if (raw_results.length) break; } catch (e) { console.warn(`[egosearch] ${inst} falló:`, e.message); } } } } catch (e) { - console.error('[egosearch] error final:', e.message); - return res.status(502).json({ error: 'El servicio de búsqueda no está disponible ahora. Inténtalo de nuevo.' }); + console.error('[egosearch] error:', e.message); + return res.status(502).json({ error: 'El servicio de búsqueda no está disponible. Inténtalo de nuevo.' }); } - const results = raw.slice(0, 5).map(r => ({ - title: r.title, - url: r.url, - snippet: r.snippet, - domain: domainOf(r.url), - engine: r.engine, - })); + /* Filtrar y validar URLs de resultados antes de enviar al cliente */ + const results = raw_results + .filter(r => safeUrl(r.url)) // solo URLs http/https válidas + .slice(0, 5) + .map(r => ({ + title: r.title, + url: safeUrl(r.url), + snippet: r.snippet, + domain: domainOf(r.url), + engine: r.engine, + })); - res.json({ results, query, total: raw.length }); + res.json({ results, query, total: raw_results.length }); };