resetea.net/api/routes/egosearch.js
hacklab 0ca9203522 Seguridad egosearch + servicio systemd + config nginx
Seguridad egosearch:
- Sanitizar query: elimina null bytes y caracteres de control (<x00-x1F)
- Limitar longitud a 150 chars (antes 300)
- Limitar tamaño de respuesta SearXNG a 500KB (anti memory exhaustion)
- Validar URLs de resultados: solo http/https, descarta el resto
- Acotar title/snippet a 300/500 chars antes de procesar

Infraestructura (pendiente de aplicar con sudo):
- /tmp/resetea-api.service: systemd service para el backend Node
- /tmp/resetea-nginx.conf: nginx con proxy /api/ -> 127.0.0.1:8787

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:57:38 +02:00

154 lines
5.5 KiB
JavaScript

'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 });
};