Tema
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 funcionaRedirigir 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
| Cabecera | Protege contra | Valor recomendado |
|---|---|---|
Strict-Transport-Security (HSTS) | Degradar la conexión a HTTP | max-age=31536000; includeSubDomains |
Content-Security-Policy (CSP) | XSS, carga de recursos no autorizados | Depende del sitio (ver abajo) |
X-Content-Type-Options | Que el navegador "adivine" el tipo de archivo | nosniff |
X-Frame-Options | Clickjacking (tu sitio dentro de un iframe ajeno) | DENY o SAMEORIGIN |
Referrer-Policy | Filtrar URLs privadas a otros sitios | strict-origin-when-cross-origin |
Permissions-Policy | Uso de cámara, micrófono, ubicación | camera=(), microphone=(), geolocation=() |
Cross-Origin-Opener-Policy | Ventanas emergentes que manipulan tu pestaña | same-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 Offnginx
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 = OnError 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
- securityheaders.com: calificación de A+ a F de tus cabeceras.
- SSL Labs: análisis del certificado y protocolos TLS.
- CSP Evaluator: detecta debilidades en tu CSP.
- En la terminal:
bash
curl -I https://tusitio.com
# Revisa que aparezcan las cabeceras y que NO aparezca X-Powered-ByEjercicios
- Analiza un sitio tuyo (o un proyecto local publicado) en securityheaders.com y anota qué cabeceras faltan.
- 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. - ¿Qué ataque evita
X-Frame-Options: DENY(oframe-ancestors 'none')? Explícalo con un ejemplo.
Ver solución
Respuesta libre. Lo habitual en sitios sin configurar: faltan HSTS, CSP,
X-Content-Type-Options,Referrer-PolicyyPermissions-Policy, y apareceX-Powered-By.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'- 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-PolicyyPermissions-Policy. - En Express usa
helmet; en Apache.htaccess; en Nginxadd_header ... always. - Oculta versiones (
expose_php = Off,server_tokens off) y verifica con securityheaders.com.