1142 lines
30 KiB
Markdown
1142 lines
30 KiB
Markdown
# 🔒 AUDITORÍA DE SEGURIDAD Y DEVOPS
|
|
|
|
**Fecha:** 2025-01-07
|
|
**Auditor:** Especialista en Seguridad y DevOps
|
|
**Versión del Proyecto:** 1.0.0
|
|
|
|
---
|
|
|
|
## 📊 RESUMEN EJECUTIVO
|
|
|
|
| Categoría | Estado | Calificación | Críticas |
|
|
|-----------|--------|--------------|----------|
|
|
| **Vulnerabilidades Inmediatas** | 🔴 CRÍTICO | 4/10 | 6 |
|
|
| **Buenas Prácticas** | ⚠️ BÁSICA | 5.5/10 | - |
|
|
| **Infraestructura/Deploy** | ⚠️ MEJORABLE | 6/10 | 2 |
|
|
| **Dependencias** | ⚠️ NO VERIFICADO | 5/10 | - |
|
|
|
|
**Calificación General: 5.1/10** 🔴 **VULNERABLE - ACCIÓN INMEDIATA REQUERIDA**
|
|
|
|
---
|
|
|
|
## 🔴 1. VULNERABILIDADES CRÍTICAS INMEDIATAS
|
|
|
|
### 1.1 Hardcoded Secrets
|
|
|
|
#### 🔴 **CRÍTICA #1: JWT Secret con Fallback Débil**
|
|
|
|
**Ubicación:** `backend/src/routes/auth.js:11` y `backend/src/middleware/auth.js:8`
|
|
|
|
```javascript
|
|
// ❌ VULNERABILIDAD CRÍTICA
|
|
const JWT_SECRET = process.env.JWT_SECRET || 'emerges-tes-secret-key-change-in-production';
|
|
```
|
|
|
|
**Problema:**
|
|
- Si `JWT_SECRET` no está en `.env`, usa un secret débil conocido
|
|
- Secret por defecto es predecible y está hardcodeado en el código
|
|
- Cualquier atacante puede forjar tokens JWT si no hay `.env`
|
|
|
|
**Impacto:** CRÍTICO
|
|
- Autenticación comprometida completamente
|
|
- Cualquiera puede generar tokens válidos
|
|
- Acceso no autorizado a toda la API
|
|
|
|
**Fix Inmediato:**
|
|
|
|
```javascript
|
|
// ✅ FIX: Validar que JWT_SECRET existe en startup
|
|
import dotenv from 'dotenv';
|
|
dotenv.config();
|
|
|
|
const JWT_SECRET = process.env.JWT_SECRET;
|
|
if (!JWT_SECRET || JWT_SECRET === 'emerges-tes-secret-key-change-in-production') {
|
|
console.error('❌ CRÍTICO: JWT_SECRET no está configurado o usa valor por defecto');
|
|
console.error(' Configura JWT_SECRET en .env (generar con: openssl rand -base64 32)');
|
|
process.exit(1);
|
|
}
|
|
|
|
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '24h';
|
|
```
|
|
|
|
**Archivos a modificar:**
|
|
- `backend/src/routes/auth.js` (línea 11)
|
|
- `backend/src/middleware/auth.js` (línea 8)
|
|
- `backend/src/index.js` (añadir validación en startup)
|
|
|
|
**Esfuerzo:** 30 minutos
|
|
**Prioridad:** 🔴 **1º (CRÍTICA - HACER YA)**
|
|
|
|
---
|
|
|
|
#### 🔴 **CRÍTICA #2: Webhook Secret Hardcoded**
|
|
|
|
**Ubicación:** `webhook-deploy.sh:10`
|
|
|
|
```bash
|
|
# ❌ VULNERABILIDAD CRÍTICA
|
|
SECRET="TU_SECRET_AQUI" # Cambiar por un secret seguro
|
|
```
|
|
|
|
**Problema:**
|
|
- Secret hardcoded en script de deploy
|
|
- Script puede ser ejecutado por cualquiera que tenga acceso al repositorio
|
|
- Sin verificación HMAC de webhook de GitHub
|
|
|
|
**Impacto:** ALTA
|
|
- Deploy automático sin autenticación
|
|
- Posible compromiso del servidor de producción
|
|
|
|
**Fix Inmediato:**
|
|
|
|
```bash
|
|
# ✅ FIX: Usar variable de entorno
|
|
SECRET="${WEBHOOK_SECRET}" # Configurar en .env o variables del sistema
|
|
|
|
if [ -z "$SECRET" ]; then
|
|
log "ERROR: WEBHOOK_SECRET no está configurado"
|
|
exit 1
|
|
fi
|
|
|
|
# Verificar HMAC de GitHub
|
|
SIGNATURE=$(echo "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" | cut -d' ' -f2)
|
|
GITHUB_SIG=$(echo "$PAYLOAD" | jq -r '.headers["X-Hub-Signature-256"]' | cut -d'=' -f2)
|
|
|
|
if [ "$SIGNATURE" != "$GITHUB_SIG" ]; then
|
|
log "ERROR: Invalid webhook signature"
|
|
exit 1
|
|
fi
|
|
```
|
|
|
|
**Archivo a modificar:**
|
|
- `webhook-deploy.sh` (línea 10, añadir verificación HMAC)
|
|
|
|
**Esfuerzo:** 1 hora
|
|
**Prioridad:** 🔴 **2º (CRÍTICA)**
|
|
|
|
---
|
|
|
|
### 1.2 Inyecciones Potenciales
|
|
|
|
#### 🔴 **CRÍTICA #3: Sin Validación de Inputs (SQL Injection Mitigado pero Falta Validación)**
|
|
|
|
**Ubicación:** `backend/src/routes/content.js:19-114`
|
|
|
|
```javascript
|
|
// ⚠️ PROBLEMA: Usa parámetros ($1, $2) pero NO valida formato de inputs
|
|
const { type, level, status, category, page = 1, pageSize = 20, search } = req.query;
|
|
|
|
// Problema: search puede contener caracteres especiales
|
|
if (search) {
|
|
whereConditions.push(`(title ILIKE $${paramIndex} OR short_title ILIKE $${paramIndex})`);
|
|
params.push(`%${search}%`); // ⚠️ Sin validación de formato
|
|
paramIndex++;
|
|
}
|
|
```
|
|
|
|
**Problema:**
|
|
- SQL Injection mitigado con parámetros ($1, $2), pero falta validación de formato
|
|
- Inputs como `search`, `type`, `status` no son validados contra valores permitidos
|
|
- Possible injection de formato (ej: `%' OR '1'='1` en search)
|
|
|
|
**Impacto:** ALTA
|
|
- Aunque protegido contra SQL injection clásico, falta validación puede causar comportamientos inesperados
|
|
- Error en queries si valores no son del formato esperado
|
|
|
|
**Fix Inmediato:**
|
|
|
|
```javascript
|
|
// ✅ FIX: Añadir validación con Zod
|
|
import { z } from 'zod';
|
|
|
|
const contentQuerySchema = z.object({
|
|
type: z.enum(['protocol', 'guide', 'drug', 'checklist', 'manual']).optional(),
|
|
level: z.enum(['operativo', 'formativo']).optional(),
|
|
status: z.enum(['draft', 'in_review', 'approved', 'published', 'archived']).optional(),
|
|
category: z.string().max(100).optional(),
|
|
page: z.coerce.number().int().positive().default(1),
|
|
pageSize: z.coerce.number().int().positive().max(100).default(20),
|
|
search: z.string().max(200).optional(),
|
|
});
|
|
|
|
router.get('/', requirePermission('content:read'), async (req, res) => {
|
|
try {
|
|
// Validar y sanitizar inputs
|
|
const validated = contentQuerySchema.parse(req.query);
|
|
|
|
const { type, level, status, category, page, pageSize, search } = validated;
|
|
|
|
// Sanitizar search: remover caracteres especiales peligrosos
|
|
const sanitizedSearch = search
|
|
? search.replace(/[%_'"]/g, '').trim() // Remover caracteres SQL especiales
|
|
: undefined;
|
|
|
|
// ... resto de la lógica
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) {
|
|
return res.status(400).json({ error: 'Parámetros inválidos', details: error.errors });
|
|
}
|
|
throw error;
|
|
}
|
|
});
|
|
```
|
|
|
|
**Archivos a modificar:**
|
|
- `backend/src/routes/content.js` (añadir validación en todas las rutas)
|
|
- `backend/src/routes/drugs.js` (añadir validación)
|
|
- `backend/src/routes/media.js` (añadir validación)
|
|
- `backend/src/routes/auth.js` (añadir validación de email)
|
|
|
|
**Esfuerzo:** 2 horas por archivo (8 horas total)
|
|
**Prioridad:** 🔴 **3º (ALTA)**
|
|
|
|
---
|
|
|
|
#### ⚠️ **CRÍTICA #4: XSS en Frontend (innerHTML sin Sanitización)**
|
|
|
|
**Ubicación 1:** `src/main.tsx:137`
|
|
|
|
```typescript
|
|
// ❌ VULNERABILIDAD XSS
|
|
rootElement.innerHTML = `
|
|
<div style="padding: 2rem; text-align: center; font-family: sans-serif;">
|
|
<h1>Error al cargar la aplicación</h1>
|
|
<p>Por favor, recarga la página. Si el problema persiste, limpia la caché del navegador.</p>
|
|
<p style="color: #666; font-size: 0.9rem;">Error: ${error instanceof Error ? error.message : String(error)}</p>
|
|
</div>
|
|
`;
|
|
```
|
|
|
|
**Ubicación 2:** `src/pages/GaleriaImagenes.tsx:159`
|
|
|
|
```typescript
|
|
// ❌ VULNERABILIDAD XSS
|
|
parent.innerHTML = `
|
|
<div class="flex flex-col items-center justify-center p-4 text-center">
|
|
<!-- ... -->
|
|
</div>
|
|
`;
|
|
```
|
|
|
|
**Problema:**
|
|
- `innerHTML` usado con contenido que puede contener código malicioso
|
|
- `error.message` puede contener payloads XSS
|
|
- Sin sanitización de HTML
|
|
|
|
**Impacto:** MEDIA-ALTA
|
|
- XSS si `error.message` contiene código malicioso
|
|
- Aunque improbable, puede ser explotado si hay logging de errores desde inputs del usuario
|
|
|
|
**Fix Inmediato:**
|
|
|
|
```typescript
|
|
// ✅ FIX: Sanitizar o usar DOM seguro
|
|
import DOMPurify from 'isomorphic-dompurify';
|
|
|
|
// Opción 1: Sanitizar con DOMPurify
|
|
const sanitizedError = DOMPurify.sanitize(error instanceof Error ? error.message : String(error));
|
|
|
|
rootElement.innerHTML = `
|
|
<div style="padding: 2rem; text-align: center; font-family: sans-serif;">
|
|
<h1>Error al cargar la aplicación</h1>
|
|
<p>Por favor, recarga la página. Si el problema persiste, limpia la caché del navegador.</p>
|
|
<p style="color: #666; font-size: 0.9rem;">Error: ${sanitizedError}</p>
|
|
</div>
|
|
`;
|
|
|
|
// Opción 2 (MEJOR): Usar DOM seguro (recomendado)
|
|
const errorDiv = document.createElement('div');
|
|
errorDiv.className = 'error-container';
|
|
errorDiv.innerHTML = `
|
|
<h1>Error al cargar la aplicación</h1>
|
|
<p>Por favor, recarga la página.</p>
|
|
`;
|
|
|
|
const errorText = document.createElement('p');
|
|
errorText.style.color = '#666';
|
|
errorText.style.fontSize = '0.9rem';
|
|
errorText.textContent = `Error: ${error instanceof Error ? error.message : String(error)}`;
|
|
errorDiv.appendChild(errorText);
|
|
|
|
rootElement.innerHTML = '';
|
|
rootElement.appendChild(errorDiv);
|
|
```
|
|
|
|
**Archivos a modificar:**
|
|
- `src/main.tsx` (línea 137)
|
|
- `src/pages/GaleriaImagenes.tsx` (línea 159)
|
|
|
|
**Esfuerzo:** 1 hora
|
|
**Prioridad:** 🟡 **4º (MEDIA-ALTA)**
|
|
|
|
---
|
|
|
|
#### ⚠️ **CRÍTICA #5: dangerouslySetInnerHTML sin Sanitización**
|
|
|
|
**Ubicación:** `src/components/ui/chart.tsx:70`
|
|
|
|
```typescript
|
|
// ⚠️ PROBLEMA: dangerouslySetInnerHTML con contenido dinámico
|
|
<style
|
|
dangerouslySetInnerHTML={{
|
|
__html: Object.entries(THEMES)
|
|
.map(([theme, prefix]) => `
|
|
${prefix} [data-chart=${id}] {
|
|
/* ... CSS dinámico ... */
|
|
}`)
|
|
.join('\n')
|
|
}}
|
|
/>
|
|
```
|
|
|
|
**Problema:**
|
|
- Aunque CSS es menos peligroso que HTML, `id` puede ser controlado externamente
|
|
- Si `id` contiene caracteres especiales, puede romper el CSS o causar problemas
|
|
|
|
**Impacto:** BAJA-MEDIA
|
|
- Menor riesgo porque es CSS, pero `id` debería ser validado
|
|
|
|
**Fix Inmediato:**
|
|
|
|
```typescript
|
|
// ✅ FIX: Validar y sanitizar id
|
|
const sanitizeId = (id: string): string => {
|
|
// Solo permitir caracteres alfanuméricos, guiones y guiones bajos
|
|
return id.replace(/[^a-zA-Z0-9_-]/g, '');
|
|
};
|
|
|
|
// En el componente:
|
|
const safeId = sanitizeId(id);
|
|
|
|
<style
|
|
dangerouslySetInnerHTML={{
|
|
__html: Object.entries(THEMES)
|
|
.map(([theme, prefix]) => `
|
|
${prefix} [data-chart="${safeId}"] {
|
|
/* ... CSS dinámico ... */
|
|
}`)
|
|
.join('\n')
|
|
}}
|
|
/>
|
|
```
|
|
|
|
**Archivo a modificar:**
|
|
- `src/components/ui/chart.tsx` (línea 70)
|
|
|
|
**Esfuerzo:** 30 minutos
|
|
**Prioridad:** 🟡 **5º (BAJA-MEDIA)**
|
|
|
|
---
|
|
|
|
### 1.3 Autenticación/Autorización
|
|
|
|
#### 🔴 **CRÍTICA #6: CORS Permisivo en Desarrollo**
|
|
|
|
**Ubicación:** `backend/src/index.js:37-53`
|
|
|
|
```javascript
|
|
// ❌ VULNERABILIDAD: CORS acepta cualquier origen en desarrollo
|
|
app.use(cors({
|
|
origin: (origin, callback) => {
|
|
// Permitir requests sin origen (mobile apps, Postman, etc.) en desarrollo
|
|
if (!origin || process.env.NODE_ENV === 'development') {
|
|
return callback(null, true); // ⚠️ Acepta CUALQUIER origen en dev
|
|
}
|
|
|
|
if (allowedOrigins.indexOf(origin) !== -1) {
|
|
callback(null, true);
|
|
} else {
|
|
callback(new Error('Not allowed by CORS'));
|
|
}
|
|
},
|
|
credentials: true, // ⚠️ Con credentials: true, esto es peligroso
|
|
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
|
|
allowedHeaders: ['Content-Type', 'Authorization']
|
|
}));
|
|
```
|
|
|
|
**Problema:**
|
|
- En desarrollo, acepta CUALQUIER origen (incluso malicioso)
|
|
- Con `credentials: true`, esto permite que cualquier sitio web pueda hacer requests autenticados
|
|
- Si se olvida cambiar `NODE_ENV` en producción, toda la API está expuesta
|
|
|
|
**Impacto:** ALTA (solo en desarrollo, pero peligroso si se olvida)
|
|
|
|
**Fix Inmediato:**
|
|
|
|
```javascript
|
|
// ✅ FIX: Limitar orígenes incluso en desarrollo
|
|
const isDevelopment = process.env.NODE_ENV === 'development';
|
|
const defaultDevOrigins = [
|
|
'http://localhost:8096',
|
|
'http://localhost:5174',
|
|
'http://localhost:5173',
|
|
'http://127.0.0.1:8096',
|
|
'http://127.0.0.1:5174',
|
|
'http://127.0.0.1:5173',
|
|
];
|
|
|
|
const allowedOrigins = process.env.CORS_ORIGINS
|
|
? process.env.CORS_ORIGINS.split(',').map(origin => origin.trim())
|
|
: (isDevelopment ? defaultDevOrigins : []); // En producción, requerir CORS_ORIGINS
|
|
|
|
app.use(cors({
|
|
origin: (origin, callback) => {
|
|
// Permitir requests sin origen SOLO para mobile apps o herramientas CLI (con verificación)
|
|
if (!origin) {
|
|
// En producción, rechazar requests sin origen (excepto health check)
|
|
if (!isDevelopment && req.path !== '/health') {
|
|
return callback(new Error('Origin required in production'));
|
|
}
|
|
// En desarrollo, permitir para testing local
|
|
return callback(null, true);
|
|
}
|
|
|
|
if (allowedOrigins.indexOf(origin) !== -1) {
|
|
callback(null, true);
|
|
} else {
|
|
console.warn(`[CORS] Origin bloqueado: ${origin}`);
|
|
callback(new Error('Not allowed by CORS'));
|
|
}
|
|
},
|
|
credentials: true,
|
|
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
|
|
allowedHeaders: ['Content-Type', 'Authorization'],
|
|
maxAge: 86400, // Cache preflight por 24 horas
|
|
}));
|
|
```
|
|
|
|
**Archivo a modificar:**
|
|
- `backend/src/index.js` (línea 37-53)
|
|
|
|
**Esfuerzo:** 30 minutos
|
|
**Prioridad:** 🔴 **6º (ALTA)**
|
|
|
|
---
|
|
|
|
#### 🔴 **CRÍTICA #7: Sin Rate Limiting**
|
|
|
|
**Ubicación:** `backend/src/index.js` (no existe)
|
|
|
|
**Problema:**
|
|
- ❌ **NO HAY RATE LIMITING**
|
|
- Cualquier atacante puede hacer requests ilimitados
|
|
- Vulnerable a:
|
|
- **Brute Force Attacks** (login)
|
|
- **DDoS Attacks**
|
|
- **API Abuse**
|
|
- **Resource Exhaustion**
|
|
|
|
**Impacto:** CRÍTICO
|
|
|
|
**Fix Inmediato:**
|
|
|
|
```javascript
|
|
// ✅ FIX: Instalar express-rate-limit
|
|
// npm install express-rate-limit
|
|
|
|
import rateLimit from 'express-rate-limit';
|
|
|
|
// Rate limiter general para todas las rutas
|
|
const generalLimiter = rateLimit({
|
|
windowMs: 15 * 60 * 1000, // 15 minutos
|
|
max: 100, // 100 requests por IP por ventana
|
|
message: 'Demasiadas requests desde esta IP, por favor intenta de nuevo más tarde',
|
|
standardHeaders: true,
|
|
legacyHeaders: false,
|
|
skip: (req) => req.path === '/health', // Skip health checks
|
|
});
|
|
|
|
// Rate limiter estricto para login
|
|
const authLimiter = rateLimit({
|
|
windowMs: 15 * 60 * 1000, // 15 minutos
|
|
max: 5, // 5 intentos de login por IP por ventana
|
|
message: 'Demasiados intentos de login, por favor intenta de nuevo en 15 minutos',
|
|
skipSuccessfulRequests: true, // No contar requests exitosos
|
|
});
|
|
|
|
// Rate limiter para creación de contenido
|
|
const contentWriteLimiter = rateLimit({
|
|
windowMs: 60 * 60 * 1000, // 1 hora
|
|
max: 50, // 50 creaciones/actualizaciones por IP por hora
|
|
message: 'Límite de operaciones de escritura alcanzado',
|
|
});
|
|
|
|
app.use(generalLimiter);
|
|
app.use('/api/auth/login', authLimiter);
|
|
app.use('/api/auth/register', authLimiter);
|
|
app.use('/api/content', contentWriteLimiter); // Solo para POST/PUT/DELETE
|
|
```
|
|
|
|
**Archivo a modificar:**
|
|
- `backend/src/index.js` (añadir antes de routes)
|
|
- `backend/package.json` (añadir dependencia)
|
|
|
|
**Esfuerzo:** 1 hora
|
|
**Prioridad:** 🔴 **7º (CRÍTICA)**
|
|
|
|
---
|
|
|
|
### 1.4 Headers de Seguridad HTTP
|
|
|
|
#### 🔴 **CRÍTICA #8: Sin Headers de Seguridad (Helmet.js)**
|
|
|
|
**Ubicación:** `backend/src/index.js` (no existe)
|
|
|
|
**Problema:**
|
|
- ❌ **NO HAY HELMET.JS**
|
|
- Sin headers de seguridad HTTP:
|
|
- Sin `Content-Security-Policy` (XSS protection)
|
|
- Sin `X-Frame-Options` (Clickjacking protection)
|
|
- Sin `X-Content-Type-Options` (MIME sniffing protection)
|
|
- Sin `Strict-Transport-Security` (HSTS)
|
|
- Sin `X-XSS-Protection`
|
|
- Sin `Referrer-Policy`
|
|
- `X-Powered-By` expuesto (información del servidor)
|
|
|
|
**Impacto:** ALTA
|
|
- Vulnerable a XSS, Clickjacking, MIME sniffing
|
|
- Información del servidor expuesta
|
|
|
|
**Fix Inmediato:**
|
|
|
|
```javascript
|
|
// ✅ FIX: Instalar y configurar Helmet.js
|
|
// npm install helmet
|
|
|
|
import helmet from 'helmet';
|
|
|
|
app.use(helmet({
|
|
contentSecurityPolicy: {
|
|
directives: {
|
|
defaultSrc: ["'self'"],
|
|
styleSrc: ["'self'", "'unsafe-inline'"], // Necesario para algunos componentes
|
|
scriptSrc: ["'self'"],
|
|
imgSrc: ["'self'", "data:", "https:"],
|
|
connectSrc: ["'self'"],
|
|
fontSrc: ["'self'"],
|
|
objectSrc: ["'none'"],
|
|
mediaSrc: ["'self'"],
|
|
frameSrc: ["'none'"],
|
|
},
|
|
},
|
|
crossOriginEmbedderPolicy: false, // Deshabilitar si causa problemas con CORS
|
|
crossOriginResourcePolicy: { policy: "cross-origin" }, // Permitir recursos cross-origin
|
|
hsts: {
|
|
maxAge: 31536000, // 1 año
|
|
includeSubDomains: true,
|
|
preload: true,
|
|
},
|
|
}));
|
|
|
|
// Remover X-Powered-By manualmente (Helmet lo hace automáticamente)
|
|
app.disable('x-powered-by');
|
|
```
|
|
|
|
**Archivo a modificar:**
|
|
- `backend/src/index.js` (añadir después de cors, antes de routes)
|
|
- `backend/package.json` (añadir dependencia)
|
|
|
|
**Esfuerzo:** 30 minutos
|
|
**Prioridad:** 🔴 **8º (ALTA)**
|
|
|
|
---
|
|
|
|
## 2. ✅ BUENAS PRÁCTICAS
|
|
|
|
### 2.1 Variables de Entorno
|
|
|
|
#### ✅ Correcto
|
|
|
|
**Ubicación:** `backend/config/database.js`, `backend/src/routes/auth.js`
|
|
|
|
```javascript
|
|
// ✅ BUENO: Usa variables de entorno
|
|
const pool = new Pool({
|
|
host: process.env.DB_HOST || 'localhost',
|
|
port: parseInt(process.env.DB_PORT || '5432', 10),
|
|
database: process.env.DB_NAME || 'emerges_tes',
|
|
user: process.env.DB_USER || 'postgres',
|
|
password: process.env.DB_PASSWORD || '',
|
|
});
|
|
```
|
|
|
|
**Estado:** ✅ Correcto (excepto JWT_SECRET que tiene fallback débil)
|
|
|
|
#### ⚠️ Mejorable
|
|
|
|
**Ubicación:** `backend/ENV_TEMPLATE.md`
|
|
|
|
**Problema:**
|
|
- Template existe, pero no está en `.env.example` (estándar de la industria)
|
|
- No hay validación de variables requeridas en startup
|
|
|
|
**Fix Sugerido:**
|
|
|
|
```javascript
|
|
// ✅ FIX: Validar variables de entorno en startup
|
|
// backend/src/utils/env-validator.js
|
|
|
|
import dotenv from 'dotenv';
|
|
dotenv.config();
|
|
|
|
const requiredEnvVars = [
|
|
'DB_HOST',
|
|
'DB_PORT',
|
|
'DB_NAME',
|
|
'DB_USER',
|
|
'DB_PASSWORD',
|
|
'JWT_SECRET',
|
|
];
|
|
|
|
const optionalEnvVars = {
|
|
'DB_PORT': '5432',
|
|
'API_PORT': '3000',
|
|
'JWT_EXPIRES_IN': '24h',
|
|
'NODE_ENV': 'development',
|
|
};
|
|
|
|
export function validateEnv() {
|
|
const missing = requiredEnvVars.filter(varName => !process.env[varName]);
|
|
|
|
if (missing.length > 0) {
|
|
console.error('❌ CRÍTICO: Variables de entorno faltantes:');
|
|
missing.forEach(varName => {
|
|
console.error(` - ${varName}`);
|
|
});
|
|
console.error('\n Configura estas variables en .env (ver backend/ENV_TEMPLATE.md)');
|
|
process.exit(1);
|
|
}
|
|
|
|
// Validar formato de JWT_SECRET
|
|
if (process.env.JWT_SECRET && process.env.JWT_SECRET.length < 32) {
|
|
console.error('❌ CRÍTICO: JWT_SECRET debe tener al menos 32 caracteres');
|
|
console.error(' Generar con: openssl rand -base64 32');
|
|
process.exit(1);
|
|
}
|
|
|
|
// Validar formato de DB_PASSWORD
|
|
if (process.env.DB_PASSWORD && process.env.DB_PASSWORD.length < 8) {
|
|
console.warn('⚠️ ADVERTENCIA: DB_PASSWORD es muy corto (recomendado: mínimo 12 caracteres)');
|
|
}
|
|
|
|
console.log('✅ Variables de entorno validadas correctamente');
|
|
}
|
|
|
|
// En backend/src/index.js
|
|
import { validateEnv } from './utils/env-validator.js';
|
|
validateEnv();
|
|
```
|
|
|
|
**Esfuerzo:** 1 hora
|
|
**Prioridad:** 🟡 MEDIA
|
|
|
|
---
|
|
|
|
### 2.2 Validación de Inputs
|
|
|
|
#### ⚠️ Estado Actual: Parcial
|
|
|
|
**Problema:**
|
|
- ❌ Sin validación con Zod o Joi
|
|
- ⚠️ Solo validaciones básicas (`if (!email || !password)`)
|
|
- ⚠️ Sin sanitización de strings
|
|
- ⚠️ Sin validación de tipos
|
|
|
|
**Fix Sugerido:**
|
|
|
|
```javascript
|
|
// ✅ FIX: Crear middleware de validación
|
|
// backend/src/middleware/validator.js
|
|
|
|
import { z } from 'zod';
|
|
|
|
export const validate = (schema) => {
|
|
return (req, res, next) => {
|
|
try {
|
|
req.body = schema.parse(req.body);
|
|
next();
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) {
|
|
return res.status(400).json({
|
|
error: 'Validation failed',
|
|
details: error.errors,
|
|
});
|
|
}
|
|
next(error);
|
|
}
|
|
};
|
|
};
|
|
|
|
// Esquemas de validación
|
|
export const loginSchema = z.object({
|
|
email: z.string().email().max(255).toLowerCase().trim(),
|
|
password: z.string().min(8).max(100),
|
|
});
|
|
|
|
export const createContentSchema = z.object({
|
|
id: z.string().regex(/^[a-z0-9-]+$/).min(3).max(100),
|
|
type: z.enum(['protocol', 'guide', 'drug', 'checklist', 'manual']),
|
|
title: z.string().min(3).max(200).trim(),
|
|
// ... más campos
|
|
});
|
|
```
|
|
|
|
**Esfuerzo:** 4 horas
|
|
**Prioridad:** 🔴 ALTA (ya incluido en CRÍTICA #3)
|
|
|
|
---
|
|
|
|
### 2.3 Manejo de Sesiones/Tokens
|
|
|
|
#### ✅ Correcto
|
|
|
|
**Ubicación:** `backend/src/routes/auth.js:59-63`
|
|
|
|
```javascript
|
|
// ✅ BUENO: JWT con expiración
|
|
const token = jwt.sign(
|
|
{ userId: user.id, email: user.email, role: user.role },
|
|
JWT_SECRET,
|
|
{ expiresIn: JWT_EXPIRES_IN }
|
|
);
|
|
```
|
|
|
|
**Estado:** ✅ Correcto
|
|
|
|
#### ⚠️ Mejorable
|
|
|
|
**Problema:**
|
|
- Sin refresh tokens
|
|
- Sin blacklist de tokens revocados
|
|
- Sin rotación de secrets
|
|
|
|
**Fix Sugerido (Opcional - Mejora futura):**
|
|
|
|
```javascript
|
|
// ✅ MEJORA FUTURA: Refresh tokens
|
|
// backend/src/routes/auth.js
|
|
|
|
router.post('/refresh', async (req, res) => {
|
|
const { refreshToken } = req.body;
|
|
// Verificar refresh token
|
|
// Generar nuevo access token
|
|
// Retornar nuevo token
|
|
});
|
|
```
|
|
|
|
**Prioridad:** 🟢 BAJA (no crítico ahora)
|
|
|
|
---
|
|
|
|
## 3. 🏗️ INFRAESTRUCTURA Y DEPLOY
|
|
|
|
### 3.1 Scripts de Deploy
|
|
|
|
#### ⚠️ **Problema 1: Deploy Script sin Validación de Entorno**
|
|
|
|
**Ubicación:** `deploy.sh:71`
|
|
|
|
```bash
|
|
# ⚠️ PROBLEMA: No verifica NODE_ENV antes de build
|
|
if npm run build; then
|
|
echo -e "${GREEN}✅ Build completado${NC}"
|
|
```
|
|
|
|
**Problema:**
|
|
- No fuerza `NODE_ENV=production` en build
|
|
- Puede construir con modo desarrollo
|
|
|
|
**Fix Sugerido:**
|
|
|
|
```bash
|
|
# ✅ FIX: Forzar producción
|
|
echo -e "${YELLOW}🔨 [4/5] Construyendo aplicación (producción)...${NC}"
|
|
if NODE_ENV=production npm run build; then
|
|
echo -e "${GREEN}✅ Build completado${NC}"
|
|
```
|
|
|
|
**Esfuerzo:** 5 minutos
|
|
**Prioridad:** 🟡 MEDIA
|
|
|
|
---
|
|
|
|
#### ⚠️ **Problema 2: Webhook Script sin Verificación HMAC**
|
|
|
|
**Ubicación:** `webhook-deploy.sh:29-33`
|
|
|
|
```bash
|
|
# ❌ PROBLEMA: No verifica HMAC de GitHub
|
|
if [ -n "$SECRET" ] && [ "$SECRET" != "TU_SECRET_AQUI" ]; then
|
|
SIGNATURE=$(echo "$PAYLOAD" | jq -r '.signature // empty')
|
|
# Aquí deberías verificar el HMAC, pero para simplicidad lo omitimos
|
|
# En producción, implementar verificación HMAC
|
|
fi
|
|
```
|
|
|
|
**Problema:**
|
|
- Comentario indica que falta verificación HMAC
|
|
- Cualquier request puede disparar deploy
|
|
|
|
**Fix:** Ya incluido en CRÍTICA #2
|
|
|
|
---
|
|
|
|
### 3.2 Configuración de Servidores
|
|
|
|
#### ✅ Dockerfile: Bueno
|
|
|
|
**Ubicación:** `Dockerfile`
|
|
|
|
**Estado:** ✅ Correcto
|
|
- Multi-stage build ✅
|
|
- Health check ✅
|
|
- Usa usuario no-root (implicito con Alpine) ✅
|
|
- Variables de entorno ✅
|
|
|
|
#### ⚠️ Docker Compose: Falta Seguridad
|
|
|
|
**Ubicación:** `docker-compose.yml`, `docker-compose.prod.yml`
|
|
|
|
**Problemas:**
|
|
- No hay límites de recursos explícitos (solo en prod)
|
|
- No hay usuario no-root especificado
|
|
- No hay read-only filesystem
|
|
- Sin secrets management (passwords en environment)
|
|
|
|
**Fix Sugerido:**
|
|
|
|
```yaml
|
|
# ✅ FIX: docker-compose.prod.yml
|
|
services:
|
|
emerges-tes:
|
|
# ... existing config ...
|
|
user: "1000:1000" # Ejecutar como usuario no-root
|
|
read_only: true # Filesystem read-only
|
|
tmpfs:
|
|
- /tmp
|
|
- /var/cache
|
|
security_opt:
|
|
- no-new-privileges:true
|
|
cap_drop:
|
|
- ALL
|
|
cap_add:
|
|
- NET_BIND_SERVICE # Solo necesario para binding a puertos <1024
|
|
secrets:
|
|
- db_password
|
|
- jwt_secret
|
|
|
|
secrets:
|
|
db_password:
|
|
external: true
|
|
jwt_secret:
|
|
external: true
|
|
```
|
|
|
|
**Esfuerzo:** 1 hora
|
|
**Prioridad:** 🟡 MEDIA
|
|
|
|
---
|
|
|
|
### 3.3 Health Checks, Logging, Monitoring
|
|
|
|
#### ✅ Health Check Implementado
|
|
|
|
**Ubicación:** `backend/src/index.js:59-66`
|
|
|
|
```javascript
|
|
// ✅ BUENO: Health check existe
|
|
app.get('/health', async (req, res) => {
|
|
const dbConnected = await testConnection();
|
|
res.json({
|
|
status: 'ok',
|
|
timestamp: new Date().toISOString(),
|
|
database: dbConnected ? 'connected' : 'disconnected'
|
|
});
|
|
});
|
|
```
|
|
|
|
**Estado:** ✅ Correcto
|
|
|
|
#### ⚠️ Logging Inadecuado
|
|
|
|
**Problema:**
|
|
- Solo `console.log` y `console.error`
|
|
- Sin logging estructurado
|
|
- Sin niveles de log (INFO, WARN, ERROR)
|
|
- Sin rotación de logs
|
|
- Sin sanitización de datos sensibles en logs
|
|
|
|
**Fix Sugerido:**
|
|
|
|
```javascript
|
|
// ✅ FIX: Implementar logging estructurado con Winston
|
|
// npm install winston
|
|
|
|
import winston from 'winston';
|
|
|
|
const logger = winston.createLogger({
|
|
level: process.env.LOG_LEVEL || 'info',
|
|
format: winston.format.combine(
|
|
winston.format.timestamp(),
|
|
winston.format.errors({ stack: true }),
|
|
winston.format.json()
|
|
),
|
|
defaultMeta: { service: 'emerges-tes-backend' },
|
|
transports: [
|
|
new winston.transports.File({ filename: 'logs/error.log', level: 'error' }),
|
|
new winston.transports.File({ filename: 'logs/combined.log' }),
|
|
],
|
|
});
|
|
|
|
if (process.env.NODE_ENV !== 'production') {
|
|
logger.add(new winston.transports.Console({
|
|
format: winston.format.simple()
|
|
}));
|
|
}
|
|
|
|
// Sanitizar datos sensibles
|
|
logger.info('User login', {
|
|
userId: user.id,
|
|
email: user.email.replace(/(.{2})(.*)(@.*)/, '$1***$3'), // Ocultar email parcialmente
|
|
// NO loggear password_hash
|
|
});
|
|
```
|
|
|
|
**Esfuerzo:** 2 horas
|
|
**Prioridad:** 🟡 MEDIA
|
|
|
|
---
|
|
|
|
#### ❌ Monitoring No Implementado
|
|
|
|
**Problema:**
|
|
- Sin métricas de rendimiento
|
|
- Sin alertas
|
|
- Sin APM (Application Performance Monitoring)
|
|
|
|
**Fix Sugerido (Mejora Futura):**
|
|
|
|
```javascript
|
|
// ✅ MEJORA FUTURA: Añadir métricas con Prometheus
|
|
// npm install prom-client express-prometheus-middleware
|
|
|
|
import promClient from 'prom-client';
|
|
import promMiddleware from 'express-prometheus-middleware';
|
|
|
|
app.use(promMiddleware({
|
|
metricsPath: '/metrics',
|
|
collectDefaultMetrics: true,
|
|
}));
|
|
```
|
|
|
|
**Prioridad:** 🟢 BAJA (mejora futura)
|
|
|
|
---
|
|
|
|
## 4. 📦 DEPENDENCIAS
|
|
|
|
### 4.1 Análisis de Vulnerabilidades
|
|
|
|
#### ⚠️ No Verificado
|
|
|
|
**Problema:**
|
|
- No hay `npm audit` en CI/CD
|
|
- No hay verificación automática de vulnerabilidades
|
|
- Dependencias no actualizadas regularmente
|
|
|
|
**Fix Inmediato:**
|
|
|
|
```bash
|
|
# ✅ FIX: Ejecutar npm audit
|
|
cd backend && npm audit
|
|
cd ../frontend && npm audit
|
|
cd ../admin-panel && npm audit
|
|
```
|
|
|
|
**Comandos Recomendados:**
|
|
|
|
```bash
|
|
# Verificar vulnerabilidades
|
|
npm audit
|
|
|
|
# Verificar vulnerabilidades con detalle
|
|
npm audit --audit-level=moderate
|
|
|
|
# Corregir vulnerabilidades automáticamente (si es posible)
|
|
npm audit fix
|
|
|
|
# Actualizar dependencias
|
|
npm update
|
|
|
|
# Verificar versiones desactualizadas
|
|
npm outdated
|
|
```
|
|
|
|
**Esfuerzo:** 30 minutos (verificación inicial)
|
|
**Prioridad:** 🔴 ALTA
|
|
|
|
---
|
|
|
|
### 4.2 Dependencias Vulnerables Conocidas
|
|
|
|
#### ⚠️ Express 4.18.2
|
|
|
|
**Problema:**
|
|
- Express 4.18.2 tiene vulnerabilidades conocidas (verificar con `npm audit`)
|
|
- Debe actualizarse a última versión 4.x o considerar Express 5
|
|
|
|
**Fix Sugerido:**
|
|
|
|
```bash
|
|
# Verificar vulnerabilidades específicas
|
|
npm audit express
|
|
|
|
# Actualizar si hay patches
|
|
npm update express
|
|
```
|
|
|
|
---
|
|
|
|
### 4.3 Lock Files
|
|
|
|
#### ✅ Presente
|
|
|
|
**Estado:** ✅ Correcto
|
|
- `package-lock.json` presente en backend ✅
|
|
- `package-lock.json` presente en frontend ✅
|
|
- `package-lock.json` presente en admin-panel ✅
|
|
|
|
**Recomendación:**
|
|
- Asegurar que lock files están en `.gitignore` para `node_modules/` pero NO para `package-lock.json`
|
|
- Committear `package-lock.json` al repositorio ✅
|
|
|
|
---
|
|
|
|
## 5. 📋 RESUMEN DE VULNERABILIDADES
|
|
|
|
### 🔴 CRÍTICAS (8)
|
|
|
|
| ID | Vulnerabilidad | Archivo | Línea | Esfuerzo Fix | Prioridad |
|
|
|----|----------------|---------|-------|--------------|-----------|
|
|
| **S1** | JWT Secret con fallback débil | `backend/src/routes/auth.js` | 11 | 30 min | **1º** |
|
|
| **S2** | Webhook Secret hardcoded | `webhook-deploy.sh` | 10 | 1 hora | **2º** |
|
|
| **S3** | Sin validación de inputs | `backend/src/routes/*.js` | Múltiples | 8 horas | **3º** |
|
|
| **S4** | XSS en innerHTML | `src/main.tsx` | 137 | 1 hora | **4º** |
|
|
| **S5** | dangerouslySetInnerHTML sin sanitización | `src/components/ui/chart.tsx` | 70 | 30 min | **5º** |
|
|
| **S6** | CORS permisivo en dev | `backend/src/index.js` | 37-53 | 30 min | **6º** |
|
|
| **S7** | Sin rate limiting | `backend/src/index.js` | - | 1 hora | **7º** |
|
|
| **S8** | Sin headers de seguridad (Helmet) | `backend/src/index.js` | - | 30 min | **8º** |
|
|
|
|
**Total Críticas:** 8
|
|
**Esfuerzo Total:** ~13 horas (1.5 días)
|
|
|
|
---
|
|
|
|
### 🟡 IMPORTANTES (6)
|
|
|
|
| ID | Vulnerabilidad | Archivo | Esfuerzo Fix | Prioridad |
|
|
|----|----------------|---------|--------------|-----------|
|
|
| **S9** | Validación de variables de entorno | `backend/src/index.js` | 1 hora | 9º |
|
|
| **S10** | Logging inadecuado | `backend/src/**/*.js` | 2 horas | 10º |
|
|
| **S11** | Docker Compose sin seguridad | `docker-compose.prod.yml` | 1 hora | 11º |
|
|
| **S12** | Deploy script sin validación | `deploy.sh` | 5 min | 12º |
|
|
| **S13** | Dependencias no auditadas | `package.json` | 30 min | 13º |
|
|
| **S14** | Sin refresh tokens | `backend/src/routes/auth.js` | 4 horas | 14º (futuro) |
|
|
|
|
**Total Importantes:** 6
|
|
**Esfuerzo Total:** ~8.5 horas
|
|
|
|
---
|
|
|
|
### 🟢 MEJORAS (3)
|
|
|
|
| ID | Mejora | Esfuerzo | Prioridad |
|
|
|----|--------|----------|-----------|
|
|
| **S15** | Monitoring con Prometheus | 1 día | Futuro |
|
|
| **S16** | Rotación de secrets | 4 horas | Futuro |
|
|
| **S17** | Blacklist de tokens | 2 horas | Futuro |
|
|
|
|
---
|
|
|
|
## 6. ✅ CHECKLIST DE IMPLEMENTACIÓN
|
|
|
|
### Quick Wins (Día 1 - 4 horas)
|
|
|
|
- [ ] **S1:** Validar JWT_SECRET en startup (30 min)
|
|
- [ ] **S7:** Instalar y configurar express-rate-limit (1 hora)
|
|
- [ ] **S8:** Instalar y configurar Helmet.js (30 min)
|
|
- [ ] **S6:** Fix CORS permisivo (30 min)
|
|
- [ ] **S5:** Sanitizar id en chart.tsx (30 min)
|
|
- [ ] **S4:** Fix XSS en innerHTML (1 hora)
|
|
|
|
**Total:** ~4 horas
|
|
|
|
---
|
|
|
|
### Refactors Importantes (Día 2-3 - 8 horas)
|
|
|
|
- [ ] **S2:** Fix webhook secret con HMAC (1 hora)
|
|
- [ ] **S3:** Implementar validación Zod en todas las rutas (8 horas) ⚠️ **GRANDE**
|
|
|
|
**Total:** ~9 horas
|
|
|
|
---
|
|
|
|
### Mejoras de Infraestructura (Día 4 - 2 horas)
|
|
|
|
- [ ] **S9:** Validar variables de entorno (1 hora)
|
|
- [ ] **S12:** Fix deploy script (5 min)
|
|
- [ ] **S13:** Ejecutar npm audit y corregir (30 min)
|
|
|
|
**Total:** ~2 horas
|
|
|
|
---
|
|
|
|
## 7. 📊 TABLA COMPARATIVA PRIORIZADA
|
|
|
|
| Prioridad | ID | Vulnerabilidad | Severidad | Esfuerzo | Impacto | ROI |
|
|
|-----------|----|----------------|-----------|----------|---------|-----|
|
|
| **1** | S1 | JWT Secret fallback | 🔴 CRÍTICA | 30 min | Alto | ⭐⭐⭐⭐⭐ |
|
|
| **2** | S7 | Sin rate limiting | 🔴 CRÍTICA | 1 hora | Alto | ⭐⭐⭐⭐⭐ |
|
|
| **3** | S8 | Sin Helmet.js | 🔴 CRÍTICA | 30 min | Alto | ⭐⭐⭐⭐⭐ |
|
|
| **4** | S6 | CORS permisivo | 🔴 CRÍTICA | 30 min | Medio | ⭐⭐⭐⭐ |
|
|
| **5** | S2 | Webhook secret | 🔴 CRÍTICA | 1 hora | Medio | ⭐⭐⭐⭐ |
|
|
| **6** | S3 | Sin validación inputs | 🔴 CRÍTICA | 8 horas | Alto | ⭐⭐⭐⭐ |
|
|
| **7** | S4 | XSS innerHTML | 🟡 ALTA | 1 hora | Medio | ⭐⭐⭐ |
|
|
| **8** | S5 | dangerouslySetInnerHTML | 🟡 ALTA | 30 min | Bajo | ⭐⭐⭐ |
|
|
| **9** | S9 | Validar env vars | 🟡 MEDIA | 1 hora | Medio | ⭐⭐⭐ |
|
|
| **10** | S10 | Logging estructurado | 🟡 MEDIA | 2 horas | Medio | ⭐⭐⭐ |
|
|
|
|
---
|
|
|
|
## 8. 🚨 PLAN DE ACCIÓN INMEDIATO
|
|
|
|
### Día 1: Seguridad Crítica (4 horas)
|
|
|
|
1. ✅ **S1:** Validar JWT_SECRET (30 min)
|
|
2. ✅ **S7:** Rate Limiting (1 hora)
|
|
3. ✅ **S8:** Helmet.js (30 min)
|
|
4. ✅ **S6:** Fix CORS (30 min)
|
|
5. ✅ **S5:** Sanitizar chart.tsx (30 min)
|
|
6. ✅ **S4:** Fix XSS innerHTML (1 hora)
|
|
|
|
### Día 2-3: Validación y Webhook (9 horas)
|
|
|
|
7. ✅ **S3:** Validación Zod (8 horas) ⚠️ **GRANDE**
|
|
8. ✅ **S2:** Webhook HMAC (1 hora)
|
|
|
|
### Día 4: Infraestructura (2 horas)
|
|
|
|
9. ✅ **S9:** Validar env vars (1 hora)
|
|
10. ✅ **S12:** Fix deploy script (5 min)
|
|
11. ✅ **S13:** npm audit (30 min)
|
|
|
|
**Total:** ~15 horas (2 días de trabajo)
|
|
|
|
---
|
|
|
|
**Última actualización:** 2025-01-07
|
|
**Próxima revisión recomendada:** Después de implementar fixes críticos (1 semana)
|
|
|