La politique de même origine, et ce que CORS assouplit
Une origine réunit trois éléments : le schéma, l'hôte et le port. https://shop.example.com et https://api.example.com sont deux origines différentes, tout comme http://example.com et https://example.com. Le chemin ne compte pas.
Les navigateurs appliquent la politique de même origine (same-origin policy) : le JavaScript d'une origine peut envoyer des requêtes vers une autre, mais il ne peut pas lire la réponse sans l'accord de l'autre côté. La même règle couvre les polices web chargées depuis un autre hôte et la relecture des pixels d'une image d'une autre origine dessinée dans un canvas.
Beaucoup de choses restent permises sans accord. Une page peut intégrer une image, une feuille de style ou un script venant de n'importe où, et un formulaire peut poster vers n'importe quel site. Ce que la politique protège, c'est la lecture : sans elle, n'importe quelle page ouverte pourrait appeler en silence votre webmail ou l'API de votre banque avec vos cookies et lire la réponse.
CORS (Cross-Origin Resource Sharing) est cet accord. Le navigateur indique au serveur quelle origine demande, dans l'en-tête de requête Origin, et le serveur répond avec des en-têtes Access-Control-* qui disent quelles origines peuvent lire la réponse. Rien dans CORS ne bloque la requête côté serveur : c'est le navigateur qui retient la réponse au lieu de la remettre à la page.
Requêtes simples et requêtes preflight
Les requêtes simples partent directement. Un GET, un HEAD ou un POST est simple lorsqu'il ne porte que des en-têtes de la liste sûre de CORS, comme Accept, Accept-Language et Content-Language, et, s'il a un corps, un Content-Type text/plain, multipart/form-data ou application/x-www-form-urlencoded. Le navigateur l'envoie avec un en-tête Origin, reçoit la réponse et ne la remet à la page que si Access-Control-Allow-Origin autorise cette origine.
Tout le reste passe d'abord par un preflight : une requête OPTIONS qui demande la permission avant l'envoi de la vraie. Elle indique la méthode dans Access-Control-Request-Method et les autres en-têtes dans Access-Control-Request-Headers. Le serveur doit répondre avec un statut 2xx, les en-têtes Access-Control-Allow-* correspondants et sans redirection. Alors seulement le navigateur envoie la requête réelle.
La surprise habituelle : une API JSON déclenche un preflight presque à chaque appel. Content-Type: application/json n'est pas sur la liste sûre, Authorization non plus, donc un fetch qui poste du JSON avec un jeton bearer paie un aller-retour de plus, sauf si le navigateur a encore la réponse du preflight en cache. C'est le rôle de Access-Control-Max-Age : Chromium garde cette réponse au plus deux heures et Firefox au plus 24, et sans l'en-tête le navigateur la conserve cinq secondes.
Un preflight et la réponse qui laisse passer la vraie requête
OPTIONS /v1/orders HTTP/1.1
Host: api.example.com
Origin: https://shop.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: authorization, content-type
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://shop.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 7200
Vary: Origin
Les en-têtes Access-Control, un par un
Access-Control-Allow-Origin est celui qui décide. Il prend exactement une valeur : * pour toute origine, une seule origine comme https://shop.example.com, ou null. Une liste séparée par des virgules n'est pas valide, et null ne doit jamais être autorisé, car les iframes en sandbox et les fichiers locaux envoient aussi Origin: null.
Access-Control-Allow-Methods et Access-Control-Allow-Headers répondent au preflight : quelles méthodes et quels en-têtes de requête la vraie requête peut utiliser. Access-Control-Max-Age dit combien de secondes le navigateur peut réutiliser cette réponse.
Access-Control-Allow-Credentials: true permet à la page de lire la réponse à une requête envoyée avec des cookies ou une authentification HTTP. Les règles qui l'accompagnent sont dans la section suivante.
Access-Control-Expose-Headers liste les en-têtes de réponse que le JavaScript de la page peut lire. Sans lui, un script ne voit que Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified et Pragma ; un X-Request-Id maison, ou l'ETag dont une bibliothèque d'envoi a besoin, reste invisible.
Côté requête, il y a trois en-têtes, tous écrits par le navigateur et impossibles à falsifier depuis le code de la page : Origin, Access-Control-Request-Method et Access-Control-Request-Headers.
Credentials : cookies, Authorization et pourquoi * cesse de fonctionner
Une requête porte des credentials lorsqu'elle inclut des cookies ou une authentification HTTP : fetch avec credentials: 'include', ou XMLHttpRequest avec withCredentials = true. Pour ces requêtes, le navigateur durcit toutes les règles.
Access-Control-Allow-Origin doit nommer l'origine exacte ; * est refusé. Access-Control-Allow-Credentials: true doit être présent. Et un * dans Allow-Headers, Allow-Methods ou Expose-Headers n'est plus un joker : il est lu comme un en-tête littéralement nommé *.
Comme une réponse ne peut nommer qu'une origine, un serveur avec plusieurs front-ends tient une liste d'autorisation, compare l'Origin reçu avec elle et renvoie celle qui correspond. La réponse dépend désormais d'un en-tête de requête, donc chaque réponse, y compris celles sans aucun en-tête CORS, doit aussi indiquer Vary: Origin ; sinon un cache donne la réponse d'une origine à une autre.
L'erreur classique consiste à renvoyer n'importe quelle origine tout en autorisant les credentials. Cela permet à tous les sites de lire les données de vos utilisateurs connectés à travers leur propre navigateur. C'est la première chose que signalent les scanners de sécurité, et cela relève du contrôle d'accès défaillant du OWASP Top 10. Les cookies ont en plus leur propre barrière : un cookie ne voyage sur une requête cross-site que s'il a été posé avec SameSite=None et Secure.
Une liste d'autorisation qui renvoie l'origine (Node.js, Express)
const allowed = new Set([
'https://shop.example.com',
'https://admin.example.com',
]);
app.use((req, res, next) => {
const origin = req.headers.origin;
res.vary('Origin');
if (allowed.has(origin)) {
res.set('Access-Control-Allow-Origin', origin);
res.set('Access-Control-Allow-Credentials', 'true');
}
if (req.method === 'OPTIONS') {
res.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type');
res.set('Access-Control-Max-Age', '7200');
return res.sendStatus(204);
}
next();
});
Les erreurs CORS courantes et ce qu'elles signifient
Le message de la console nomme la règle qui a échoué. Lisez-le avec l'onglet Network ouvert : le code de statut de la requête en échec raconte généralement la vraie histoire. Les messages ci-dessous sont les formulations de Chromium.
No 'Access-Control-Allow-Origin' header is present on the requested resource. La réponse est arrivée sans l'en-tête. Le plus souvent, c'est une réponse d'erreur, un 404, un 500, une redirection vers la page de connexion ou le 403 d'un pare-feu, produite par du code qui n'atteint jamais votre gestion de CORS. Corrigez d'abord le statut, puis faites en sorte que les réponses d'erreur portent aussi l'en-tête, pour que la prochaine panne montre sa vraie cause.
The 'Access-Control-Allow-Origin' header contains multiple values '*, *', but only one is allowed. Deux couches ajoutent l'en-tête : en général l'application et un proxy ou un CDN placé devant. N'en gardez qu'une.
Response to preflight request doesn't pass access control check: It does not have HTTP ok status. La requête OPTIONS a reçu un 401, 403, 404 ou 405. La cause habituelle est une authentification exécutée avant CORS : un preflight ne porte jamais votre en-tête Authorization, il doit donc recevoir sa réponse avant tout contrôle de connexion.
Redirect is not allowed for a preflight request. L'URL redirige : de http vers https, une barre oblique finale manquante, un autre hôte. Appelez directement l'URL finale.
The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include'. La page envoie des cookies et le serveur répond *. Renvoyez l'origine exacte et ajoutez Access-Control-Allow-Credentials: true, ou cessez d'envoyer des credentials.
Request header field authorization is not allowed by Access-Control-Allow-Headers in preflight response. Ajoutez l'en-tête cité à Access-Control-Allow-Headers.
Une origine null signifie que la page a été ouverte depuis file:// ; servez-la depuis un serveur web local. Et une « solution » à éviter : mode: 'no-cors' fait taire l'erreur en donnant à votre code une réponse opaque qu'il ne peut pas lire. La requête part, votre script ne reçoit rien.
CORS derrière un CDN : cache et Vary: Origin
Un cache stocke une réponse par URL et la sert à tous ceux qui demandent cette URL. Des en-têtes CORS qui changent selon la requête cassent cette hypothèse de deux façons.
Si le serveur d'origine n'ajoute Access-Control-Allow-Origin que lorsqu'un en-tête Origin est présent, c'est la première requête qui décide de ce qui est mis en cache. Si cette première requête venait d'une simple balise <img> ou d'une visite directe, la copie en cache n'a pas d'en-tête CORS, et chaque fetch cross-origin suivant échoue jusqu'à son expiration. L'erreur semble aléatoire, parce qu'elle dépend de qui est passé en premier.
Si le serveur d'origine renvoie l'origine qui demande, la copie en cache nomme une seule origine, et un second site qui lit la même URL reçoit une réponse adressée à quelqu'un d'autre.
Il existe deux conceptions propres. Pour les fichiers publics, polices, images, scripts et JSON public, envoyez le même en-tête à tout le monde : * ou l'origine de votre site unique. Chaque copie en cache est alors juste pour chaque visiteur et le taux de hit reste intact. Quand la réponse doit varier selon l'origine, envoyez Vary: Origin sur chaque réponse de cette URL pour que le cache sépare les variantes. Cela coûte un peu de taux de hit, réservez-le donc aux chemins qui en ont besoin.
Les navigateurs ont aussi un cache. Une image chargée d'abord par une simple balise <img>, puis demandée avec l'attribut crossorigin, peut sortir du cache du navigateur sans l'en-tête. Les deux mêmes conceptions règlent ce cas.
Les en-têtes CORS sur CDN.com.tr
Pour les fichiers servis par le CDN, la périphérie peut ajouter l'en-tête elle-même. Dans le panneau, ouvrez Règles de diffusion, modifiez la règle qui couvre le chemin (par exemple /fonts/ ou /static/) et écrivez un en-tête par ligne dans En-têtes personnalisés, sous la forme Nom-En-tête valeur (sur la page des règles à onglets : En-têtes → Ajouter des en-têtes de réponse). Une valeur qui contient des espaces se met entre guillemets doubles.
La périphérie ajoute ces en-têtes aux réponses réussies et de redirection (2xx et 3xx), réponses servies depuis le cache comprises, dès la publication de la règle. Le panneau et la périphérie refusent ;, { et } dans une ligne d'en-tête ; un en-tête CORS n'en a jamais besoin, puisque Access-Control-Allow-Origin ne prend qu'une valeur.
Si votre serveur d'origine envoie déjà un en-tête Access-Control-* pour ces fichiers, sélectionnez le même nom dans Masquer les En-têtes de cette règle. Sinon, le navigateur reçoit deux valeurs et les rejette toutes les deux.
Deux choses restent du ressort de votre serveur d'origine. D'abord les preflights : la périphérie transmet les requêtes OPTIONS à votre serveur et ne les met jamais en cache, donc une API qui a besoin de preflights y répond elle-même. Les simples GET de polices, d'images et de JSON public n'ont pas besoin de preflight, et l'en-tête de la règle leur suffit. Ensuite les réponses qui varient selon l'origine : renvoyer l'une de plusieurs origines autorisées, comme l'exigent les requêtes avec credentials, relève de votre application. La périphérie garde ces variantes séparées quand votre serveur d'origine envoie Vary: Origin, tant que la règle n'est pas réglée pour ignorer Vary. Sur une règle avec optimisation d'images, les images JPEG et PNG passent par l'optimiseur, qui pose son propre Vary ; donnez aux images un en-tête fixe.
Si le WAF du site est activé, OPTIONS fait partie par défaut de ses méthodes autorisées ; PUT, PATCH et DELETE doivent être cochées dans Méthodes HTTP autorisées. Une requête refusée par la périphérie reçoit un 403 sans en-têtes CORS, que le navigateur signale comme une erreur CORS. L'en-tête X-MT-Blocked-By: cdn-edge-security de ce 403 vous indique que c'est la périphérie, et non votre application, qui a dit non.
En-têtes personnalisés sur une règle pour /fonts/ ou /static/
Access-Control-Allow-Origin *
Access-Control-Expose-Headers "Content-Length, ETag"
CORS sur les buckets du stockage objet
Les fichiers qu'un navigateur envoie directement dans un bucket, par exemple avec un PUT pré-signé depuis une application web, reçoivent leur réponse du service de stockage et non de votre application ; le bucket a donc besoin de ses propres règles CORS.
Le stockage objet de CDN.com.tr les accepte via l'appel S3 standard PutBucketCors, sur https://s3.cdn.com.tr, et répond lui-même aux preflights du navigateur. Tout outil S3 capable de définir le CORS d'un bucket convient ; avec l'AWS CLI, c'est une commande et un petit fichier JSON.
Pour tout ce qui écrit, listez des origines exactes plutôt que *, et exposez ETag : les bibliothèques d'envoi côté navigateur le lisent pour terminer les envois multipart. CORS ne rend pas non plus un bucket public. Un bucket privé répond toujours 403 aux lectures anonymes ; CORS décide seulement si une page peut lire une réponse qu'elle avait déjà le droit d'obtenir.
Le CORS d'un bucket avec l'AWS CLI
# cors.json
{
"CORSRules": [{
"AllowedOrigins": ["https://app.example.com"],
"AllowedMethods": ["GET", "PUT"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3600
}]
}
aws s3api put-bucket-cors --bucket my-bucket \
--cors-configuration file://cors.json \
--endpoint-url https://s3.cdn.com.tr
aws s3api get-bucket-cors --bucket my-bucket \
--endpoint-url https://s3.cdn.com.tr
Tester CORS en ligne de commande
curl n'applique pas CORS, ce qui en fait le bon outil pour voir ce que dit réellement un serveur. Envoyez vous-même l'en-tête Origin et lisez les en-têtes Access-Control-* qui reviennent ; pour un preflight, envoyez OPTIONS avec les deux en-têtes qu'ajouterait le navigateur.
Vérifiez trois choses : un statut 2xx sur le preflight, exactement un Access-Control-Allow-Origin, et Vary: Origin partout où la valeur change selon l'origine. Répétez ensuite la requête via le CDN et comparez. Sur CDN.com.tr, l'en-tête X-Proxy-Cache-MT indique si la réponse vient du cache ; si la réponse en cache diffère de la première, c'est le problème de cache décrit plus haut.
# a simple request: what does the edge answer for this origin?
curl -sI https://cdn.example.com/fonts/inter.woff2 \
-H 'Origin: https://www.example.com' \
| grep -i -E 'access-control|vary|x-proxy-cache'
# a preflight, as the browser would send it
curl -si -X OPTIONS https://api.example.com/v1/orders \
-H 'Origin: https://shop.example.com' \
-H 'Access-Control-Request-Method: PUT' \
-H 'Access-Control-Request-Headers: authorization, content-type'
Questions fréquentes sur CORS
CORS protège-t-il mon API ?
Non. CORS protège le navigateur de vos utilisateurs contre les pages qui tentent de lire des données en leur nom. Il ne fait rien contre un script, un serveur ou curl qui appelle directement votre API, et les requêtes simples atteignent votre serveur même si le navigateur cache ensuite la réponse. Votre API a toujours besoin de sa propre authentification et de ses propres autorisations.
Access-Control-Allow-Origin: * est-il sûr ?
Pour des ressources publiques qui ne demandent pas de connexion, polices, images, scripts et JSON public, oui : n'importe qui pourrait les télécharger de toute façon. Il ne convient pas à ce qui dépend des cookies ou de la session de l'utilisateur, et les navigateurs refusent de le combiner avec des credentials.
Comment autoriser plusieurs origines ?
Pas avec une liste : l'en-tête ne prend qu'une valeur. Tenez une liste d'autorisation sur le serveur, renvoyez l'Origin de la requête quand il y figure, et envoyez Vary: Origin sur chaque réponse pour que les caches gardent les réponses séparées.
Pourquoi la requête fonctionne-t-elle dans Postman ou curl mais pas dans le navigateur ?
Parce que seuls les navigateurs appliquent CORS. Postman et curl envoient la requête et affichent ce qui revient ; le navigateur vérifie les en-têtes Access-Control-* avant de laisser votre JavaScript voir la réponse.
Pourquoi l'erreur CORS n'apparaît-elle que de temps en temps ?
En général à cause d'un cache. Si la réponse change selon l'en-tête Origin sans indiquer Vary: Origin, la requête arrivée la première dans le cache décide de ce que reçoivent tous les autres. Envoyez un en-tête fixe pour les fichiers publics, ou Vary: Origin là où la réponse doit varier.
Comment activer CORS sur CDN.com.tr ?
Pour les fichiers servis par le CDN, ajoutez Access-Control-Allow-Origin dans les En-têtes personnalisés de la règle de diffusion ; les preflights de votre API et les réponses qui varient selon l'origine viennent de votre serveur d'origine. Pour les buckets du stockage objet, définissez les règles CORS avec l'appel S3 PutBucketCors sur https://s3.cdn.com.tr.