<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="es">
  <title>Blog de Asentic</title>
  <subtitle>Análisis técnico, casos reales y guías de remediación en ciberseguridad.</subtitle>
  <link href="https://www.asentic.cl/blog/feed.xml" rel="self" type="application/atom+xml"/>
  <link href="https://www.asentic.cl/blog/" rel="alternate" type="text/html"/>
  <id>https://www.asentic.cl/blog/</id>
  <updated>2026-05-12T03:19:05.122877+00:00</updated>
  <author>
    <name>Asentic</name>
    <uri>https://www.asentic.cl/</uri>
    <email>contacto@asentic.cl</email>
  </author>
  <icon>https://www.asentic.cl/assets/img/logo-mark.svg</icon>
  <rights>© 2026 Asentic</rights>

  <entry>
    <title>Subdomain takeover: cómo un CNAME huérfano se convierte en un dominio hostil</title>
    <link href="https://www.asentic.cl/blog/subdomain-takeover/" rel="alternate" type="text/html"/>
    <id>https://www.asentic.cl/blog/subdomain-takeover/</id>
    <published>2026-05-13T00:00:00+00:00</published>
    <updated>2026-05-13T00:00:00+00:00</updated>
    <summary type="text">Un subdominio que apunta por CNAME a un servicio SaaS retirado puede ser reclamado por cualquiera. El atacante hereda tu nombre, tu cert TLS válido y la confianza de tus usuarios. Cómo detectarlo y prevenirlo.</summary>
    <content type="html"><![CDATA[<p>Un patrón que detectamos en muchos clientes durante recon: subdominios huérfanos que apuntan a servicios SaaS retirados. El equipo de marketing creó <code>promo2024.cliente.cl</code> apuntando a un sitio de campaña en Heroku, terminó la campaña, alguien borró la app — pero <strong>nadie tocó el DNS</strong>.</p>
<p>Resultado: un atacante crea una cuenta en Heroku, reclama exactamente el mismo nombre que tenía la app borrada, y ahora <code>promo2024.cliente.cl</code> resuelve a un servidor controlado por él. Con TLS válido. En tu zona.</p>
<p>A esto se le llama <strong>subdomain takeover</strong>, y es una vulnerabilidad de impacto alto que vive en el espacio entre &ldquo;responsabilidad de DevOps&rdquo; y &ldquo;responsabilidad del equipo que usa el SaaS&rdquo;. Por eso suele quedar sin dueño.</p>
<h2 id="el-mecanismo-paso-a-paso">El mecanismo, paso a paso</h2>
<ol>
<li><strong>Tu zona DNS tiene un CNAME</strong> del tipo <code>sub.cliente.cl. → algo.saas.com.</code></li>
<li><strong>El recurso en el SaaS se borra o expira</strong> (la app de Heroku, el bucket de S3, el sitio de Netlify, el subdomain de Shopify, etc.). El CNAME en tu zona queda apuntando a un nombre que <strong>ya no existe en ese SaaS</strong>.</li>
<li><strong>El SaaS permite &ldquo;claim&rdquo; del nombre</strong>: cualquier usuario puede crear un recurso nuevo con ese mismo subdomain interno. La mayoría de proveedores no valida que vos seas el dueño del CNAME que apunta hacia ahí.</li>
<li><strong>El atacante reclama el nombre</strong>. Su recurso ahora responde cuando alguien resuelve <code>sub.cliente.cl</code> → CNAME a <code>algo.saas.com</code> → el contenido del atacante.</li>
<li><strong>Heredás la URL completa</strong>: tu nombre, en tu dominio, con TLS válido (porque muchos SaaS proveen cert automático para el hostname configurado), con la confianza acumulada de la zona.</li>
</ol>
<p>El atacante puede ahora:</p>
<ul>
<li>Servir un sitio de phishing con tu marca y URL legítima.</li>
<li>Robar cookies con dominio <code>.cliente.cl</code> (si tu apex setea cookies sin <code>Path</code> estricto).</li>
<li>Ejecutar JS bajo el mismo origen que cualquier app que importe scripts desde ese subdomain.</li>
<li>Setear meta-tags arbitrarios y aparecer en SEO como tu sitio oficial.</li>
<li>Recibir emails enviados a <code>*@sub.cliente.cl</code> si setea MX (escenario más raro pero posible).</li>
</ul>
<h2 id="donde-pasa-mas-seguido">Dónde pasa más seguido</h2>
<p>Cualquier SaaS que use CNAME a hostnames internos y permita claim libre. La lista cambia con el tiempo — muchos proveedores arreglaron el bug exigiendo verificación de ownership — pero a 2026 los patrones más comunes siguen siendo:</p>
<ul>
<li><strong>Heroku</strong> — <code>*.herokuapp.com</code> para apps borradas.</li>
<li><strong>GitHub Pages</strong> — repos archivados o borrados sin quitar el CNAME del custom domain.</li>
<li><strong>AWS S3 / CloudFront</strong> — buckets sin verificación de ownership.</li>
<li><strong>Azure</strong> — App Service, Blob Storage, Traffic Manager, Front Door.</li>
<li><strong>GCP</strong> — Cloud Storage, App Engine, Firebase Hosting, Cloud Run.</li>
<li><strong>Vercel / Netlify</strong> — proyectos eliminados con CNAME residual.</li>
<li><strong>Shopify, Webflow, Pantheon, Fastly, Tumblr, WordPress.com</strong> — el patrón se repite en hosting tipo SaaS.</li>
</ul>
<p>Cada proveedor tiene un &ldquo;fingerprint&rdquo; — texto en la respuesta HTTP que indica &ldquo;este nombre no está reclamado&rdquo;. Por ejemplo, GitHub Pages devuelve <code>"There isn't a GitHub Pages site here."</code>, Heroku devuelve <code>"No such app"</code>. Herramientas de detección como <a href="https://github.com/haccer/subjack">subjack</a> y <a href="https://github.com/m4ll0k/takeover">takeover</a> usan exactamente esa lista.</p>
<h2 id="como-verificas-tu-zona">Cómo verificás tu zona</h2>
<h3 id="manual-para-un-subdominio-puntual">Manual, para un subdominio puntual</h3>
<div class="post-code"><pre><span></span><code><span class="c1"># 1) ¿el sub tiene CNAME?</span>
dig<span class="w"> </span>+short<span class="w"> </span>CNAME<span class="w"> </span>sub.cliente.cl

<span class="c1"># 2) ¿resuelve a una IP?</span>
dig<span class="w"> </span>+short<span class="w"> </span>A<span class="w"> </span>sub.cliente.cl

<span class="c1"># 3) ¿qué responde el destino?</span>
curl<span class="w"> </span>-sI<span class="w"> </span>https://sub.cliente.cl/
curl<span class="w"> </span>-s<span class="w"> </span>https://sub.cliente.cl/<span class="w"> </span><span class="p">|</span><span class="w"> </span>head<span class="w"> </span>-20
</code></pre></div>

<p>Las señales rojas:</p>
<ul>
<li>Hay CNAME, pero <code>dig A</code> no resuelve a nada → posible NXDOMAIN del target.</li>
<li>El HTTP responde con uno de los strings conocidos del SaaS (<code>"No such app"</code>, <code>"There isn't a GitHub Pages site here"</code>, etc.).</li>
<li>El cert TLS no matchea el hostname (otro caso, llamado <strong>dangling IP recycled</strong> — un IP que era tuyo ahora es de otro tenant).</li>
</ul>
<h3 id="a-escala-para-todo-el-dominio">A escala, para todo el dominio</h3>
<p>Inventario de subdominios desde CT logs:</p>
<div class="post-code"><pre><span></span><code>curl<span class="w"> </span>-s<span class="w"> </span><span class="s1">&#39;https://crt.sh/?q=%25.cliente.cl&amp;output=json&#39;</span><span class="w"> </span><span class="se">\</span>
<span class="w">  </span><span class="p">|</span><span class="w"> </span>jq<span class="w"> </span>-r<span class="w"> </span><span class="s1">&#39;.[].name_value&#39;</span><span class="w"> </span><span class="se">\</span>
<span class="w">  </span><span class="p">|</span><span class="w"> </span>tr<span class="w"> </span><span class="s1">&#39;,&#39;</span><span class="w"> </span><span class="s1">&#39;\n&#39;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>sort<span class="w"> </span>-u<span class="w"> </span>&gt;<span class="w"> </span>subs.txt
</code></pre></div>

<p>Después corrés algo como subjack contra esa lista. Lo importante: <strong>el inventario debe rehacerse seguido</strong>, porque CT logs solo te dan dominios que tuvieron cert TLS en algún momento, y los CNAME a SaaS típicamente disparan eso (los SaaS auto-provisionan TLS).</p>
<h2 id="la-parte-que-mas-cuesta-prevencion">La parte que más cuesta: prevención</h2>
<p>La detección es relativamente fácil. Lo difícil es que el patrón <strong>no reaparezca</strong>, y eso requiere disciplina organizacional, no técnica.</p>
<h3 id="regla-operativa-dns-first-on-creation-dns-last-on-retirement">Regla operativa: <strong>DNS first on creation, DNS last on retirement</strong></h3>
<ul>
<li>Cuando creás un subdomain apuntando a un SaaS: documentá quién es el dueño, en qué proyecto vive, y cuándo se revisará.</li>
<li>Cuando vas a retirar un servicio: <strong>primero borrás el DNS, después borrás el recurso en el SaaS</strong>. Si lo hacés al revés (borrás el recurso, dejás el DNS), creás exactamente la ventana de oportunidad.</li>
</ul>
<h3 id="inventario-versionado">Inventario versionado</h3>
<p>Tu zona DNS debería estar en git, o al menos exportarse periódicamente a un repo. Cualquier CNAME hacia un host externo necesita un comentario que diga <strong>para qué servicio es</strong> y <strong>quién lo creó</strong>. Si nadie reconoce el CNAME, candidato a borrar.</p>
<h3 id="verificacion-de-ownership-en-el-saas-cuando-exista">Verificación de ownership en el SaaS, cuando exista</h3>
<p>Varios proveedores hoy permiten &ldquo;domain verification&rdquo; — un TXT record en tu zona que prueba que sos vos el dueño. Si el SaaS lo soporta, activalo. Eso bloquea el claim externo aunque el CNAME quede huérfano.</p>
<h3 id="monitoreo-continuo-no-auditoria-puntual">Monitoreo continuo, no auditoría puntual</h3>
<p>Una auditoría manual cada 6 meses NO sirve. El CNAME huérfano puede aparecer mañana y ser reclamado pasado mañana. La detección efectiva es diaria, automatizada, con alerta al equipo de seguridad cuando aparece un nuevo huérfano.</p>
<p>Es exactamente el problema que nos llevó a construir el <a href="/servicios/dangling-monitor.html">Dangling Monitor</a> de Asentic — auto-descubre subdominios desde CT logs, clasifica cada uno en 5 estados (<code>healthy</code>, <code>dangling-cname-nxdomain</code>, <code>dangling-cname-saas-claimable</code>, <code>dangling-ip-recycled</code>, <code>dangling-ns</code>) y alerta cuando aparece uno nuevo o cuando uno existente cambia de estado. El servicio corre como monitoreo continuo con cadencia diaria y entrega los hallazgos por email.</p>
<h2 id="casos-reales-que-vimos">Casos reales que vimos</h2>
<p>Ofuscando contexto:</p>
<ul>
<li><strong>Cliente educacional</strong> — <code>eventos2019.universidad.cl</code> apuntaba a un Heroku que se borró en 2020. Detectado en 2026, reclamable. Reclamamos nosotros antes que cualquiera externo y lo entregamos al cliente para que limpie el DNS.</li>
<li><strong>Retail</strong> — <code>legacy-shop.retail.cl</code> apuntaba a un Shopify retirado hacía 18 meses. El CNAME estaba documentado en una hoja Excel que nadie revisaba.</li>
<li><strong>Pyme tecnológica</strong> — <code>docs.empresa.cl</code> → GitHub Pages, repo archivado por un ex-empleado. Cuenta personal, no organizacional. El nombre quedó claimable cuando GitHub borró el repo automáticamente por inactividad.</li>
</ul>
<p>El patrón común: <strong>nadie sabía que el CNAME existía</strong>. No es un problema técnico, es un problema de inventario.</p>
<h2 id="cierre">Cierre</h2>
<p>Si tu zona DNS tiene más de 20 subdominios y nunca corriste un check de takeover, asumí que tenés al menos uno. La probabilidad sube con cada equipo que usa SaaS sin coordinación con seguridad — marketing, eventos, dev, customer success.</p>
<p>La detección manual te resuelve el problema de ahora. La detección continua te lo resuelve para siempre.</p>]]></content>
    <category term="dns"/>
    <category term="subdomain"/>
    <category term="takeover"/>
    <category term="hardening"/>
    <category term="monitoring"/>
  </entry>

  <entry>
    <title>HSTS en Apache: cómo activarlo sin romper subdominios</title>
    <link href="https://www.asentic.cl/blog/hsts-en-apache/" rel="alternate" type="text/html"/>
    <id>https://www.asentic.cl/blog/hsts-en-apache/</id>
    <published>2026-05-12T00:00:00+00:00</published>
    <updated>2026-05-12T00:00:00+00:00</updated>
    <summary type="text">Strict-Transport-Security cierra la ventana de TLS stripping pero, mal puesto, deja inaccesibles subdominios HTTP legacy. Guía para llegar a max-age=2 años + includeSubDomains + preload sin sustos.</summary>
    <content type="html"><![CDATA[<p><code>Strict-Transport-Security</code> (HSTS) le dice al browser que tu sitio se accede SIEMPRE por HTTPS. La primera vez que un visitante llega por HTTPS y recibe el header, lo guarda; cualquier intento futuro de cargar el sitio por <code>http://</code> se reescribe a <code>https://</code> antes de salir del browser.</p>
<p>Es el control que cierra el último hueco que dejó la migración a HTTPS: el primer <code>http://</code> que un usuario tipea o que un link viejo apunta. Sin HSTS, ese primer request va en claro y puede ser interceptado para hacer downgrade (TLS stripping).</p>
<p>El problema: HSTS es <strong>pegajoso</strong>. Una vez que un browser lo memoriza, no hay forma práctica de bajarse rápido si te equivocaste. Por eso vale la pena entender qué activás antes de pegarlo.</p>
<h2 id="anatomia-del-header">Anatomía del header</h2>
<div class="post-code"><pre><span></span><code>Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
</code></pre></div>

<p>Tres piezas, cada una con su trade-off:</p>
<ul>
<li><strong><code>max-age=63072000</code></strong> — segundos que el browser memoriza la política. 63072000 = 2 años. Lo mínimo razonable para producción es 6 meses (<code>15552000</code>). Menos de eso es práctica de &ldquo;estoy probando&rdquo;.</li>
<li><strong><code>includeSubDomains</code></strong> — extiende la regla a TODOS los subdominios del host actual. Es lo que sube la postura de &ldquo;tu apex está protegido&rdquo; a &ldquo;tu zona DNS entera está protegida&rdquo;.</li>
<li><strong><code>preload</code></strong> — declara intención de aparecer en la <a href="https://hstspreload.org/">HSTS preload list</a>. Los browsers traen esa lista hardcoded; tu sitio queda protegido <em>antes</em> del primer request. Es la única forma de cerrar el &ldquo;primer-request gap&rdquo; del 100%.</li>
</ul>
<h2 id="la-trampa-de-includesubdomains">La trampa de <code>includeSubDomains</code></h2>
<p>Este es el campo que más se equivoca. Implica que cualquier subdominio bajo tu zona también va a forzarse a HTTPS.</p>
<p>Caso real que vemos a menudo:</p>
<ul>
<li><code>cliente.cl</code> está en HTTPS con cert válido. Se le activa HSTS con <code>includeSubDomains</code>.</li>
<li><code>cliente.cl</code> tiene un <code>legacy.cliente.cl</code> viejo, sin cert, sirviendo un sitio interno por HTTP plano (intranet, monitoreo Grafana, lo que sea).</li>
<li>A partir del primer visit a <code>cliente.cl</code>, ese mismo browser ya no puede entrar a <code>legacy.cliente.cl</code> por HTTP. Y como no tiene cert TLS, tampoco por HTTPS. El subdominio queda inalcanzable hasta que el max-age expire.</li>
</ul>
<p>La lógica defensiva es la correcta — un atacante que controle DNS del subdominio podría exfiltrar cookies del apex si HSTS no aplicara a subs. Pero en práctica te tomás el tiempo de inventariar todos tus subs antes de prender la regla.</p>
<h2 id="el-camino-seguro-aumentar-gradualmente">El camino seguro: aumentar gradualmente</h2>
<p>La receta que usamos en clientes:</p>
<h3 id="paso-1-max-age-corto-sin-includesubdomains-sin-preload">Paso 1 — <code>max-age</code> corto, sin <code>includeSubDomains</code>, sin <code>preload</code></h3>
<div class="post-code"><pre><span></span><code>Strict-Transport-Security: max-age=300
</code></pre></div>

<p>Cinco minutos. Si rompés algo, en 5 minutos los browsers olvidan. Lo dejás 24-48 horas en producción para verificar que el apex realmente sirve TLS bien (no hay request mixto, no hay redirect-loop, todo funciona).</p>
<h3 id="paso-2-max-age-6-meses-sin-includesubdomains-todavia">Paso 2 — <code>max-age</code> 6 meses, sin <code>includeSubDomains</code> todavía</h3>
<div class="post-code"><pre><span></span><code>Strict-Transport-Security: max-age=15552000
</code></pre></div>

<p>Lo dejás 1-2 semanas. Esto ya te da la protección real para el apex.</p>
<h3 id="paso-3-inventario-de-subdominios-decision-sobre-includesubdomains">Paso 3 — Inventario de subdominios + decisión sobre <code>includeSubDomains</code></h3>
<p>Antes de agregarlo, listá todos tus subs:</p>
<div class="post-code"><pre><span></span><code>dig +short NS cliente.cl
dig +short ANY cliente.cl
# o consulta los CT logs:
curl -s &#39;https://crt.sh/?q=%25.cliente.cl&amp;output=json&#39; | jq -r &#39;.[].name_value&#39; | sort -u
</code></pre></div>

<p>Para cada uno, verifica:</p>
<ul>
<li>¿Sirve HTTPS con cert válido?</li>
<li>¿Es un subdominio &ldquo;vivo&rdquo; o queda DNS huérfano? (los huérfanos no importan para HSTS, pero deberías limpiarlos por otras razones).</li>
<li>¿Hay alguno que SÓLO sirva HTTP por diseño? (intranets viejas, dashboards internos sin TLS).</li>
</ul>
<p>Si todos sirven HTTPS bien, agregás <code>includeSubDomains</code>. Si hay alguno HTTP-only, primero lo migrás a HTTPS o lo retirás del DNS.</p>
<div class="post-code"><pre><span></span><code>Strict-Transport-Security: max-age=15552000; includeSubDomains
</code></pre></div>

<p>Una semana más en este estado.</p>
<h3 id="paso-4-max-age-2-anos-preload">Paso 4 — <code>max-age</code> 2 años + <code>preload</code></h3>
<p>Cuando ya estás seguro de que todo el ecosistema soporta TLS y querés cerrar el primer-request gap:</p>
<div class="post-code"><pre><span></span><code>Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
</code></pre></div>

<p>Submitís el dominio en <a href="https://hstspreload.org/">hstspreload.org</a>. Toma 1-3 meses entrar en la próxima release de Chromium, después Firefox/Safari/Edge la heredan.</p>
<p><strong>Importante:</strong> una vez en la preload list, salir es lento. Solicitar la remoción puede tomar meses y los browsers viejos pueden quedarte preloaded por años. No actives <code>preload</code> mientras dudes.</p>
<h2 id="config-en-apache">Config en Apache</h2>
<h3 id="via-htaccess-o-vhost">Vía <code>.htaccess</code> o vhost</h3>
<div class="post-code"><pre><span></span><code><span class="nt">&lt;IfModule</span><span class="w"> </span><span class="s">mod_headers.c</span><span class="nt">&gt;</span>
<span class="w">    </span><span class="nb">Header</span><span class="w"> </span>always<span class="w"> </span>set<span class="w"> </span>Strict-Transport-Security<span class="w"> </span><span class="s2">&quot;max-age=63072000; includeSubDomains; preload&quot;</span>
<span class="nt">&lt;/IfModule&gt;</span>
</code></pre></div>

<p>El flag <code>always</code> es importante: sin él, Apache aplica el header solo en respuestas 200; con <code>always</code> también lo manda en 30x/40x/50x, donde tradicionalmente se &ldquo;olvida&rdquo; — y es justamente en un 301 HTTP→HTTPS donde más útil resulta.</p>
<h3 id="redirect-301-obligatorio-antes">Redirect 301 obligatorio antes</h3>
<p>HSTS asume que tu sitio responde por HTTPS. El redirect HTTP→HTTPS debe estar antes:</p>
<div class="post-code"><pre><span></span><code><span class="nt">&lt;VirtualHost</span><span class="w"> </span><span class="s">*:80</span><span class="nt">&gt;</span>
<span class="w">    </span><span class="nb">ServerName</span><span class="w"> </span>cliente.cl
<span class="w">    </span><span class="nb">ServerAlias</span><span class="w"> </span>www.cliente.cl
<span class="w">    </span><span class="nb">Redirect</span><span class="w"> </span>permanent<span class="w"> </span>/<span class="w"> </span>https://cliente.cl/
<span class="nt">&lt;/VirtualHost&gt;</span>
</code></pre></div>

<p>O en <code>.htaccess</code>:</p>
<div class="post-code"><pre><span></span><code><span class="nt">&lt;IfModule</span><span class="w"> </span><span class="s">mod_rewrite.c</span><span class="nt">&gt;</span>
<span class="w">    </span><span class="nb">RewriteEngine</span><span class="w"> </span><span class="k">On</span>
<span class="w">    </span><span class="nb">RewriteCond</span><span class="w"> </span>%{HTTPS}<span class="w"> </span><span class="k">off</span>
<span class="w">    </span><span class="nb">RewriteRule</span><span class="w"> </span>^<span class="w"> </span>https://%{HTTP_HOST}%{REQUEST_URI}<span class="w"> </span>[L,R=301]
<span class="nt">&lt;/IfModule&gt;</span>
</code></pre></div>

<p>El header HSTS debe servirse desde HTTPS (los browsers ignoran HSTS recibido por HTTP), así que el vhost :443 es el que lleva el <code>Header always set</code>.</p>
<h2 id="detras-de-cloudflare">Detrás de Cloudflare</h2>
<p>Si estás detrás de CF (o cualquier CDN), tenés dos opciones para servir HSTS:</p>
<ol>
<li><strong>Del origen</strong>: Apache pone el header, CF lo deja pasar. Pro: una sola fuente de verdad. Contra: si tu origen alguna vez se sirve directo (CF bypass), el header sigue saliendo.</li>
<li><strong>Del edge (CF dashboard)</strong>: CF inyecta el header. Pro: aunque tu origen se sirva en HTTP por algún motivo, CF te cubre. Contra: si migrás de CDN o quitás CF, perdés el header sin enterarte.</li>
</ol>
<p>En la práctica, <strong>ambos</strong>: el origen lo manda Y CF lo refuerza. Cloudflare detecta duplicados y deja uno solo.</p>
<h2 id="como-verificas">Cómo verificás</h2>
<h3 id="antes-de-pegarlo-en-produccion">Antes de pegarlo en producción</h3>
<div class="post-code"><pre><span></span><code># verifica que el sitio sirve HTTPS bien
curl -sI https://cliente.cl/ | grep -i &quot;strict-transport&quot;

# verifica que ningún subdominio queda HTTP-only:
for sub in www app api blog dashboard mail; do
  echo &quot;--- $sub.cliente.cl ---&quot;
  curl -sI --max-time 5 https://$sub.cliente.cl/ 2&gt;&amp;1 | head -1
done
</code></pre></div>

<h3 id="despues-de-pegarlo">Después de pegarlo</h3>
<div class="post-code"><pre><span></span><code># debería verse el header completo
curl -sI https://cliente.cl/ | grep -i &quot;strict-transport&quot;
# Strict-Transport-Security: max-age=63072000; includeSubDomains; preload

# verifica que el redirect 301 desde HTTP funciona
curl -sI http://cliente.cl/ | head -5
# debería devolver 301 hacia https://...
</code></pre></div>

<h3 id="antes-de-submitir-a-preload">Antes de submitir a preload</h3>
<p><a href="https://hstspreload.org/">hstspreload.org</a> hace los checks por vos: header presente, max-age suficiente, <code>includeSubDomains</code>, <code>preload</code>, redirect 301, cert válido, subdominios accesibles por HTTPS. Si fallás algún check, te dice cuál — y mejor arreglarlo antes que después.</p>
<h2 id="salida-de-emergencia">Salida de emergencia</h2>
<p>Si pegaste HSTS y querés revertir:</p>
<ol>
<li><strong>Antes de preload</strong>: bajá <code>max-age</code> a 0 (no quites el header). Los browsers que reciban <code>max-age=0</code> borran la entrada. Tardás <code>max-age</code> original en cubrir a todos los visitantes que tuvieron la versión anterior.</li>
<li><strong>Después de preload</strong>: hay que solicitar remoción en la HSTS preload list + esperar la siguiente release de Chrome (~6 semanas) + heredan los demás browsers (~3 meses) + los browsers viejos pueden quedarte protegidos por años. No hay rollback rápido.</li>
</ol>
<h2 id="como-lo-detectamos-en-el-freescan">Cómo lo detectamos en el FreeScan</h2>
<p>El <a href="https://scan.asentic.cl/">Asentic FreeScan</a> reporta tres patrones distintos:</p>
<ul>
<li><strong>HSTS ausente</strong> — Medium. El sitio funciona en HTTPS pero no protege el primer request.</li>
<li><strong>HSTS con max-age corto</strong> — Low. El header está, pero <code>max-age &lt; 6 meses</code> te deja una ventana de exposición innecesaria.</li>
<li><strong>HSTS sin <code>includeSubDomains</code> o sin <code>preload</code></strong> — Info. Eligible para subir la postura, no es un riesgo per se.</li>
</ul>
<p>La distinción importa porque &ldquo;no implementado&rdquo; y &ldquo;implementado pobre&rdquo; se ven igual en una auditoría superficial, pero requieren acciones distintas.</p>
<h2 id="cierre">Cierre</h2>
<p>HSTS es de los headers con mejor ratio efecto/esfuerzo. La parte difícil no es el header en sí — son tres líneas de Apache — sino el inventario de subdominios que hay que hacer antes para no romper el ecosistema. Dedicarle un día a eso y migrar gradualmente vale el costo de la postura final.</p>
<p>Si tu sitio ya sirve HTTPS hace tiempo, lo más probable es que el camino al <code>preload</code> esté a una semana de distancia.</p>]]></content>
    <category term="hsts"/>
    <category term="tls"/>
    <category term="apache"/>
    <category term="headers"/>
    <category term="hardening"/>
  </entry>

  <entry>
    <title>CSP en WordPress: cómo escribir un header útil sin romper el sitio</title>
    <link href="https://www.asentic.cl/blog/csp-en-wordpress/" rel="alternate" type="text/html"/>
    <id>https://www.asentic.cl/blog/csp-en-wordpress/</id>
    <published>2026-05-11T00:00:00+00:00</published>
    <updated>2026-05-11T00:00:00+00:00</updated>
    <summary type="text">Content-Security-Policy es de los headers más útiles para frenar XSS y data exfil, pero también el más fácil de inutilizar. En WordPress, además, la mitad de los plugins inyecta inline scripts. Esta es una receta concreta para llegar a una CSP estricta sin dejar el panel inservible.</summary>
    <content type="html"><![CDATA[<p>Content-Security-Policy (CSP) es uno de los headers que más nos toca discutir con equipos de desarrollo. Es de los pocos controles que <strong>mitigan XSS aunque el código siga vulnerable</strong>: aunque un atacante consiga inyectar un <code>&lt;script&gt;</code> malicioso, el browser se niega a ejecutarlo si no encaja en la política. También bloquea exfiltración a dominios no listados, frena form-jacking y reduce el blast radius de un plugin comprometido.</p>
<p>El problema: en WordPress muy pocas instalaciones la tienen bien configurada. Las dos rutas habituales son:</p>
<ol>
<li><strong>CSP ausente</strong> — nadie la puso, el sitio queda con la postura por defecto del browser.</li>
<li><strong>CSP nominalmente puesta</strong> con <code>default-src *</code> o <code>script-src 'self' 'unsafe-inline' 'unsafe-eval'</code> — escrita para que &ldquo;no rompa nada&rdquo;, lo que en la práctica equivale a no tener CSP.</li>
</ol>
<p>Este post es la receta que usamos cuando tenemos que llevar un WordPress real desde &ldquo;sin CSP&rdquo; hasta &ldquo;CSP estricta funcional&rdquo;, sin pasar por el infierno del soporte llamando porque el admin no carga.</p>
<h2 id="que-hace-csp-en-60-segundos">Qué hace CSP, en 60 segundos</h2>
<p>La política se envía como cabecera HTTP (<code>Content-Security-Policy: ...</code>) o como <code>&lt;meta&gt;</code>. Cada directiva define qué orígenes están autorizados para cargar un tipo de recurso:</p>
<ul>
<li><code>script-src 'self'</code> — sólo JS del mismo origen.</li>
<li><code>script-src 'self' https://www.googletagmanager.com</code> — JS del mismo origen + GTM.</li>
<li><code>script-src 'self' 'sha256-abc...'</code> — JS del mismo origen + un script inline cuyo contenido tiene ese hash.</li>
<li><code>script-src 'self' 'nonce-XYZ'</code> — JS del mismo origen + scripts inline marcados con <code>nonce="XYZ"</code>.</li>
</ul>
<p>Cuando el browser ve un recurso que no encaja, lo bloquea y opcionalmente reporta. <strong>Mientras más estricta la política, mejor protege</strong>, pero también más fácil es que algo deje de funcionar. El truco está en saber qué relajar y qué mantener.</p>
<h2 id="por-que-wordpress-la-rompe">Por qué WordPress la rompe</h2>
<p>WP es un campo minado para CSP por cinco razones, todas estructurales:</p>
<ol>
<li><strong>Inline scripts en el admin.</strong> El bloque <code>wp-admin/</code> está lleno de <code>&lt;script&gt;</code> inline con configuración por request (nonces, <code>userSettings</code>, traducciones). Sin <code>'unsafe-inline'</code> ni hashes/nonces, el panel deja de funcionar.</li>
<li><strong>Plugins que inyectan inline.</strong> Yoast, Elementor, WooCommerce, RankMath, varios plugins de analítica y ads inyectan <code>&lt;script&gt;</code> inline con datos por request. El contenido cambia entre páginas — los hashes estáticos no sirven.</li>
<li><strong><code>wp_add_inline_script</code> y <code>wp_localize_script</code>.</strong> WordPress mismo expone APIs para que los plugins inyecten configuración inline. El core lo hace, los themes lo hacen.</li>
<li><strong>jQuery y bundled libraries.</strong> WP bundlea jQuery, jQuery UI, Underscore, Backbone, etc. Si tu theme los usa con <code>?ver=</code> cachebuster, vas a tener URLs ligeramente distintas por release.</li>
<li><strong>CDNs de terceros.</strong> Google Fonts, Font Awesome, Bootstrap CDN, embeds (YouTube, Vimeo, Maps), pasarelas de pago, reCAPTCHA, livechat — todos suman orígenes externos.</li>
</ol>
<p>La conclusión práctica: una CSP estricta para WordPress <strong>requiere conocer qué carga tu instalación específica</strong>. No hay una política universal copy-paste que funcione bien.</p>
<h2 id="estrategia-report-only-primero-enforce-despues">Estrategia: Report-Only primero, enforce después</h2>
<p>El error más caro es publicar la CSP en modo enforcement sin medir antes. Si te equivocas, el sitio se cae para los visitantes (o peor: para los admins, y vuelves a quedar bloqueado tú).</p>
<p>El header <code>Content-Security-Policy-Report-Only</code> hace <strong>exactamente lo mismo</strong> pero sin bloquear. El browser sólo reporta las violaciones que habrían ocurrido. Con eso medimos durante 1-2 semanas qué se rompería con la política propuesta, y la ajustamos antes de enforcement.</p>
<p>Flujo recomendado:</p>
<ol>
<li>Diseñar una política propuesta (más estricta de lo que crees que tolera el sitio).</li>
<li>Configurar un endpoint para recibir reports.</li>
<li>Publicar la política en modo <code>Report-Only</code> durante ~2 semanas.</li>
<li>Revisar los reports, ajustar la política para cubrir falsos positivos legítimos.</li>
<li>Pasar a enforce (<code>Content-Security-Policy</code>) cuando los reports queden estables.</li>
</ol>
<h3 id="endpoint-de-reports">Endpoint de reports</h3>
<p>Lo mínimo: un script que reciba el JSON POST y lo escriba a log. Si no quieres mantener uno, <a href="https://report-uri.com/">report-uri.com</a> ofrece un free tier suficiente. Para WordPress, un endpoint custom en un plugin propio basta:</p>
<div class="post-code"><pre><span></span><code><span class="x">add_action(&#39;rest_api_init&#39;, function () {</span>
<span class="x">    register_rest_route(&#39;csp/v1&#39;, &#39;/report&#39;, [</span>
<span class="x">        &#39;methods&#39;  =&gt; &#39;POST&#39;,</span>
<span class="x">        &#39;callback&#39; =&gt; function ($request) {</span>
<span class="x">            $body = $request-&gt;get_body();</span>
<span class="x">            error_log(&#39;[CSP] &#39; . $body, 3, WP_CONTENT_DIR . &#39;/csp-reports.log&#39;);</span>
<span class="x">            return new WP_REST_Response(null, 204);</span>
<span class="x">        },</span>
<span class="x">        &#39;permission_callback&#39; =&gt; &#39;__return_true&#39;,</span>
<span class="x">    ]);</span>
<span class="x">});</span>
</code></pre></div>

<p>Y en la cabecera: <code>report-uri https://tu-sitio.cl/wp-json/csp/v1/report;</code>. Para CSP 3, también: <code>report-to csp-endpoint;</code> con el grupo definido vía <code>Report-To</code> (más nuevo, no todos los browsers lo soportan aún — mantener ambos por un tiempo).</p>
<h2 id="identificar-inline-scripts-legitimos">Identificar inline scripts legítimos</h2>
<p>Para no caer en <code>'unsafe-inline'</code>, necesitas que los inline scripts que sí son legítimos pasen. Hay tres formas:</p>
<h3 id="hashes">Hashes</h3>
<p><code>'sha256-&lt;base64&gt;'</code>. Funciona bien para <strong>bloques fijos</strong>, ejemplo: un <code>&lt;script type="application/ld+json"&gt;</code> con tu Schema.org. Mientras el contenido no cambie, el hash sirve.</p>
<p>Para calcular el hash de un bloque (Python):</p>
<div class="post-code"><pre><span></span><code><span class="kn">import</span> <span class="nn">hashlib</span><span class="o">,</span> <span class="nn">base64</span>
<span class="n">script</span> <span class="o">=</span> <span class="s1">&#39;...&#39;</span>  <span class="c1"># contenido exacto entre &lt;script&gt;...&lt;/script&gt;, sin las tags</span>
<span class="nb">print</span><span class="p">(</span><span class="s1">&#39;sha256-&#39;</span> <span class="o">+</span> <span class="n">base64</span><span class="o">.</span><span class="n">b64encode</span><span class="p">(</span><span class="n">hashlib</span><span class="o">.</span><span class="n">sha256</span><span class="p">(</span><span class="n">script</span><span class="o">.</span><span class="n">encode</span><span class="p">())</span><span class="o">.</span><span class="n">digest</span><span class="p">())</span><span class="o">.</span><span class="n">decode</span><span class="p">())</span>
</code></pre></div>

<p>Limitación: si el bloque tiene cualquier cosa dinámica (timestamp, sessionId, nonce de form), el hash cambia por request y no sirve.</p>
<h3 id="nonces">Nonces</h3>
<p><code>'nonce-&lt;aleatorio-por-request&gt;'</code>. WordPress puede emitir un nonce CSP en cada response, y los plugins que cooperan lo van pegando a sus inline scripts:</p>
<div class="post-code"><pre><span></span><code><span class="x">add_action(&#39;init&#39;, function () {</span>
<span class="x">    if (!defined(&#39;CSP_NONCE&#39;)) {</span>
<span class="x">        define(&#39;CSP_NONCE&#39;, base64_encode(random_bytes(16)));</span>
<span class="x">    }</span>
<span class="x">});</span>
</code></pre></div>

<p>Y luego en el <code>&lt;script&gt;</code> que inyectas: <code>&lt;script nonce="&lt;?php echo esc_attr(CSP_NONCE); ?&gt;"&gt;</code>. Cabecera: <code>script-src 'self' 'nonce-&lt;?php echo CSP_NONCE; ?&gt;';</code>.</p>
<p>El problema en WP: <strong>los plugins de terceros no usan tu nonce</strong>. Tienes que parchearlos uno por uno, o usar un plugin &ldquo;CSP Manager&rdquo; que intercepta <code>wp_add_inline_script</code> y le agrega el nonce. La práctica nuestra: vale la pena para inline <em>propio</em>, no perseguir cada plugin.</p>
<h3 id="strict-dynamic">strict-dynamic</h3>
<p>CSP 3 introduce <code>'strict-dynamic'</code>: si un script con nonce/hash crea otros scripts, los hijos heredan la confianza automáticamente. Es la dirección recomendada por el W3C para apps modernas. En WordPress es complicado porque WP no fue diseñado pensando en esto — el nonce tiene que llegar a todo el árbol de carga, lo que rara vez es práctico.</p>
<h2 id="un-header-funcional-para-un-wp-tipico">Un header funcional para un WP típico</h2>
<p>Suponiendo un WP institucional con Yoast, Cloudflare, Google Analytics y un form de contacto. La política mínima estricta razonable:</p>
<div class="post-code"><pre><span></span><code>Content-Security-Policy:
  default-src &#39;self&#39;;
  base-uri &#39;self&#39;;
  object-src &#39;none&#39;;
  frame-ancestors &#39;self&#39;;
  form-action &#39;self&#39;;
  img-src &#39;self&#39; data: https://secure.gravatar.com https://*.googleusercontent.com;
  font-src &#39;self&#39; https://fonts.gstatic.com data:;
  style-src &#39;self&#39; &#39;unsafe-inline&#39; https://fonts.googleapis.com;
  script-src &#39;self&#39; &#39;nonce-XYZ&#39; https://www.googletagmanager.com https://www.google-analytics.com;
  connect-src &#39;self&#39; https://www.google-analytics.com https://region1.google-analytics.com;
  frame-src &#39;self&#39; https://www.youtube-nocookie.com;
  report-uri /wp-json/csp/v1/report;
  upgrade-insecure-requests;
</code></pre></div>

<p>Notas sobre cada directiva:</p>
<ul>
<li><strong><code>default-src 'self'</code></strong>: catch-all conservador. Cualquier tipo no listado cae acá.</li>
<li><strong><code>base-uri 'self'</code></strong> y <strong><code>form-action 'self'</code></strong>: previenen ataques de manipulación del <code>&lt;base&gt;</code> y de redirección de submits.</li>
<li><strong><code>object-src 'none'</code></strong>: nadie usa <code>&lt;object&gt;</code> / <code>&lt;embed&gt;</code> legítimamente en 2026. Cerrarlo.</li>
<li><strong><code>frame-ancestors 'self'</code></strong>: reemplaza el viejo <code>X-Frame-Options: SAMEORIGIN</code>. Si tu sitio nunca se embebe, mejor <code>'none'</code>.</li>
<li><strong><code>img-src 'self' data:</code></strong>: el <code>data:</code> se necesita para imágenes inline base64 que algunos plugins usan. Los hosts adicionales son típicos de Gravatar y Google sign-in avatars.</li>
<li><strong><code>style-src 'self' 'unsafe-inline'</code></strong>: lamentablemente WordPress y la mayoría de los themes inyectan estilos inline. Por ahora <code>'unsafe-inline'</code> queda. Una versión más estricta usa nonces para <code>&lt;style&gt;</code> también, pero el ROI es bajo (XSS via CSS exfil existe pero es marginal).</li>
<li><strong><code>script-src 'self' 'nonce-XYZ'</code></strong> + hosts GTM/GA: la línea más sensible. Aquí es donde el modo Report-Only te dice qué más necesitas.</li>
<li><strong><code>connect-src</code></strong>: dónde puede hacer <code>fetch</code>/<code>XMLHttpRequest</code>/<code>WebSocket</code>. GA usa <code>region1.google-analytics.com</code> para EU/CL traffic — no olvidar.</li>
<li><strong><code>frame-src</code></strong>: si embebes YouTube, usar <code>youtube-nocookie.com</code> (privacidad-first). Para Vimeo: <code>https://player.vimeo.com</code>.</li>
<li><strong><code>upgrade-insecure-requests</code></strong>: si algún <code>src="http://..."</code> quedó hardcoded, el browser lo upgradea automáticamente. Útil mientras limpias el sitio.</li>
</ul>
<p><strong>Lo que no está</strong>: <code>'unsafe-inline'</code> en <code>script-src</code>. Esa es la línea que más cuesta defender ante un equipo de devs apurados, pero también la que da el 80% del valor del header.</p>
<h2 id="trampas-comunes-en-wordpress">Trampas comunes en WordPress</h2>
<h3 id="el-admin-se-cae-sin-unsafe-inline">El admin se cae sin <code>'unsafe-inline'</code></h3>
<p>Casi inevitable. La solución pragmática: aplicar dos políticas distintas según el path. En Apache:</p>
<div class="post-code"><pre><span></span><code><span class="nt">&lt;LocationMatch</span><span class="w"> </span><span class="s">&quot;^/wp-admin&quot;</span><span class="nt">&gt;</span>
<span class="w">    </span><span class="nb">Header</span><span class="w"> </span>always<span class="w"> </span>set<span class="w"> </span>Content-Security-Policy<span class="w"> </span><span class="s2">&quot;default-src &#39;self&#39;; script-src &#39;self&#39; &#39;unsafe-inline&#39; &#39;unsafe-eval&#39;; style-src &#39;self&#39; &#39;unsafe-inline&#39;; img-src &#39;self&#39; data: https:; font-src &#39;self&#39; data:; frame-ancestors &#39;self&#39;&quot;</span>
<span class="nt">&lt;/LocationMatch&gt;</span>

<span class="nt">&lt;LocationMatch</span><span class="w"> </span><span class="s">&quot;^/(?!wp-admin)&quot;</span><span class="nt">&gt;</span>
<span class="w">    </span><span class="nb">Header</span><span class="w"> </span>always<span class="w"> </span>set<span class="w"> </span>Content-Security-Policy<span class="w"> </span><span class="s2">&quot;[política estricta del frontend]&quot;</span>
<span class="nt">&lt;/LocationMatch&gt;</span>
</code></pre></div>

<p>El admin queda con una política más laxa, pero los visitantes anónimos (99% del tráfico) reciben la estricta. Es un trade-off aceptable mientras planificas migrar el admin a nonces.</p>
<h3 id="google-tag-manager-carga-otros-scripts">Google Tag Manager carga otros scripts</h3>
<p>GTM es un container que carga scripts dinámicamente (Analytics, Ads, Hotjar, Facebook Pixel, …). Cada tag agregada en GTM añade un origen al <code>script-src</code> y al <code>connect-src</code>. La práctica nuestra: cada vez que el equipo de marketing agrega una tag en GTM, recibimos el report y agregamos el host. Si esto se vuelve un dolor crónico, vale la pena usar <code>'strict-dynamic'</code> con nonce en GTM (Google lo soporta).</p>
<h3 id="recaptcha-v3">reCAPTCHA v3</h3>
<p>Necesita: <code>script-src https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/</code>, <code>frame-src https://www.google.com/recaptcha/</code>. Si lo olvidas, los forms con captcha quedan rotos sin error visible (el captcha simplemente no carga).</p>
<h3 id="embeds-youtube-vimeo-maps-soundcloud">Embeds (YouTube, Vimeo, Maps, SoundCloud)</h3>
<p>Cada uno agrega <code>frame-src</code> y muchas veces <code>script-src</code>/<code>connect-src</code>. La regla: <strong>prefiere el embed <code>nocookie</code> si existe</strong>. YouTube tiene <code>youtube-nocookie.com</code>, Vimeo respeta privacy mode. Reduce footprint de cookies y simplifica la política.</p>
<h3 id="stripe-webpay-khipu">Stripe / Webpay / Khipu</h3>
<p>Pasarelas chilenas: Webpay 3D Secure abre un iframe a <code>https://www.webpay.cl/</code>. Khipu API: <code>connect-src https://payment-api.khipu.com/</code>. Stripe Elements: <code>script-src https://js.stripe.com/</code>, <code>frame-src https://js.stripe.com/</code>. Documenta esto explícitamente en tus runbooks — son los que más cuesta diagnosticar.</p>
<h3 id="cloudflare-rocket-loader">Cloudflare Rocket Loader</h3>
<p>Si tienes Rocket Loader activado en Cloudflare, reescribe los <code>&lt;script&gt;</code> reales y los inyecta dinámicamente. Eso choca con CSP estricta. Dos opciones: deshabilitarlo (recomendado, su beneficio de performance es marginal con HTTP/2+Brotli activos) o marcar los scripts críticos con <code>data-cfasync="false"</code> para que no los toque.</p>
<h2 id="mantenimiento">Mantenimiento</h2>
<p>CSP no es &ldquo;ponla y olvídala&rdquo;. Cada nuevo plugin, cada feature nueva, cada tag de marketing puede romperla. La rutina mínima:</p>
<ul>
<li><strong>Revisión mensual</strong> del log de reports. Patrones nuevos → ajuste de política.</li>
<li><strong>Pre-deploy</strong>: si tocas plugins, themes o agregas un tag externo, deja la política nueva primero en <code>Report-Only</code>, valida 48h, luego enforce.</li>
<li><strong>Audit anual</strong> del header: ¿siguen siendo necesarios todos los hosts listados? Cada origen es superficie de ataque (si lo comprometen, sirven JS a tus visitantes).</li>
<li><strong>Alertas</strong>: agrega una métrica simple &ldquo;violaciones por hora&rdquo; en tu logging stack. Un spike de violaciones desde una IP particular es señal de un ataque XSS en curso siendo bloqueado.</li>
</ul>
<h2 id="como-lo-verificas">Cómo lo verificas</h2>
<p>El <a href="https://scan.asentic.cl/">Asentic FreeScan</a> revisa headers de seguridad incluido CSP. Detecta los tres patrones más comunes que vimos arriba: header ausente, header con <code>'unsafe-inline'</code> o <code>'unsafe-eval'</code>, y header sin <code>default-src</code> o sin <code>script-src</code> (que dejan el resto sin restricción). El reporte distingue entre &ldquo;no implementado&rdquo; y &ldquo;implementado pero ineficaz&rdquo; — porque el segundo caso es engañoso para auditores que sólo verifican presencia del header.</p>
<p>Para validación manual:</p>
<ul>
<li><strong><a href="https://csp-evaluator.withgoogle.com/">csp-evaluator.withgoogle.com</a></strong> — analiza tu política y marca debilidades específicas.</li>
<li><strong><code>curl -sI https://tu-sitio.cl/</code></strong> — confirma que el header está siendo enviado.</li>
<li><strong>DevTools del browser → Network → Response Headers</strong> — verifica la política en una request real (a veces hay middlewares que la sobreescriben).</li>
<li><strong>Console del browser</strong> — los bloqueos CSP aparecen como <code>Refused to execute inline script because it violates the following Content Security Policy directive…</code>.</li>
</ul>
<h2 id="cierre">Cierre</h2>
<p>CSP es una de esas herramientas que dan una ganancia de seguridad real con un costo de implementación que <strong>siempre subestimamos</strong>. Vale la pena pagarlo: cuando llegue el día en que un plugin se vea comprometido o un XSS pase la primera línea, el header es lo que decide entre un incidente menor y una filtración de datos.</p>
<p>Si tienes un WordPress en producción sin CSP, el primer paso es siempre el mismo: poner una política propuesta en <code>Report-Only</code>, dejarla correr una semana, y leer los reports. Todo lo demás sale de ahí.</p>]]></content>
    <category term="csp"/>
    <category term="wordpress"/>
    <category term="headers"/>
    <category term="xss"/>
    <category term="hardening"/>
  </entry>
</feed>