'use strict'; /* ════════════════════════════════════════════════════════════════ 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', 'https://search.mdosch.de', '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, format: 'json', categories: 'general', language: 'es-ES', }); const res = await fetch(url, { headers: { 'User-Agent': 'resetea.net/1.0 (egosurfing privacy tool)', 'Accept': 'application/json', }, signal: AbortSignal.timeout(7000), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); // 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: 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, cx: process.env.GOOGLE_CSE_ID, q: query, num: 10, hl: 'es', }); const res = await fetch(url, { signal: AbortSignal.timeout(7000) }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.error?.message || `HTTP ${res.status}`); } const data = await res.json(); return (data.items || []).map(item => ({ title: String(item.title || '').slice(0, 300), url: String(item.link || ''), snippet: String(item.snippet || '').slice(0, 500), engine: 'google', })); } /* ── Handler principal ──────────────────────────────────────── */ module.exports = async (req, res) => { const raw = req.query.q; if (!raw || typeof raw !== 'string' || raw.trim().length < 2) return res.status(400).json({ error: 'Introduce al menos 2 caracteres.' }); const q = sanitizeQuery(raw); if (q.length < 2) return res.status(400).json({ error: 'Búsqueda inválida.' }); /* 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_results = await searchGoogle(query); } else { for (const inst of SEARX_INSTANCES) { try { 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:', e.message); return res.status(502).json({ error: 'El servicio de búsqueda no está disponible. Inténtalo de nuevo.' }); } /* 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_results.length }); };