nginx: gli header di sicurezza che servono davvero

Gli header di sicurezza HTTP sono una delle poche misure di hardening che costano cinque righe di configurazione. Su nginx però c’è una trappola: le cinque righe ci sono, e in metà delle pagine il browser non le riceve. Prima la trappola, poi gli header uno per uno, poi come verificare.
La trappola: add_header non si somma, si sostituisce
La documentazione del modulo ngx_http_headers_module lo scrive chiaro, ma in una frase che si legge di corsa:
These directives are inherited from the previous configuration level if and only if there are no
add_headerdirectives defined on the current level.
Tradotto: gli add_header di un blocco server arrivano nelle location interne solo se quella location non ha nessun add_header suo. Basta una riga in più, anche innocua, e tutti quelli del livello sopra spariscono.
server {
listen 443 ssl;
server_name example.com;
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Content-Type-Options "nosniff" always;
location /assets/ {
add_header Cache-Control "public, max-age=2592000";
# da qui in giù HSTS e nosniff NON vengono più inviati
}
}
Chi aggiunge il Cache-Control sta pensando alla cache, non alla sicurezza, e nessun errore lo avvisa: nginx -t passa, il sito funziona. Mancano solo gli header.
Le soluzioni sono due.
La classica: mettere gli header di sicurezza in un file a parte e includerlo in ogni blocco che ha un suo add_header.
# /etc/nginx/snippets/security-headers.conf
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
location /assets/ {
include snippets/security-headers.conf;
add_header Cache-Control "public, max-age=2592000";
}
La nuova: dalla versione 1.29.3 (28 ottobre 2025), e quindi anche nel ramo stabile 1.30, esiste add_header_inherit. Con merge i valori del livello superiore vengono aggiunti a quelli del livello corrente invece di essere scartati:
http {
add_header_inherit merge;
# ...
}
La documentazione specifica che la regola stessa si eredita: scritta in http, vale in tutti i livelli annidati finché qualcuno non la ridefinisce. Prima di usarla, nginx -v: su una versione precedente la direttiva non esiste e la configurazione non si carica.
always, o gli header mancano proprio sugli errori
Senza parametri, add_header aggiunge l’header solo se il codice di risposta è uno di questi: 200, 201, 204, 206, 301, 302, 303, 304, 307, 308. Una pagina 404 o un 500 escono senza. Con always (disponibile dalla 1.7.5) l’header viene aggiunto «regardless of the response code». Per gli header di sicurezza va messo sempre.
Gli header, uno per uno
Strict-Transport-Security (HSTS)
Dice al browser che il sito va raggiunto solo in HTTPS, e che ogni tentativo futuro in HTTP va convertito automaticamente. max-age è in secondi: 31536000 è un anno.
Tre cose da sapere, tutte dalla documentazione MDN:
- Va nel blocco HTTPS. I browser ignorano l’header se arriva in HTTP, proprio per evitare che qualcuno nel mezzo lo alteri.
includeSubDomainsestende la regola a tutti i sottodomini. Se ne hai uno che risponde solo in HTTP (un vecchio gestionale, una stampante esposta, un servizio di terzi su un tuo sottodominio), smette di essere raggiungibile dai browser che hanno visto l’header.- Tornare indietro non è immediato. Per disattivarlo si manda
max-age=0, ma vale solo quando il browser fa una richiesta HTTPS e riceve la risposta; se l’header semplicemente sparisce, quello precedente «remains in effect until its expiration time».
Per questo conviene partire con un max-age breve, controllare che tutto funzioni e solo dopo allungarlo. Il preload (la lista integrata nei browser) richiede max-age di almeno un anno e includeSubDomains: è un impegno, non una casella da spuntare.
X-Content-Type-Options: nosniff
Dice al browser di fidarsi del Content-Type dichiarato invece di indovinarlo dal contenuto. In concreto blocca uno script servito con un tipo che non è JavaScript e un foglio di stile che non è text/css, e impedisce di trattare come HTML una risposta dichiarata come altro. È la protezione giusta per i siti che servono file caricati dagli utenti: un .txt con dentro dell’HTML resta testo. Non ha opzioni e non rompe niente su un server configurato bene: va messo.
Referrer-Policy
Decide quanto dell’indirizzo di provenienza viene inviato quando l’utente clicca un link o la pagina carica una risorsa esterna. Qui va detta una cosa che molte guide tacciono: il default dei browser è già strict-origin-when-cross-origin (origine, percorso e querystring verso il proprio sito; solo l’origine verso gli altri; niente verso HTTP). Scriverlo esplicitamente serve a non dipendere dal default, non cambia il comportamento.
Ha senso cambiarlo se nelle URL del sito ci sono informazioni che non devono uscire: in quel caso same-origin non manda nulla ai siti esterni. Da evitare unsafe-url, che MDN accompagna con un avviso esplicito: fa uscire percorso e querystring anche verso origini non sicure.
Content-Security-Policy, prima in Report-Only
La CSP dice al browser da dove la pagina può caricare script, stili, immagini, frame. È l’header più utile contro l’XSS e anche l’unico che può rompere il sito, perché per impostazione blocca gli script inline. MDN lo dice così: «Disallowing inline styles and inline scripts is one of the biggest security wins CSP provides». E un sito che non hai scritto tu, un WordPress con i suoi plugin per esempio, di script inline può averne molti senza che tu lo sappia.
Per questo si parte da Content-Security-Policy-Report-Only: il browser valuta la policy, non blocca niente e segnala le violazioni.
add_header Reporting-Endpoints 'csp-endpoint="https://example.com/csp-reports"' always;
add_header Content-Security-Policy-Report-Only "default-src 'self'; frame-ancestors 'self'; report-uri /csp-reports; report-to csp-endpoint" always;
report-to è la direttiva attuale, report-uri quella deprecata: MDN consiglia di dichiararle entrambe perché report-to non ha ancora supporto completo in tutti i browser. Senza un indirizzo dove riceverle, le segnalazioni non arrivano da nessuna parte: serve un endpoint che le raccolga, tuo o di un servizio esterno.
default-src 'self' fa da ripiego per le direttive che non scrivi. frame-ancestors invece non ripiega su default-src, va scritta a parte: decide chi può incorporare il sito in un iframe. Va mandata come header HTTP, perché nel tag <meta> non funziona.
Si raccolgono le violazioni per qualche settimana, si aggiunge alla policy ciò che è legittimo, e solo quando i report sono puliti si rinomina l’header in Content-Security-Policy. Fino a quel momento, ricordalo, la policy non protegge da niente, frame-ancestors compreso: è solo una prova.
server_tokens off
Non è un header da aggiungere ma uno da accorciare: il default è on, e nginx scrive la sua versione nell’header Server e nelle pagine di errore. server_tokens off; la toglie. Non rende sicuro un nginx non aggiornato. Evita solo di dire a tutti qual è.
Come verificare
Dal tuo computer, su una pagina normale e poi su una che passa da una location con un add_header suo, e infine su un indirizzo che non esiste (404):
curl -sI https://example.com/ | grep -iE 'strict-transport|x-content-type|referrer-policy|content-security'
curl -sI https://example.com/assets/style.css | grep -iE 'strict-transport|x-content-type'
curl -sI https://example.com/pagina-che-non-esiste | grep -iE 'strict-transport|x-content-type'
Se la seconda o la terza riga escono vuote, sei nella trappola di add_header o ti manca always.
Errori da evitare
- Aggiungere un
add_headerin unalocationsenza ripetere quelli di sicurezza (o senzaadd_header_inherit merge). È l’errore più comune e quello che non dà segni. - Dimenticare
always: le pagine di errore escono senza protezioni. - Mandare HSTS con
includeSubDomainse un anno dimax-agesenza aver controllato i sottodomini. - Passare subito a
Content-Security-Policyin modalità bloccante su un sito che non hai scritto tu: senza aver letto i report non sai cosa bloccherà. - Copiare blocchi di header da una guida vecchia: alcuni non servono più e altri sono deprecati. Ogni riga in configurazione va capita.
Se vuoi un controllo della configurazione del tuo server, o del sito che ci gira sopra, scrivici.
Fonti: nginx — ngx_http_headers_module ·
nginx — server_tokens ·
nginx CHANGES-1.30 ·
MDN: Strict-Transport-Security,
X-Content-Type-Options,
Referrer-Policy,
Content-Security-Policy-Report-Only,
frame-ancestors,
script-src.
