🔐

Hardening de Seguridad

👨‍🍳 Chef⏱️ 45 minutos

📋 Prerequisitos sugeridos

  • Node.js instalado
  • Conocimientos basicos de Express.js
  • npm o pnpm

Lo que vas a construir

Una API REST segura con Express.js que implementa las mejores practicas de seguridad para aplicaciones web.

Empezaras con una API vulnerable y paso a paso la convertiras en una API hardened con:

  • Headers de seguridad con Helmet.js
  • Rate limiting para prevenir ataques de fuerza bruta
  • Validacion de entrada con Zod
  • Prevencion de SQL Injection con queries parametrizadas
  • Proteccion XSS
  • Configuracion segura de CORS
  • Auditoria de dependencias en CI/CD

Al terminar tendras una API lista para produccion con un checklist de seguridad verificable.


Paso 1: Crea el proyecto base

mkdir secure-api && cd secure-api
npm init -y
npm install express

Crea server.js con una API basica (vulnerable):

// server.js - VERSION VULNERABLE (NO USAR EN PRODUCCION)
const express = require('express');
const app = express();

app.use(express.json());

// Base de datos simulada
const users = [
  { id: 1, name: 'Admin', email: 'admin@example.com', password: 'admin123' }
];

// Endpoint vulnerable a varios ataques
app.get('/api/users', (req, res) => {
  res.json(users); // Expone passwords!
});

app.post('/api/login', (req, res) => {
  const { email, password } = req.body;
  // Sin validacion de entrada
  // Vulnerable a inyeccion
  const user = users.find(u => u.email === email && u.password === password);
  if (user) {
    res.json({ message: 'Login exitoso', user }); // Expone todo el objeto
  } else {
    res.status(401).json({ message: 'Credenciales invalidas' });
  }
});

app.listen(3000, () => console.log('Server en http://localhost:3000'));

Paso 2: Agrega Helmet.js para headers de seguridad

Helmet configura automaticamente headers HTTP que protegen contra vulnerabilidades comunes.

npm install helmet

Antes (sin headers de seguridad):

const app = express();
// Sin proteccion - headers por defecto de Express

Despues (con Helmet):

const express = require('express');
const helmet = require('helmet');

const app = express();

// Helmet agrega 11+ headers de seguridad automaticamente
app.use(helmet());

// Headers que Helmet configura:
// - Content-Security-Policy: previene XSS
// - X-Content-Type-Options: nosniff
// - X-Frame-Options: DENY (previene clickjacking)
// - Strict-Transport-Security: fuerza HTTPS
// - X-XSS-Protection: filtro XSS del navegador

Verifica los headers:

curl -I http://localhost:3000/api/users

Paso 3: Implementa Rate Limiting

Previene ataques de fuerza bruta y DDoS limitando las peticiones por IP.

npm install express-rate-limit

Antes (sin limites):

// Un atacante puede hacer millones de peticiones
app.post('/api/login', (req, res) => { ... });

Despues (con rate limiting):

const rateLimit = require('express-rate-limit');

// Limite global: 100 peticiones por 15 minutos
const globalLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutos
  max: 100,
  message: { error: 'Demasiadas peticiones. Intenta en 15 minutos.' },
  standardHeaders: true,
  legacyHeaders: false,
});

// Limite estricto para login: 5 intentos por 15 minutos
const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5,
  message: { error: 'Demasiados intentos de login. Cuenta bloqueada temporalmente.' },
  skipSuccessfulRequests: true, // No cuenta logins exitosos
});

app.use(globalLimiter);
app.post('/api/login', loginLimiter, (req, res) => { ... });

Paso 4: Validacion de entrada con Zod

Nunca confies en datos del usuario. Valida TODO.

npm install zod

Antes (sin validacion):

app.post('/api/login', (req, res) => {
  const { email, password } = req.body;
  // Si email es un objeto o array, puede causar errores
  // Si password es muy largo, puede causar DoS
});

Despues (con Zod):

const { z } = require('zod');

// Definir esquema de validacion
const loginSchema = z.object({
  email: z.string()
    .email('Email invalido')
    .max(255, 'Email muy largo'),
  password: z.string()
    .min(8, 'Password debe tener al menos 8 caracteres')
    .max(128, 'Password muy largo'),
});

// Middleware de validacion reutilizable
const validate = (schema) => (req, res, next) => {
  try {
    schema.parse(req.body);
    next();
  } catch (error) {
    res.status(400).json({
      error: 'Datos invalidos',
      details: error.errors.map(e => ({
        field: e.path.join('.'),
        message: e.message
      }))
    });
  }
};

app.post('/api/login', validate(loginSchema), (req, res) => {
  const { email, password } = req.body;
  // Ahora sabemos que email y password son strings validos
});

// Esquema para crear usuario
const userSchema = z.object({
  name: z.string().min(2).max(100).regex(/^[a-zA-Z\s]+$/, 'Solo letras'),
  email: z.string().email().max(255),
  password: z.string()
    .min(8)
    .regex(/[A-Z]/, 'Debe tener mayuscula')
    .regex(/[0-9]/, 'Debe tener numero')
    .regex(/[^A-Za-z0-9]/, 'Debe tener caracter especial'),
});

Paso 5: Prevencion de SQL Injection

Si usas base de datos SQL, SIEMPRE usa queries parametrizadas.

Antes (VULNERABLE):

// NUNCA hagas esto - vulnerable a SQL injection
app.get('/api/users/:id', async (req, res) => {
  const query = \`SELECT * FROM users WHERE id = \${req.params.id}\`;
  // Atacante puede enviar: 1; DROP TABLE users; --
  const result = await db.query(query);
  res.json(result);
});

Despues (SEGURO) - con pg (PostgreSQL):

const { Pool } = require('pg');
const pool = new Pool();

app.get('/api/users/:id', async (req, res) => {
  try {
    // Query parametrizada - el driver escapa automaticamente
    const result = await pool.query(
      'SELECT id, name, email FROM users WHERE id = $1',
      [req.params.id]  // Parametro separado
    );

    if (result.rows.length === 0) {
      return res.status(404).json({ error: 'Usuario no encontrado' });
    }

    res.json(result.rows[0]);
  } catch (error) {
    console.error('DB Error:', error);
    res.status(500).json({ error: 'Error interno' });
  }
});

// Para INSERT tambien usa parametros
app.post('/api/users', validate(userSchema), async (req, res) => {
  const { name, email, password } = req.body;
  const hashedPassword = await bcrypt.hash(password, 12);

  const result = await pool.query(
    'INSERT INTO users (name, email, password) VALUES ($1, $2, $3) RETURNING id, name, email',
    [name, email, hashedPassword]
  );

  res.status(201).json(result.rows[0]);
});

Con un ORM (Prisma) - tambien seguro:

// Prisma usa queries parametrizadas internamente
const user = await prisma.user.findUnique({
  where: { id: parseInt(req.params.id) },
  select: { id: true, name: true, email: true } // No seleccionar password
});

Paso 6: Proteccion XSS

Cross-Site Scripting ocurre cuando un atacante inyecta scripts maliciosos.

Antes (VULNERABLE):

app.get('/api/comments', async (req, res) => {
  const comments = await db.getComments();
  res.json(comments);
  // Si un comentario contiene <script>alert('hacked')</script>
  // y el frontend lo renderiza con innerHTML, se ejecuta
});

Despues (SEGURO):

const createDOMPurify = require('dompurify');
const { JSDOM } = require('jsdom');
const window = new JSDOM('').window;
const DOMPurify = createDOMPurify(window);

// Sanitizar al guardar
app.post('/api/comments', validate(commentSchema), async (req, res) => {
  const sanitizedContent = DOMPurify.sanitize(req.body.content, {
    ALLOWED_TAGS: ['b', 'i', 'em', 'strong'], // Solo tags seguros
    ALLOWED_ATTR: [] // Sin atributos
  });

  await db.createComment({
    content: sanitizedContent,
    userId: req.user.id
  });

  res.status(201).json({ message: 'Comentario creado' });
});

// Alternativa: escapar HTML en el frontend
// React lo hace automaticamente con JSX
// Vue lo hace con {{ }} (doble llave)

Headers adicionales (ya incluidos en Helmet):

// Content-Security-Policy estricto
app.use(helmet.contentSecurityPolicy({
  directives: {
    defaultSrc: ["'self'"],
    scriptSrc: ["'self'"], // Solo scripts del mismo origen
    styleSrc: ["'self'", "'unsafe-inline'"],
    imgSrc: ["'self'", "data:", "https:"],
    connectSrc: ["'self'"],
    fontSrc: ["'self'"],
    objectSrc: ["'none'"],
    upgradeInsecureRequests: [],
  },
}));

Paso 7: Configuracion segura de CORS

CORS controla que dominios pueden acceder a tu API.

npm install cors

Antes (INSEGURO):

const cors = require('cors');
app.use(cors()); // Permite TODOS los origenes - peligroso!

Despues (SEGURO):

const cors = require('cors');

const allowedOrigins = [
  'https://miapp.com',
  'https://www.miapp.com',
  process.env.NODE_ENV === 'development' && 'http://localhost:3000'
].filter(Boolean);

app.use(cors({
  origin: (origin, callback) => {
    // Permitir requests sin origin (mobile apps, Postman)
    if (!origin) return callback(null, true);

    if (allowedOrigins.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error('CORS no permitido'));
    }
  },
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  credentials: true, // Permite cookies
  maxAge: 86400 // Cache preflight por 24 horas
}));

Paso 8: npm audit en CI/CD

Detecta vulnerabilidades en dependencias automaticamente.

Crea .github/workflows/security.yml:

name: Security Audit

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  schedule:
    - cron: '0 0 * * 1' # Cada lunes a medianoche

jobs:
  audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Install dependencies
        run: npm ci

      - name: Run security audit
        run: npm audit --audit-level=high
        # Falla si hay vulnerabilidades high o critical

      - name: Run Snyk security scan
        uses: snyk/actions/node@master
        env:
          SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
        with:
          args: --severity-threshold=high
        continue-on-error: true # No bloquear, solo alertar

  dependency-review:
    runs-on: ubuntu-latest
    if: github.event_name == 'pull_request'
    steps:
      - uses: actions/checkout@v4
      - name: Dependency Review
        uses: actions/dependency-review-action@v3
        with:
          fail-on-severity: high

Comandos locales utiles:

# Ver vulnerabilidades
npm audit

# Arreglar automaticamente
npm audit fix

# Arreglar incluyendo breaking changes
npm audit fix --force

# Ver solo vulnerabilidades criticas
npm audit --audit-level=critical

Paso 9: Codigo completo seguro

// server.js - VERSION SEGURA
require('dotenv').config();
const express = require('express');
const helmet = require('helmet');
const cors = require('cors');
const rateLimit = require('express-rate-limit');
const { z } = require('zod');
const bcrypt = require('bcrypt');

const app = express();

// 1. Security headers
app.use(helmet());

// 2. CORS configurado
const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(',') || [];
app.use(cors({
  origin: allowedOrigins,
  credentials: true
}));

// 3. Rate limiting
app.use(rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 100
}));

app.use(express.json({ limit: '10kb' })); // Limitar tamano del body

// 4. Esquemas de validacion
const loginSchema = z.object({
  email: z.string().email().max(255),
  password: z.string().min(8).max(128)
});

const validate = (schema) => (req, res, next) => {
  const result = schema.safeParse(req.body);
  if (!result.success) {
    return res.status(400).json({ error: 'Datos invalidos' });
  }
  next();
};

// 5. Login seguro
const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5,
  skipSuccessfulRequests: true
});

app.post('/api/login', loginLimiter, validate(loginSchema), async (req, res) => {
  try {
    const { email, password } = req.body;

    // Buscar usuario (query parametrizada)
    const user = await db.query(
      'SELECT id, email, password_hash FROM users WHERE email = $1',
      [email]
    );

    if (!user.rows[0]) {
      // Tiempo constante para prevenir timing attacks
      await bcrypt.compare(password, '$2b$12$invalidhashtopreventtiming');
      return res.status(401).json({ error: 'Credenciales invalidas' });
    }

    const valid = await bcrypt.compare(password, user.rows[0].password_hash);
    if (!valid) {
      return res.status(401).json({ error: 'Credenciales invalidas' });
    }

    // Respuesta sin datos sensibles
    res.json({
      user: { id: user.rows[0].id, email: user.rows[0].email }
    });
  } catch (error) {
    console.error('Login error:', error);
    res.status(500).json({ error: 'Error interno' });
  }
});

// 6. Manejo de errores global
app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(500).json({ error: 'Algo salio mal' });
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(\`Servidor seguro en puerto \${PORT}\`);
});

Paso 10: Checklist de verificacion

Antes de ir a produccion, verifica cada punto:

CategoriaItemComando/Verificacion
HeadersHelmet activocurl -I localhost:3000
HeadersCSP configuradoVerificar Content-Security-Policy
Rate LimitGlobal activoHacer 101+ requests
Rate LimitLogin estrictoHacer 6+ logins fallidos
ValidacionZod en endpointsEnviar datos invalidos
SQLQueries parametrizadasRevisar codigo
XSSSanitizacion activaEnviar <script>alert(1)</script>
CORSSolo origenes permitidosRequest desde otro dominio
DepsSin vulnerabilidadesnpm audit
CI/CDAudit automaticoRevisar GitHub Actions
PasswordsHasheados con bcryptRevisar DB
ErroresSin stack tracesForzar error en prod
HTTPSForzado en prodVerificar redirect

Troubleshooting

ProblemaCausaSolucion
CORS bloqueadoOrigen no en listaAgregar a allowedOrigins
Rate limit muy estrictoPocos requests permitidosAjustar max y windowMs
Zod rechaza todoEsquema muy estrictoRevisar validaciones
npm audit fallaDependencia vulnerablenpm audit fix o actualizar

Proximo paso

-> RAG con Documentos PDF - Nivel Master