Skip to content

HTTPS y cabeceras de seguridad ​

HTTPS cifra la comunicación entre el navegador y tu servidor, y las cabeceras de seguridad le indican al navegador cómo protegerse a sí mismo. Juntas forman una capa de defensa muy barata (en su mayoría son unas cuantas líneas de configuración) que bloquea ataques enteros. En esta lección las configurarás en PHP, Express, Apache y Nginx.

Concepto ​

Sin HTTPS, los datos viajan como una postal: cualquiera en el camino (el Wi-Fi de la cafetería, el proveedor de internet, un router comprometido) puede leer contraseñas y cookies, o incluso modificar la página e inyectar código. Con HTTPS, los datos viajan en un sobre sellado que solo el destinatario puede abrir, y el certificado garantiza que el destinatario es quien dice ser.

Las cabeceras de seguridad son instrucciones que tu servidor manda al navegador junto con cada página: "solo cárgame por HTTPS", "no permitas que me metan en un iframe", "solo ejecuta scripts de estos dominios".

HTTPS en la práctica ​

  • Hosting compartido (cPanel, Hostinger, etc.): casi todos ofrecen certificados gratuitos de Let's Encrypt con un clic (AutoSSL).
  • VPS: usa Certbot.
  • Cloudflare: ofrece HTTPS gratis; configura el modo Full (strict), no "Flexible" (en Flexible el tramo Cloudflare → tu servidor va sin cifrar).
bash
# VPS con Nginx (Ubuntu): certificado gratuito y renovación automática
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d tusitio.com -d www.tusitio.com
sudo certbot renew --dry-run   # comprueba que la renovación automática funciona

Redirigir todo HTTP a HTTPS ​

apache
RewriteEngine On
# Si la petición NO es HTTPS, redirige permanentemente (301)
RewriteCond %{HTTPS} off
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
nginx
server {
    listen 80;
    server_name tusitio.com www.tusitio.com;
    return 301 https://$host$request_uri;
}

Contenido mixto

Si tu página HTTPS carga una imagen, script o CSS por http://, el navegador lo bloquea o muestra advertencias. Revisa la consola y cambia todas las URLs a https:// o a rutas relativas.

Las cabeceras importantes ​

CabeceraProtege contraValor recomendado
Strict-Transport-Security (HSTS)Degradar la conexión a HTTPmax-age=31536000; includeSubDomains
Content-Security-Policy (CSP)XSS, carga de recursos no autorizadosDepende del sitio (ver abajo)
X-Content-Type-OptionsQue el navegador "adivine" el tipo de archivonosniff
X-Frame-OptionsClickjacking (tu sitio dentro de un iframe ajeno)DENY o SAMEORIGIN
Referrer-PolicyFiltrar URLs privadas a otros sitiosstrict-origin-when-cross-origin
Permissions-PolicyUso de cámara, micrófono, ubicacióncamera=(), microphone=(), geolocation=()
Cross-Origin-Opener-PolicyVentanas emergentes que manipulan tu pestañasame-origin

Y algunas que conviene quitar: X-Powered-By: PHP/8.3.1 y Server: Apache/2.4.58 revelan versiones exactas a un atacante.

HSTS ​

Le dice al navegador: "durante un año, conéctate a este dominio solo por HTTPS, aunque el usuario escriba http://". Elimina el primer instante vulnerable antes de la redirección.

Cuidado con HSTS

Actívalo solo cuando HTTPS funcione perfectamente en el dominio y en todos los subdominios (si usas includeSubDomains). Si el certificado falla, los usuarios no podrán entrar de ninguna forma hasta que lo arregles. Empieza con un max-age corto (300) y súbelo.

Content Security Policy (CSP) ​

CSP es la cabecera más poderosa contra XSS: le das al navegador una lista blanca de dónde puede cargar scripts, estilos, imágenes, etc. Un script inyectado por un atacante no está en la lista, así que no se ejecuta.

txt
Content-Security-Policy:
  default-src 'self';                        ← por defecto, solo de mi dominio
  script-src 'self' https://cdn.jsdelivr.net; ← scripts propios y de este CDN
  style-src 'self' https://fonts.googleapis.com;
  font-src 'self' https://fonts.gstatic.com;
  img-src 'self' data: https:;               ← imágenes de cualquier sitio HTTPS
  connect-src 'self' https://api.tusitio.com; ← destinos de fetch/XHR
  frame-ancestors 'none';                    ← nadie puede meterme en un iframe
  base-uri 'self';
  form-action 'self';                        ← formularios solo envían a mi dominio
  object-src 'none'

(En la cabecera real va todo en una sola línea, separado por punto y coma.)

Con script-src 'self', los scripts en línea y los atributos onclick dejan de funcionar. Es intencional: así se bloquea el XSS. Para permitir un script en línea concreto, usa un nonce (número aleatorio por petición):

php
<?php
// Genera un nonce distinto en cada petición
$nonce = base64_encode(random_bytes(16));
header("Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-$nonce'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'");
?>
<!-- Solo los scripts con este nonce se ejecutan -->
<script nonce="<?= $nonce ?>">
  console.log('Script en línea permitido');
</script>

Prueba CSP sin romper nada

Usa primero Content-Security-Policy-Report-Only: el navegador no bloquea nada, solo reporta en la consola lo que habría bloqueado. Ajusta la política hasta que no haya avisos y entonces cámbiala a la cabecera real.

Ejemplo: configurar todas las cabeceras ​

php
<?php
// Inclúyelo al inicio de cada página, antes de cualquier salida
header_remove('X-Powered-By');

header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: DENY');
header('Referrer-Policy: strict-origin-when-cross-origin');
header('Permissions-Policy: camera=(), microphone=(), geolocation=()');
header('Cross-Origin-Opener-Policy: same-origin');
header(
    "Content-Security-Policy: default-src 'self'; " .
    "img-src 'self' data: https:; " .
    "style-src 'self' https://fonts.googleapis.com; " .
    "font-src 'self' https://fonts.gstatic.com; " .
    "frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'"
);
js
import express from 'express';
import helmet from 'helmet';

const app = express();

// helmet aplica ~13 cabeceras seguras y quita X-Powered-By
app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'", 'https://cdn.jsdelivr.net'],
      styleSrc: ["'self'", 'https://fonts.googleapis.com'],
      fontSrc: ["'self'", 'https://fonts.gstatic.com'],
      imgSrc: ["'self'", 'data:', 'https:'],
      frameAncestors: ["'none'"],
      objectSrc: ["'none'"],
    },
  },
  strictTransportSecurity: { maxAge: 31536000, includeSubDomains: true },
  referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
}));
apache
<IfModule mod_headers.c>
    Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
    Header always set X-Content-Type-Options "nosniff"
    Header always set X-Frame-Options "DENY"
    Header always set Referrer-Policy "strict-origin-when-cross-origin"
    Header always set Permissions-Policy "camera=(), microphone=(), geolocation=()"
    Header always set Content-Security-Policy "default-src 'self'; img-src 'self' data: https:; frame-ancestors 'none'; base-uri 'self'; object-src 'none'"
    Header unset X-Powered-By
</IfModule>

# Evita listar el contenido de carpetas sin index
Options -Indexes
# No mostrar la versión de Apache en páginas de error
ServerSignature Off
nginx
server {
    listen 443 ssl http2;
    server_name tusitio.com;

    ssl_certificate     /etc/letsencrypt/live/tusitio.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/tusitio.com/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;   # sin TLS 1.0/1.1

    server_tokens off;               # oculta la versión de Nginx

    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-Frame-Options "DENY" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
    add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: https:; frame-ancestors 'none'; object-src 'none'" always;
}
ini
; No anunciar la versión de PHP
expose_php = Off
; Nunca mostrar errores en producción
display_errors = Off
log_errors = On

Error común

En Nginx, si defines add_header dentro de un bloque location, se pierden todas las cabeceras definidas en el bloque server. Repítelas o usa un archivo include con todas.

Verifica tu configuración ​

bash
curl -I https://tusitio.com
# Revisa que aparezcan las cabeceras y que NO aparezca X-Powered-By

Ejercicios ​

  1. Analiza un sitio tuyo (o un proyecto local publicado) en securityheaders.com y anota qué cabeceras faltan.
  2. Escribe una CSP para un sitio que usa: scripts propios, Google Analytics (https://www.googletagmanager.com), Google Fonts e imágenes de su propio dominio.
  3. ¿Qué ataque evita X-Frame-Options: DENY (o frame-ancestors 'none')? Explícalo con un ejemplo.
Ver solución
  1. Respuesta libre. Lo habitual en sitios sin configurar: faltan HSTS, CSP, X-Content-Type-Options, Referrer-Policy y Permissions-Policy, y aparece X-Powered-By.

  2. Una posible política (en una línea en la cabecera real):

txt
default-src 'self';
script-src 'self' https://www.googletagmanager.com;
connect-src 'self' https://www.google-analytics.com https://*.google-analytics.com;
img-src 'self' https://www.google-analytics.com;
style-src 'self' https://fonts.googleapis.com;
font-src 'self' https://fonts.gstatic.com;
frame-ancestors 'none'; base-uri 'self'; object-src 'none'
  1. El clickjacking: el atacante carga tu sitio en un iframe invisible encima de un botón atractivo ("¡Gana un iPhone!"). El usuario cree que hace clic en el botón del atacante, pero en realidad hace clic en "Eliminar cuenta" o "Transferir" de tu sitio, con su sesión activa. Si el navegador se niega a mostrar tu sitio en iframes ajenos, el ataque es imposible.

Resumen ​

  • HTTPS es obligatorio: certificados gratuitos con Let's Encrypt y redirección 301 de HTTP a HTTPS.
  • HSTS obliga a usar HTTPS siempre; actívalo cuando todo funcione bien.
  • CSP es una lista blanca de recursos y la defensa extra más fuerte contra XSS; pruébala primero con Report-Only.
  • Añade nosniff, X-Frame-Options, Referrer-Policy y Permissions-Policy.
  • En Express usa helmet; en Apache .htaccess; en Nginx add_header ... always.
  • Oculta versiones (expose_php = Off, server_tokens off) y verifica con securityheaders.com.

Academia Web — aprende, copia y construye.