seguridad: hardening completo OWASP — validación, CSP, headers, XSS
Backend (FLUJOS_APP.js): - Lista blanca de temas válidos (bloquea escaneo libre de la BD) - Validación estricta de subtematica (regex alfanumérico, 80 chars) - Validación de fechas: formato ISO YYYY-MM-DD + rango lógico - Validación de nodos y complejidad con rangos numéricos explícitos - CSP sin unsafe-inline en scriptSrc (eliminado) - Helmet completo activado (X-Frame-Options, X-Content-Type-Options, HSTS...) - Filtro anti-CSRF por cabecera Origin en /api/ - bodyParser con límite 10kb - Cabeceras: Referrer-Policy, X-Content-Type-Options explícitas Frontend (3dscript_eco-corp.html, script_eco-corp.html): - innerHTML eliminado, sustituido por DOM API + textContent Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
954f47996f
commit
f04ab5fa74
3 changed files with 115 additions and 64 deletions
|
|
@ -11,28 +11,46 @@ const { MongoClient } = require('mongodb');
|
|||
const app = express();
|
||||
const port = process.env.PORT || 3000;
|
||||
|
||||
// Configurar Helmet con CSP personalizado
|
||||
// Helmet completo: X-Powered-By off, XSS protection, nosniff, frameguard, HSTS, etc.
|
||||
app.use(helmet());
|
||||
|
||||
// CSP personalizado encima del helmet por defecto
|
||||
app.use(
|
||||
helmet.contentSecurityPolicy({
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: [
|
||||
"'self'",
|
||||
"'unsafe-inline'",
|
||||
'https://unpkg.com',
|
||||
'https://cdnjs.cloudflare.com',
|
||||
'https://fonts.googleapis.com',
|
||||
'https://fonts.gstatic.com',
|
||||
],
|
||||
scriptSrc: ["'self'", 'https://unpkg.com', 'https://cdnjs.cloudflare.com'],
|
||||
styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
|
||||
imgSrc: ["'self'", 'data:', 'blob:', 'https://unpkg.com'],
|
||||
connectSrc: ["'self'", 'ws://localhost:3000'],
|
||||
imgSrc: ["'self'", 'data:', 'blob:'],
|
||||
connectSrc: ["'self'"],
|
||||
fontSrc: ["'self'", 'https://fonts.gstatic.com'],
|
||||
frameAncestors: ["'none'"],
|
||||
objectSrc: ["'none'"],
|
||||
baseUri: ["'self'"],
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
app.use(bodyParser.json());
|
||||
// Prevenir CSRF: solo aceptar peticiones GET a /api desde el mismo origen
|
||||
app.use('/api/', (req, res, next) => {
|
||||
const origin = req.headers.origin;
|
||||
const referer = req.headers.referer;
|
||||
// En producción detrás de nginx no hay Origin en peticiones same-site normales,
|
||||
// pero sí lo habría en peticiones cross-origin maliciosas
|
||||
if (origin && origin !== `https://theflows.net` && origin !== `http://localhost:${port}`) {
|
||||
return res.status(403).json({ error: 'Origen no permitido' });
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// Añadir cabecera anti-clickjacking y anti-MIME-sniffing explícitas
|
||||
app.use((req, res, next) => {
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
next();
|
||||
});
|
||||
|
||||
app.use(bodyParser.json({ limit: '10kb' }));
|
||||
|
||||
// **Definir primero las rutas de la API**
|
||||
|
||||
|
|
@ -53,13 +71,25 @@ app.get('/api/data', async (req, res) => {
|
|||
const imagenesWikiCollection = db.collection('imagenes_wiki'); // scrapeadas (fallback)
|
||||
const comparacionesCollection = db.collection('comparaciones');
|
||||
|
||||
// Sanitizar parámetros: rechazar cualquier valor que no sea string plano
|
||||
// (previene MongoDB operator injection como tema[$ne]=x)
|
||||
// ── Sanitización y validación de parámetros ──────────────────────────────
|
||||
// Rechaza cualquier valor que no sea string (bloquea operator injection)
|
||||
function sanitizeParam(val) {
|
||||
if (typeof val !== 'string') return undefined;
|
||||
return val.trim();
|
||||
}
|
||||
const tema = sanitizeParam(req.query.tema);
|
||||
|
||||
// Lista blanca de temas válidos (evita escaneo libre de la BD)
|
||||
const TEMAS_VALIDOS = new Set([
|
||||
'guerra global', 'inteligencia y seguridad', 'cambio climático',
|
||||
'demografía y sociedad', 'economía y corporaciones', 'otros',
|
||||
'geopolítica conflictos', 'seguridad internacional espionaje',
|
||||
'libertad de prensa periodismo', 'corporaciones poder económico',
|
||||
'populismo extremismo', 'desinformación redes sociales',
|
||||
'privacidad vigilancia masiva', 'biodiversidad medioambiente',
|
||||
'inteligencia artificial tecnología',
|
||||
]);
|
||||
|
||||
const temaRaw = sanitizeParam(req.query.tema);
|
||||
const subtematica = sanitizeParam(req.query.subtematica);
|
||||
const palabraClave = sanitizeParam(req.query.palabraClave);
|
||||
const fechaInicio = sanitizeParam(req.query.fechaInicio);
|
||||
|
|
@ -67,38 +97,59 @@ app.get('/api/data', async (req, res) => {
|
|||
const nodos = sanitizeParam(req.query.nodos);
|
||||
const complejidad = sanitizeParam(req.query.complejidad);
|
||||
|
||||
console.log('Parámetros de consulta:', req.query);
|
||||
|
||||
// Validar que el parámetro 'tema' esté presente
|
||||
// Validar tema contra lista blanca
|
||||
const tema = temaRaw && TEMAS_VALIDOS.has(temaRaw) ? temaRaw : null;
|
||||
if (!tema) {
|
||||
res.status(400).json({ error: 'El parámetro "tema" es obligatorio' });
|
||||
res.status(400).json({ error: 'El parámetro "tema" es obligatorio o no válido' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Construir la consulta para obtener los nodos (noticias, wikipedia y torrents)
|
||||
let nodesQuery = {
|
||||
tema: tema,
|
||||
};
|
||||
if (subtematica) nodesQuery.subtema = subtematica;
|
||||
// Validar subtematica: solo letras, números, espacios y guiones (máx 80 chars)
|
||||
if (subtematica && !/^[\w\s\-áéíóúñüÁÉÍÓÚÑÜ]{1,80}$/.test(subtematica)) {
|
||||
res.status(400).json({ error: 'subtematica no válida' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Validar palabraClave: máx 100 chars, escapar metacaracteres regex
|
||||
let regexKw = null;
|
||||
if (palabraClave) {
|
||||
if (palabraClave.length > 100) {
|
||||
res.status(400).json({ error: 'palabraClave demasiado larga' });
|
||||
return;
|
||||
}
|
||||
const escapedKw = palabraClave.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
nodesQuery.texto = { $regex: escapedKw, $options: 'i' };
|
||||
regexKw = palabraClave.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
// Validar fechas: formato YYYY-MM-DD estricto
|
||||
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
let fechaGte = null, fechaLte = null;
|
||||
if (fechaInicio && fechaFin) {
|
||||
nodesQuery.fecha = {
|
||||
$gte: new Date(fechaInicio),
|
||||
$lte: new Date(fechaFin),
|
||||
};
|
||||
if (!ISO_DATE.test(fechaInicio) || !ISO_DATE.test(fechaFin)) {
|
||||
res.status(400).json({ error: 'Formato de fecha inválido (YYYY-MM-DD)' });
|
||||
return;
|
||||
}
|
||||
fechaGte = new Date(fechaInicio);
|
||||
fechaLte = new Date(fechaFin);
|
||||
if (isNaN(fechaGte) || isNaN(fechaLte) || fechaGte > fechaLte) {
|
||||
res.status(400).json({ error: 'Rango de fechas inválido' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
console.log('Consulta para nodos:', nodesQuery);
|
||||
|
||||
// Obtener el límite de nodos (con un máximo para evitar sobrecarga)
|
||||
const nodosLimit = Math.min(parseInt(nodos) || 100, 500);
|
||||
// Validar nodos: entero positivo entre 1 y 500
|
||||
const nodosRaw = parseInt(nodos, 10);
|
||||
const nodosLimit = (!isNaN(nodosRaw) && nodosRaw > 0) ? Math.min(nodosRaw, 500) : 100;
|
||||
|
||||
// Validar complejidad: float entre 0 y 100
|
||||
const complejidadRaw = parseFloat(complejidad);
|
||||
const porcentajeSimilitudMin = (!isNaN(complejidadRaw) && complejidadRaw >= 0 && complejidadRaw <= 100)
|
||||
? complejidadRaw : 0;
|
||||
|
||||
// Construir query de MongoDB solo con valores validados
|
||||
const nodesQuery = { tema };
|
||||
if (subtematica) nodesQuery.subtema = subtematica;
|
||||
if (regexKw) nodesQuery.texto = { $regex: regexKw, $options: 'i' };
|
||||
if (fechaGte) nodesQuery.fecha = { $gte: fechaGte, $lte: fechaLte };
|
||||
// Ejecutar la consulta y obtener los resultados de las colecciones
|
||||
console.log('Ejecutando consulta para nodos en colecciones...');
|
||||
// Límite de imágenes: un tercio del total para no saturar el grafo
|
||||
|
|
@ -156,10 +207,6 @@ app.get('/api/data', async (req, res) => {
|
|||
const nodeIds = formattedNodes_final.map(node => node.id);
|
||||
console.log('IDs de nodos:', nodeIds);
|
||||
|
||||
// Leer el parámetro 'complejidad' de la query (que en tu front envías como % de similitud)
|
||||
const porcentajeSimilitudMin = parseFloat(complejidad) || 0;
|
||||
console.log('Porcentaje de similitud mínimo (desde complejidad):', porcentajeSimilitudMin);
|
||||
|
||||
// Construir la consulta para obtener los enlaces relacionados con los nodos obtenidos
|
||||
const linksQuery = {
|
||||
porcentaje_similitud: { $gte: porcentajeSimilitudMin },
|
||||
|
|
|
|||
|
|
@ -85,24 +85,27 @@
|
|||
cubeContainer.style.top = position.y + 'px';
|
||||
cubeContainer.style.left = position.x + 'px';
|
||||
|
||||
cubeContainer.innerHTML = `
|
||||
<div class="cube">
|
||||
<div class="face front">País ${i}</div>
|
||||
<div class="face back">Detalle ${i}</div>
|
||||
<div class="face left">Año ${2000 + i}</div>
|
||||
<div class="face right">Muertos ${(i + 1) * 1000}</div>
|
||||
<div class="face top">ONU</div>
|
||||
<div class="face bottom">Fuente</div>
|
||||
</div>
|
||||
`;
|
||||
const cube = document.createElement('div');
|
||||
cube.className = 'cube';
|
||||
[['front', `País ${i}`], ['back', `Detalle ${i}`],
|
||||
['left', `Año ${2000 + i}`], ['right', `Muertos ${(i + 1) * 1000}`],
|
||||
['top', 'ONU'], ['bottom', 'Fuente']].forEach(([cls, txt]) => {
|
||||
const face = document.createElement('div');
|
||||
face.className = `face ${cls}`;
|
||||
face.textContent = txt;
|
||||
cube.appendChild(face);
|
||||
});
|
||||
cubeContainer.appendChild(cube);
|
||||
|
||||
// Crear un popup para cada cubo
|
||||
const popup = document.createElement('div');
|
||||
popup.classList.add('popup');
|
||||
popup.innerHTML = `
|
||||
<p>Noticia relacionada con País ${i}</p>
|
||||
<button>Close</button>
|
||||
`;
|
||||
const p = document.createElement('p');
|
||||
p.textContent = `Noticia relacionada con País ${i}`;
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = 'Close';
|
||||
popup.appendChild(p);
|
||||
popup.appendChild(btn);
|
||||
|
||||
popup.querySelector('button').addEventListener('click', () => {
|
||||
popup.style.display = 'none';
|
||||
|
|
|
|||
|
|
@ -38,16 +38,17 @@
|
|||
cubeContainer.style.top = position.y + 'px';
|
||||
cubeContainer.style.left = position.x + 'px';
|
||||
|
||||
cubeContainer.innerHTML = `
|
||||
<div class="cube">
|
||||
<div class="face front">País ${i+1}</div>
|
||||
<div class="face back">Detalle ${i+1}</div>
|
||||
<div class="face left">Año ${2010 + i}</div>
|
||||
<div class="face right">Muertos ${(i+1)*1000}</div>
|
||||
<div class="face top">ONU</div>
|
||||
<div class="face bottom">Fuente</div>
|
||||
</div>
|
||||
`;
|
||||
const cube = document.createElement('div');
|
||||
cube.className = 'cube';
|
||||
[['front', `País ${i+1}`], ['back', `Detalle ${i+1}`],
|
||||
['left', `Año ${2010 + i}`], ['right', `Muertos ${(i+1)*1000}`],
|
||||
['top', 'ONU'], ['bottom', 'Fuente']].forEach(([cls, txt]) => {
|
||||
const face = document.createElement('div');
|
||||
face.className = `face ${cls}`;
|
||||
face.textContent = txt;
|
||||
cube.appendChild(face);
|
||||
});
|
||||
cubeContainer.appendChild(cube);
|
||||
|
||||
cubePositions.push({x: position.x + 35, y: position.y + 35}); // Ajustar al centro del cubo
|
||||
graphContainer.appendChild(cubeContainer);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue