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>
This commit is contained in:
parent
f1d80dde13
commit
0ca9203522
1 changed files with 82 additions and 35 deletions
|
|
@ -1,6 +1,22 @@
|
||||||
'use strict';
|
'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 = [
|
const SEARX_INSTANCES = [
|
||||||
'https://searx.be',
|
'https://searx.be',
|
||||||
'https://priv.au',
|
'https://priv.au',
|
||||||
|
|
@ -8,6 +24,30 @@ const SEARX_INSTANCES = [
|
||||||
'https://searxng.site',
|
'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) {
|
async function searchSearX(instance, query) {
|
||||||
const url = `${instance}/search?` + new URLSearchParams({
|
const url = `${instance}/search?` + new URLSearchParams({
|
||||||
q: query,
|
q: query,
|
||||||
|
|
@ -25,15 +65,21 @@ async function searchSearX(instance, query) {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
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 => ({
|
return (data.results || []).map(r => ({
|
||||||
title: r.title || '',
|
title: String(r.title || '').slice(0, 300),
|
||||||
url: r.url || '',
|
url: String(r.url || ''),
|
||||||
snippet: r.content || '',
|
snippet: String(r.content || '').slice(0, 500),
|
||||||
engine: r.engine || 'web',
|
engine: String(r.engine || 'web').slice(0, 50),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Google Custom Search ───────────────────────────────────── */
|
||||||
async function searchGoogle(query) {
|
async function searchGoogle(query) {
|
||||||
const url = 'https://www.googleapis.com/customsearch/v1?' + new URLSearchParams({
|
const url = 'https://www.googleapis.com/customsearch/v1?' + new URLSearchParams({
|
||||||
key: process.env.GOOGLE_API_KEY,
|
key: process.env.GOOGLE_API_KEY,
|
||||||
|
|
@ -50,58 +96,59 @@ async function searchGoogle(query) {
|
||||||
}
|
}
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
return (data.items || []).map(item => ({
|
return (data.items || []).map(item => ({
|
||||||
title: item.title || '',
|
title: String(item.title || '').slice(0, 300),
|
||||||
url: item.link || '',
|
url: String(item.link || ''),
|
||||||
snippet: item.snippet || '',
|
snippet: String(item.snippet || '').slice(0, 500),
|
||||||
engine: 'google',
|
engine: 'google',
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function domainOf(rawUrl) {
|
/* ── Handler principal ──────────────────────────────────────── */
|
||||||
try { return new URL(rawUrl).hostname.replace(/^www\./, ''); }
|
|
||||||
catch { return ''; }
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = async (req, res) => {
|
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.' });
|
return res.status(400).json({ error: 'Introduce al menos 2 caracteres.' });
|
||||||
|
|
||||||
if (q.trim().length > 300)
|
const q = sanitizeQuery(raw);
|
||||||
return res.status(400).json({ error: 'Búsqueda demasiado larga.' });
|
|
||||||
|
|
||||||
/* Si la query no tiene operadores especiales la ponemos entre comillas
|
if (q.length < 2)
|
||||||
para forzar coincidencia exacta (ideal para nombre/email/alias). */
|
return res.status(400).json({ error: 'Búsqueda inválida.' });
|
||||||
const query = /[:"()]/.test(q) ? q.trim() : `"${q.trim()}"`;
|
|
||||||
|
|
||||||
let raw = [];
|
/* Si no tiene operadores especiales, forzar coincidencia exacta */
|
||||||
|
const query = /[:"()]/.test(q) ? q : `"${q}"`;
|
||||||
|
|
||||||
|
let raw_results = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (process.env.GOOGLE_API_KEY && process.env.GOOGLE_CSE_ID) {
|
if (process.env.GOOGLE_API_KEY && process.env.GOOGLE_CSE_ID) {
|
||||||
raw = await searchGoogle(query);
|
raw_results = await searchGoogle(query);
|
||||||
} else {
|
} else {
|
||||||
for (const inst of SEARX_INSTANCES) {
|
for (const inst of SEARX_INSTANCES) {
|
||||||
try {
|
try {
|
||||||
raw = await searchSearX(inst, query);
|
raw_results = await searchSearX(inst, query);
|
||||||
if (raw.length) break;
|
if (raw_results.length) break;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(`[egosearch] ${inst} falló:`, e.message);
|
console.warn(`[egosearch] ${inst} falló:`, e.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[egosearch] error final:', e.message);
|
console.error('[egosearch] error:', e.message);
|
||||||
return res.status(502).json({ error: 'El servicio de búsqueda no está disponible ahora. Inténtalo de nuevo.' });
|
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 => ({
|
/* 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,
|
title: r.title,
|
||||||
url: r.url,
|
url: safeUrl(r.url),
|
||||||
snippet: r.snippet,
|
snippet: r.snippet,
|
||||||
domain: domainOf(r.url),
|
domain: domainOf(r.url),
|
||||||
engine: r.engine,
|
engine: r.engine,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
res.json({ results, query, total: raw.length });
|
res.json({ results, query, total: raw_results.length });
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue