Saltar al contenido

Defensa en profundidad para APIs: límites, validación y cabeceras

Capas de seguridad más allá del login: rate limiting, validación, codificación de salida, cabeceras y auditoría, con middleware en TypeScript.

5 min de lectura
Diagrama de arquitectura de seguridad por capas que muestra las peticiones pasando por las capas de limitación de tasa, autenticación, validación, autorización y registro de auditoría

La autenticación verifica la identidad. Responde a la pregunta «¿quién eres?». Pero un usuario correctamente autenticado aún puede abusar de tu API: enviando datos malformados, machacando endpoints, explotando la lógica de negocio o extrayendo datos mediante consultas sin límites. Las capas que hay más allá de la autenticación determinan si tu API sobrevive al contacto con el tráfico del mundo real.

La defensa en profundidad significa que cada capa asume que la anterior puede fallar. La limitación de tasa no asume que el cortafuegos bloqueó el ataque. La validación de entradas no asume que el cliente envió datos bien formados. La codificación de salida no asume que la base de datos contenía valores limpios.

Limitación de tasa: proteger la capacidad

La limitación de tasa evita que un solo cliente consuma recursos de forma desproporcionada. Sin ella, un único cliente agresivo —malicioso o con errores— puede denegar el servicio a todos los demás.

tstypescript
// ❌ No rate limiting — any client can overwhelm the API
app.post("/api/search", async (req, res) => {
  const results = await db.fullTextSearch(req.body.query);
  res.json(results);
  // An attacker sends 10,000 requests/second
  // Database overwhelmed, other users get timeouts
});
tstypescript
// ✅ Layered rate limiting middleware
import { Redis } from "ioredis";
 
const redis = new Redis(process.env.REDIS_URL);
 
interface RateLimitConfig {
  windowMs: number;
  maxRequests: number;
  keyPrefix: string;
}
 
async function checkRateLimit(
  key: string,
  config: RateLimitConfig
): Promise<{
  allowed: boolean;
  remaining: number;
  resetAt: number;
}> {
  const windowKey =
    `${config.keyPrefix}:${key}:` +
    `${Math.floor(Date.now() / config.windowMs)}`;
 
  const count = await redis.incr(windowKey);
 
  if (count === 1) {
    await redis.pexpire(windowKey, config.windowMs);
  }
 
  const remaining = Math.max(
    0,
    config.maxRequests - count
  );
  const resetAt =
    Math.ceil(Date.now() / config.windowMs) *
    config.windowMs;
 
  return {
    allowed: count <= config.maxRequests,
    remaining,
    resetAt,
  };
}
 
function rateLimiter(config: RateLimitConfig) {
  return async (
    req: Request,
    res: Response,
    next: NextFunction
  ) => {
    // Use authenticated user ID, fall back to IP
    const key =
      req.user?.id ?? req.ip ?? "unknown";
 
    const result = await checkRateLimit(key, config);
 
    // Always set rate limit headers
    res.set({
      "X-RateLimit-Limit": String(config.maxRequests),
      "X-RateLimit-Remaining": String(result.remaining),
      "X-RateLimit-Reset": String(result.resetAt),
    });
 
    if (!result.allowed) {
      res.status(429).json({
        error: "Too many requests",
        retryAfter: Math.ceil(
          (result.resetAt - Date.now()) / 1000
        ),
      });
      return;
    }
 
    next();
  };
}
 
// Different limits for different endpoints
app.use(
  "/api/search",
  rateLimiter({
    windowMs: 60_000,
    maxRequests: 30,
    keyPrefix: "rl:search",
  })
);
 
app.use(
  "/api/auth/login",
  rateLimiter({
    windowMs: 900_000, // 15 minutes
    maxRequests: 5,     // Strict for auth
    keyPrefix: "rl:login",
  })
);

Validación de entradas: rechazar datos incorrectos cuanto antes

Cada parámetro de una petición es una entrada no confiable. Valida la forma, el tipo y las restricciones antes de procesarla.

tstypescript
import { z } from "zod";
 
// ❌ Trusting client input
app.post("/api/users", async (req, res) => {
  // req.body could be anything — no validation
  await db.query(
    "INSERT INTO users (name, email, role) VALUES ($1, $2, $3)",
    [req.body.name, req.body.email, req.body.role]
    // Attacker sets role: "admin" — privilege escalation
  );
});
tstypescript
// ✅ Strict schema validation with Zod
const createUserSchema = z.object({
  name: z
    .string()
    .min(1)
    .max(100)
    .regex(
      /^[\p{L}\p{N}\s\-'.]+$/u,
      "Invalid characters in name"
    ),
  email: z.string().email().max(254),
  // Role is NOT user-settable — determined by backend
});
 
const searchSchema = z.object({
  query: z
    .string()
    .min(1)
    .max(200)
    .transform((q) => q.trim()),
  page: z.coerce
    .number()
    .int()
    .min(1)
    .max(100)
    .default(1),
  limit: z.coerce
    .number()
    .int()
    .min(1)
    .max(50)
    .default(20),
  // Prevent unbounded queries
});
 
function validate<T>(schema: z.ZodSchema<T>) {
  return (
    req: Request,
    res: Response,
    next: NextFunction
  ) => {
    const result = schema.safeParse(req.body);
 
    if (!result.success) {
      res.status(400).json({
        error: "Validation failed",
        details: result.error.issues.map((issue) => ({
          field: issue.path.join("."),
          message: issue.message,
        })),
      });
      return;
    }
 
    req.body = result.data;
    next();
  };
}
 
app.post(
  "/api/users",
  validate(createUserSchema),
  async (req, res) => {
    // req.body is now typed and validated
    const { name, email } = req.body;
    // Role assigned by backend logic, never from input
    const role = "user";
    await db.createUser({ name, email, role });
    res.status(201).json({ name, email, role });
  }
);

Cabeceras de seguridad: blindar las respuestas

Las cabeceras de seguridad indican a los navegadores cómo manejar tus respuestas. La ausencia de cabeceras deja a los clientes vulnerables a clickjacking, XSS y ataques de degradación de protocolo.

tstypescript
function securityHeaders(
  req: Request,
  res: Response,
  next: NextFunction
) {
  // Prevent clickjacking
  res.set("X-Frame-Options", "DENY");
 
  // Block MIME-type sniffing
  res.set("X-Content-Type-Options", "nosniff");
 
  // Enable strict transport security
  res.set(
    "Strict-Transport-Security",
    "max-age=31536000; includeSubDomains; preload"
  );
 
  // Content Security Policy for APIs
  res.set(
    "Content-Security-Policy",
    "default-src 'none'; frame-ancestors 'none'"
  );
 
  // Control referrer information
  res.set("Referrer-Policy", "strict-origin");
 
  // Permissions policy
  res.set(
    "Permissions-Policy",
    "camera=(), microphone=(), geolocation=()"
  );
 
  next();
}
 
app.use(securityHeaders);

Codificación de salida: sanitizar las respuestas

Los datos almacenados en tu base de datos pueden contener contenido malicioso. Codifica la salida antes de enviarla a los clientes.

tstypescript
// ❌ Raw database values in response
app.get("/api/comments/:postId", async (req, res) => {
  const comments = await db.getComments(req.params.postId);
  res.json(comments);
  // If a comment contains <script>alert('xss')</script>
  // and the client renders it as HTML — XSS
});
tstypescript
// ✅ Sanitize output
import DOMPurify from "isomorphic-dompurify";
 
function sanitizeOutput<T extends Record<string, unknown>>(
  obj: T,
  htmlFields: string[] = []
): T {
  const sanitized = { ...obj };
 
  for (const [key, value] of Object.entries(sanitized)) {
    if (typeof value === "string") {
      if (htmlFields.includes(key)) {
        // Allow safe HTML in designated fields
        (sanitized as Record<string, unknown>)[key] =
          DOMPurify.sanitize(value, {
            ALLOWED_TAGS: [
              "b",
              "i",
              "em",
              "strong",
              "a",
              "p",
              "br",
            ],
            ALLOWED_ATTR: ["href"],
          });
      } else {
        // Strip all HTML from non-HTML fields
        (sanitized as Record<string, unknown>)[key] =
          value
            .replace(/</g, "&lt;")
            .replace(/>/g, "&gt;")
            .replace(/"/g, "&quot;");
      }
    }
  }
 
  return sanitized;
}
 
app.get(
  "/api/comments/:postId",
  async (req, res) => {
    const comments = await db.getComments(
      req.params.postId
    );
    const safe = comments.map((c) =>
      sanitizeOutput(c, ["body"])
    );
    res.json(safe);
  }
);

Registro de auditoría: registrar lo ocurrido

Cuando se produce un incidente de seguridad, los registros de auditoría te dicen qué pasó, cuándo y quién estuvo implicado. Sin ellos, la respuesta a incidentes es un juego de adivinanzas.

tstypescript
interface AuditEvent {
  timestamp: string;
  userId: string | null;
  action: string;
  resource: string;
  resourceId: string;
  ip: string;
  userAgent: string;
  outcome: "success" | "failure" | "denied";
  details?: Record<string, unknown>;
}
 
class AuditLogger {
  async log(event: AuditEvent): Promise<void> {
    // Write to append-only audit store
    await db.query(
      `INSERT INTO audit_log
       (timestamp, user_id, action, resource,
        resource_id, ip, user_agent, outcome, details)
       VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
      [
        event.timestamp,
        event.userId,
        event.action,
        event.resource,
        event.resourceId,
        event.ip,
        event.userAgent,
        event.outcome,
        JSON.stringify(event.details ?? {}),
      ]
    );
  }
}
 
const audit = new AuditLogger();
 
// Audit middleware for sensitive operations
function auditAction(action: string, resource: string) {
  return async (
    req: Request,
    res: Response,
    next: NextFunction
  ) => {
    const originalJson = res.json.bind(res);
    const startTime = new Date().toISOString();
 
    res.json = function (body: unknown) {
      const outcome =
        res.statusCode >= 400 ? "failure" : "success";
 
      audit.log({
        timestamp: startTime,
        userId: req.user?.id ?? null,
        action,
        resource,
        resourceId:
          req.params.id ?? "unknown",
        ip: req.ip ?? "unknown",
        userAgent:
          req.headers["user-agent"] ?? "unknown",
        outcome,
      });
 
      return originalJson(body);
    };
 
    next();
  };
}
 
app.delete(
  "/api/users/:id",
  auditAction("delete", "user"),
  async (req, res) => {
    await db.deleteUser(req.params.id);
    res.json({ deleted: true });
    // Audit log automatically captures: who deleted
    // which user, when, from what IP
  }
);

Conclusiones clave

La limitación de tasa debe ser por capas y específica de cada endpoint: los endpoints de autenticación necesitan límites estrictos (5 intentos por cada 15 minutos), los de búsqueda necesitan límites moderados y los de lectura pueden ser más permisivos, usando el ID de usuario autenticado como clave principal con la IP como respaldo para peticiones no autenticadas. La validación de entradas con bibliotecas de esquemas como Zod debe rechazar las peticiones inválidas en el límite antes de que se ejecute cualquier lógica de negocio, imponiendo restricciones de tipo, límites de longitud y listas de permitidos explícitas para valores enumerados, sin confiar nunca en los campos de rol o permisos proporcionados por el cliente. Las cabeceras de seguridad forman una capa de defensa pasiva que no cuesta nada implementar: Strict-Transport-Security, X-Content-Type-Options, Content-Security-Policy y X-Frame-Options previenen categorías enteras de ataques con independencia de que la lógica de la aplicación sea correcta. El registro de auditoría en operaciones sensibles crea un rastro de investigación que convierte la respuesta a incidentes en un análisis basado en evidencias en lugar de adivinanzas: registra quién realizó qué acción sobre qué recurso y con qué resultado, y almacena estos registros en un almacén de solo anexado separado de los datos de la aplicación.

Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX