Saltar al contenido

Guía de configuración de Content Security Policy

Cómo implementar cabeceras Content Security Policy que protejan de verdad: sintaxis, scripts inline con nonces, reportes y despliegue incremental.

5 min de lectura
Herramientas de desarrollo del navegador mostrando cabeceras Content Security Policy bloqueando un intento de inyección de script inline

Content Security Policy (CSP) es la defensa más sólida contra Cross-Site Scripting (XSS). Le indica al navegador qué fuentes de contenido están permitidas: scripts, estilos, imágenes, fuentes y conexiones. Si un atacante inyecta una etiqueta script en tu página, el navegador la bloquea porque la fuente del script no está en tu lista de permitidos de CSP.

La mayoría de los equipos omiten CSP porque creen que es demasiado complejo o que romperá su sitio. La realidad es que una CSP bien configurada puede desplegarse de forma incremental: empieza con el modo report-only, corrige las violaciones y luego aplica la política. Esta guía recorre el proceso desde cero hasta la aplicación completa.

Conceptos básicos de las directivas CSP

Una CSP es un conjunto de directivas, cada una controlando un tipo de recurso. Cada directiva especifica qué fuentes están permitidas.

tstypescript
// The most common CSP directives
const cspDirectives = {
  "default-src":
    "Fallback for all resource types not explicitly listed",
  "script-src":
    "JavaScript sources (most critical for XSS prevention)",
  "style-src":
    "CSS sources",
  "img-src":
    "Image sources",
  "font-src":
    "Font sources",
  "connect-src":
    "URLs for fetch(), XHR, WebSocket, EventSource",
  "frame-src":
    "Sources for <iframe> and <frame>",
  "media-src":
    "Audio and video sources",
  "object-src":
    "Plugin sources (<object>, <embed>, <applet>)",
  "base-uri":
    "URLs allowed in <base> element",
  "form-action":
    "URLs that forms can submit to",
  "frame-ancestors":
    "Who can embed this page (replaces X-Frame-Options)",
  "report-uri":
    "Where to send violation reports (deprecated, use report-to)",
  "report-to":
    "Reporting endpoint group for violations",
};
nginxnginx
# ❌ Overly permissive CSP that provides little protection
Content-Security-Policy: default-src * 'unsafe-inline' 'unsafe-eval';
# Allows everything from everywhere — XSS protection is effectively disabled
 
# ✅ Strict CSP that actually protects against XSS
Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-abc123';
  style-src 'self' 'nonce-abc123';
  img-src 'self' data: https://images.example.com;
  font-src 'self' https://fonts.gstatic.com;
  connect-src 'self' https://api.example.com wss://ws.example.com;
  frame-ancestors 'none';
  object-src 'none';
  base-uri 'self';
  form-action 'self';
  report-to csp-violations;

CSP basada en nonces para scripts inline

La palabra clave unsafe-inline anula por completo la CSP para los scripts: cualquier script inline inyectado se ejecuta. Los nonces resuelven esto: cada carga de página genera un token aleatorio único. Solo se ejecutan las etiquetas script con el nonce correspondiente.

tstypescript
import crypto from "crypto";
import { Request, Response, NextFunction } from "express";
 
function cspMiddleware(
  req: Request,
  res: Response,
  next: NextFunction
): void {
  // Generate a unique nonce per request
  const nonce = crypto.randomBytes(16).toString("base64");
 
  // Make nonce available to templates
  res.locals.cspNonce = nonce;
 
  const csp = [
    `default-src 'self'`,
    `script-src 'self' 'nonce-${nonce}'`,
    `style-src 'self' 'nonce-${nonce}'`,
    `img-src 'self' data: https://images.example.com`,
    `font-src 'self' https://fonts.gstatic.com`,
    `connect-src 'self' https://api.example.com`,
    `frame-ancestors 'none'`,
    `object-src 'none'`,
    `base-uri 'self'`,
    `form-action 'self'`,
  ].join("; ");
 
  res.setHeader("Content-Security-Policy", csp);
  next();
}
htmlhtml
<!-- In your HTML template -->
<!DOCTYPE html>
<html>
<head>
  <!-- ❌ Blocked: inline script without nonce -->
  <script>console.log("This is blocked by CSP")</script>
 
  <!-- ✅ Allowed: inline script with matching nonce -->
  <script nonce="<%= cspNonce %>">
    window.__CONFIG__ = { apiUrl: "/api" };
  </script>
 
  <!-- ✅ Allowed: external script from 'self' -->
  <script src="/js/app.js"></script>
 
  <!-- ❌ Blocked: external script from unknown origin -->
  <script src="https://evil.com/steal-data.js"></script>
 
  <!-- ✅ Allowed: style with nonce -->
  <style nonce="<%= cspNonce %>">
    body { font-family: sans-serif; }
  </style>
</head>
<body>
  <!-- If an attacker injects via XSS: -->
  <!-- <script>document.cookie</script> -->
  <!-- Browser blocks it: no nonce, no execution -->
</body>
</html>

Despliegue incremental con el modo Report-Only

Aplicar CSP de inmediato en un sitio complejo romperá cosas. El modo report-only envía reportes de violaciones sin bloquear contenido, de modo que puedes identificar y corregir los problemas primero.

tstypescript
interface CSPViolationReport {
  "csp-report": {
    "document-uri": string;
    "violated-directive": string;
    "blocked-uri": string;
    "source-file": string;
    "line-number": number;
    "column-number": number;
    "original-policy": string;
  };
}
 
