J’ai donné aujourd’hui une conférence à l’API Platform Con de Lille, sur les en-têtes HTTP comme première ligne de défense. La voici par écrit, avec la configuration, parce qu’une diapositive photographiée dans une salle sombre ne se colle pas dans un projet.
L’argument tient sur un écran. Voici une réponse d’API ordinaire, celle que votre application envoie déjà :
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 284
Cache-Control: max-age=3600
Rien là-dedans ne dit quoi que ce soit au navigateur au sujet de la confiance. Et un navigateur qui n’a rien pour se guider est permissif par construction : il devinera un type de contenu, laissera un autre site encadrer la page, laissera fuir l’URL complète dans un référent, et exécutera le script que le balisage contient. Ce n’est pas un bogue, c’est le défaut d’une plateforme ouverte, et la seule chose qui le change est un en-tête.
Le socle, qui n’est pas négociable
Strict-Transport-Security: plus jamais de première requête en clair.X-Content-Type-Options: nosniff: le navigateur cesse de deviner ce que sont vos octets.X-Frame-Options, ou sa forme moderneframe-ancestors: personne ne vous encadre.Referrer-Policy: l’URL sur laquelle sont vos utilisateurs cesse de voyager chez des tiers.Permissions-Policy: caméra, micro et géolocalisation sont refusés avant que quoi que ce soit ne les demande.
Trois d’entre eux tiennent en un bloc YAML avec NelmioSecurityBundle :
nelmio_security:
clickjacking:
paths:
'^/.*': DENY
content_type:
nosniff: true
forced_ssl:
enabled: true
hsts_max_age: 31536000
hsts_subdomains: true
hsts_preload: true
Un cookie est un en-tête, lui aussi
Set-Cookie: __Host-SESSION=9f2a4c...; path=/; secure; httponly; samesite=lax
La partie intéressante est le préfixe. secure, httponly et samesite sont des promesses que votre application fait et que rien ne vérifie. __Host- inverse qui contrôle : le navigateur refuse le cookie tout net s’il n’est pas sécurisé, si le chemin n’est pas /, ou s’il porte un attribut de domaine. Un sous-domaine ne peut plus planter un cookie que votre application relira.
La CSP, et les deux mots à ne jamais écrire
nelmio_security:
csp:
hash:
algorithm: sha256
enforce:
default-src: [ 'none' ]
script-src: [ 'self', 'cdn.example.com/app.v4.js' ]
style-src: [ 'self' ]
img-src: [ 'self', 'data:' ]
frame-ancestors: [ 'none' ]
Partez de default-src 'none' et ouvrez ce dont vous avez besoin, plutôt que l’inverse. Épinglez un fichier plutôt qu’un CDN entier : autoriser cdn.example.com autorise tous les fichiers que quiconque y a jamais déposés, ce qui sur un CDN mutualisé n’est pas une courte liste.
Et jamais unsafe-inline ni unsafe-eval. Une politique qui porte l’un des deux est une politique qui arrête un attaquant qui n’essayait pas. Employez un jeton par requête ou une empreinte, que le bundle engendre pour vous dans les deux cas.
CORS et CORP ne répondent pas à la même question
C’est la confusion sur laquelle la conférence a passé le plus de temps. CORS décide qui peut lire votre réponse quand un script la demande. CORP décide qui peut incorporer votre ressource tout court, script ou pas. L’un parle d’une récupération et de sa permission, l’autre de la ressource elle-même et de qui a le droit de pointer dessus.
nelmio_security:
cross_origin_isolation:
enabled: true
paths:
# First match wins, so the specific pattern goes first.
'^/assets/public':
corp: cross-origin
'^/.*':
corp: same-site
Le commentaire signale l’erreur de loin la plus fréquente avec une configuration par chemins, dans ce bundle comme dans tous ceux qui comparent des chemins dans l’ordre : la première correspondance l’emporte. Mettez le motif attrape-tout en premier et tout ce qui est en dessous devient de la configuration morte, en silence.
L’isolation se demande, et la plupart des sites n’en ont pas besoin
COOP et COEP réunis placent votre page dans un groupe de contextes de navigation qui lui est propre, ce que le navigateur exige avant de rendre SharedArrayBuffer, les minuteries de haute résolution et la mesure de mémoire. C’est la réponse à Spectre, et c’est un coût réel : chaque ressource multi-origine que vous chargez doit alors dire oui, par CORP ou par CORS.
Activez-la donc quand vous avez besoin de ce qu’elle débloque, et pas avant. Un site statique ou une application ordinaire s’arrête au socle. Et une API n’a rien à faire avec COOP ni COEP : ils s’appliquent aux documents et aux contextes de navigation, ce qu’une réponse d’API n’est pas.
Le déployer sans casser le site
En mode rapport d’abord. La CSP comme le COEP ont un jumeau en mode rapport qui signale ce qui aurait été bloqué sans rien bloquer, et lire ces rapports pendant une semaine est la façon de trouver le script en ligne dont plus personne ne se souvenait.
Puis verrouillez. Un scanner comme securityheaders.com vous dit ce qui est vrai aujourd’hui ; un test vous dit que ce l’est encore après le prochain remaniement. NelmioSecurityBundle fournit un trait PHPUnit pour exactement cela :
use Nelmio\SecurityBundle\Test\SecurityHeadersAssertionsTrait;
final class SecurityHeadersTest extends WebTestCase
{
use SecurityHeadersAssertionsTrait;
public function testTheHeadersAreThere(): void
{
static::createClient()->request('GET', '/');
static::assertIsIsolated();
static::assertFrameOptions('DENY');
static::assertContentTypeOptions();
static::assertStrictTransportSecurity();
static::assertCspHeader();
}
}
assertIsIsolated() vérifie COOP, COEP et CORP en un appel, et les autres vérifient chacune leur en-tête, assertCspHeader() allant jusqu’aux valeurs de directives exigées et proscrites.
La même réponse, armée
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 284
Cache-Control: max-age=3600
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()
Content-Security-Policy: default-src 'none'; frame-ancestors 'none'
Cross-Origin-Resource-Policy: same-site
Vary: Origin
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Expose-Headers: Link, ETag, Location
Set-Cookie: __Host-SESSION=9f2a4c...; path=/; secure; httponly; samesite=lax
Même réponse, même charge utile, mêmes quatre lignes en haut. Tout ce qui a été ajouté est de la configuration sur une réponse que vous envoyiez déjà : pas un point de terminaison de plus, pas une requête changée, pas une bibliothèque ajoutée.
Voilà pour la conférence. Chacun de ces en-têtes a plus à dire qu’une diapositive ne le permet, alors ils auront leurs propres articles ici : le socle, les préfixes de cookies, la CSP sans unsafe-inline, CORS face à CORP, l’isolation et ce qu’elle coûte vraiment, et la différence entre même origine et même site, où logent les erreurs coûteuses.