// Step 1: Deploy in report-only mode
function reportOnlyCSP(nonce: string): string {
  return [
    `default-src 'self'`,
    `script-src 'self' 'nonce-${nonce}'`,
    `style-src 'self' 'nonce-${nonce}'`,
    `img-src 'self' data:`,
    `connect-src 'self'`,
    `report-uri /api/csp-report`,
  ].join("; ");
}
 
// Set report-only header (does NOT block anything)
// res.setHeader("Content-Security-Policy-Report-Only", reportOnlyCSP(nonce));
 
// Step 2: Collect and analyze violation reports
import express from "express";
const app = express();
 
app.post(
  "/api/csp-report",
  express.json({ type: "application/csp-report" }),
  (req, res) => {
    const report = req.body as CSPViolationReport;
    const violation = report["csp-report"];
 
    console.log("CSP violation:", {
      page: violation["document-uri"],
      directive: violation["violated-directive"],
      blocked: violation["blocked-uri"],
      source: violation["source-file"],
      line: violation["line-number"],
    });
 
    // Store violations for analysis
    // Group by blocked-uri to identify patterns
 
    res.status(204).end();
  }
);
tstypescript
// Step 3: Fix violations iteratively
 
// Common violations and their fixes:
const violationFixes = {
  "Inline script blocked": {
    violation: "script-src: 'inline'",
    fix: "Add nonce to the script tag, or move to external file",
  },
  "Inline style blocked": {
    violation: "style-src: 'inline'",
    fix: "Add nonce to style tag, or use external stylesheet. " +
      "For style attributes, use 'unsafe-hashes' with specific hashes",
  },
  "Third-party script blocked": {
    violation: "script-src: https://cdn.analytics.com",
    fix: "Add the domain to script-src directive",
  },
  "eval() blocked": {
    violation: "script-src: 'eval'",
    fix: "Refactor code to avoid eval(). If a library requires it, " +
      "add 'unsafe-eval' only for that specific context (last resort)",
  },
  "data: URI blocked": {
    violation: "img-src: data:",
    fix: "Add 'data:' to img-src if you use base64 images",
  },
};
 
// Step 4: When violations drop to near-zero, switch to enforcement
// res.setHeader("Content-Security-Policy", enforceCSP(nonce));

CSP para aplicaciones de una sola página (SPA)

Las SPA presentan desafíos específicos de CSP: carga dinámica de scripts, bibliotecas de CSS-in-JS y llamadas a API contra múltiples backends.

tstypescript
// Next.js CSP configuration
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
 
export function middleware(request: NextRequest) {
  const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
 
  const csp = [
    `default-src 'self'`,
    `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
    // 'strict-dynamic' trusts scripts loaded by already-trusted scripts
    // If app.js (trusted via nonce) loads chunk-abc.js, it's allowed
    `style-src 'self' 'nonce-${nonce}'`,
    `img-src 'self' data: blob: https://images.example.com`,
    `font-src 'self'`,
    `connect-src 'self' https://api.example.com`,
    `frame-ancestors 'none'`,
    `object-src 'none'`,
    `base-uri 'self'`,
  ].join("; ");
 
  const response = NextResponse.next();
  response.headers.set("Content-Security-Policy", csp);
 
  // Pass nonce to the page via request header
  response.headers.set("x-nonce", nonce);
 
  return response;
}
tstypescript
// ❌ CSS-in-JS libraries often use inline styles
// styled-components, emotion, etc. inject <style> tags at runtime
// Without nonce support, they break under strict CSP
 
// ✅ Configure CSS-in-JS to use nonces
// styled-components: <StyleSheetManager nonce={nonce}>
// emotion: <CacheProvider value={createCache({ nonce })}>
// If the library doesn't support nonces, consider:
// 1. Using CSS modules instead (no runtime injection)
// 2. Using 'unsafe-inline' for styles only (still protects scripts)
// 3. Extracting styles at build time (next/styled-components plugin)
 
const cspForCSSInJS = {
  withNonce: `style-src 'self' 'nonce-${nonce}'`,
  fallback: `style-src 'self' 'unsafe-inline'`,
  // 'unsafe-inline' for styles is acceptable when script-src is strict
  // XSS payload injection is via scripts, not style attributes
};

Probando tu CSP

shbash
# Verify CSP headers are set correctly
curl -s -D - https://example.com | grep -i content-security-policy
 
# Use Google's CSP Evaluator to check for weaknesses
# https://csp-evaluator.withgoogle.com/
 
# Common mistakes that weaken CSP:
# 1. Using 'unsafe-inline' for script-src (defeats XSS protection)
# 2. Using * in any directive (too permissive)
# 3. Missing object-src 'none' (Flash/plugin-based attacks)
# 4. Missing base-uri 'self' (base tag injection)
# 5. Allowing 'unsafe-eval' without strict necessity

Puntos clave

  1. CSP es la defensa más sólida contra XSS: le indica al navegador qué fuentes de contenido están permitidas; incluso si un atacante inyecta HTML, los scripts no autorizados no se ejecutarán
  2. Usa nonces en lugar de unsafe-inline: genera un nonce aleatorio único por petición y adjúntalo a las etiquetas script y style; esto permite tu código inline mientras bloquea el código inyectado
  3. Despliega primero en modo report-only: Content-Security-Policy-Report-Only recopila violaciones sin romper nada; corrige los problemas antes de pasar a la aplicación de la política
  4. strict-dynamic simplifica la CSP en las SPA: permite los scripts cargados por scripts ya confiables, así que solo necesitas aplicar nonce a tu punto de entrada, no a cada chunk cargado dinámicamente
  5. object-src: none y base-uri: self son siempre obligatorios: cierran los vectores de inyección basados en plugins y en la etiqueta base, que a menudo se pasan por alto en las configuraciones de CSP
Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX