CodeGym /Cours /Spring Security /Origin et le rôle de CORS dans le navigateur

Origin et le rôle de CORS dans le navigateur

Spring Security
Niveau 12, Leçon 0
Disponible

1. Symptôme typique : « ça marche dans Postman, mais pas dans le navigateur »

Presque chaque développeur backend se retrouve un jour dans une situation où il est certain que l'endpoint est vivant, le contrôleur écrit, le service fonctionne, même la requête de test depuis Postman réussit — mais dès qu'un client navigateur apparaît (même le plus rudimentaire), tout se transforme soudainement en « CORS error » et « rien ne marche ». Et à ce moment-là, le débutant a la main qui se tend vers deux boutons : « désactiver Security » et « désactiver CSRF ». C'est une impulsion humaine compréhensible, mais c'est exactement ainsi qu'apparaissent les légendaires « temporary fixes » qui vivent en production pendant des années.

Avant cela, nous avions déjà le modèle classique navigateur/session : après la connexion, le navigateur transporte les cookies, et les requêtes modifiant l'état se heurtent à CSRF. Dès que la page vit sur un origin distinct — dans notre baseline local, c'est http://localhost:5173, tandis que l'API reste sur http://localhost:8080 — au-dessus des mêmes cookies et CSRF, une nouvelle couche apparaît : le navigateur commence à décider séparément si la page peut lire la réponse de l'API.

Prenons notre projet pédagogique Secure Content Platform API. L'API est hébergée localement sur http://localhost:8080, et nous avons une zone publique, par exemple GET /api/public/articles. Nous essayons d'appeler cet endpoint depuis une page navigateur qui vit séparément (cas typique — le serveur de développement frontend) : http://localhost:5173. Pour un humain, les deux adresses parlent de « localhost », mais pour le navigateur ce sont des mondes différents : des ports différents signifient des origins différents.

Le plus désagréable avec CORS, c'est que l'erreur ne ressemble pas à un joli 401 ou 403, mais plutôt à quelque chose comme :

“Access to fetch at 'http://localhost:8080/api/public/articles' from origin 'http://localhost:5173' has been blocked by CORS policy…”

Et le débutant a l'impression que « le serveur n'a pas répondu ». Mais le serveur a peut-être répondu (et parfois a même répondu avec le bon JSON). Simplement, le navigateur a décidé : « la page n'a pas le droit de lire cette réponse ».

Il est important de comprendre ce sentiment : CORS n'est pas une « erreur de Spring », c'est une réaction du navigateur face à la situation où « une page d'un origin tente d'accéder à la réponse d'un autre origin ».

2. Origin : schéma, hôte et port

Pour que CORS cesse d'être mystérieux, il faut d'abord cesser de penser aux URL « en entier » et commencer à penser en origins. En termes de navigateur, un origin est une combinaison du schéma, de l'hôte et du port. C'est selon ces trois valeurs que le navigateur décide si une ressource est « locale » ou « étrangère ». Le chemin (/api/public/articles), les paramètres de requête (?page=2) et les fragments (#section) ne sont pas importants pour déterminer l'origin. Si vous ne fixez pas cela, tout semblera chaotique par la suite.

Voici un tableau simple pour « enfoncer le clou » :

URL Origin Même origin avec http://localhost:8080 ?
http://localhost:8080/api/public/articles http://localhost:8080 oui
http://localhost:8080/api/me http://localhost:8080 oui (le chemin n'a pas d'importance)
http://localhost:5173/ http://localhost:5173 non (port différent)
https://localhost:8080/ https://localhost:8080 non (schéma différent)
http://127.0.0.1:8080/ http://127.0.0.1:8080 non (hôte différent)

Oui, le dernier est particulièrement « vexant » : pour un humain, localhost et 127.0.0.1 sont la même chose, mais pour le navigateur ce sont des hôtes différents → origins différents.

Écrivons même un mini-exemple en Java qui reproduit « bêtement » la logique d'origin du navigateur (simplifié, sans les nuances des ports par défaut) :

import java.net.URI;
import java.util.Objects;

boolean sameOrigin(URI left, URI right) {
    // Logique simplifiée "same-origin" : on compare seulement le schéma, l'hôte et le port.
    // Le chemin (/api/...), la requête (?page=2) et le fragment (#section) sont intentionnellement ignorés ici.
    return Objects.equals(left.getScheme(), right.getScheme())
            && Objects.equals(left.getHost(), right.getHost())
            && left.getPort() == right.getPort();
}

// Exemple : même origin, mais chemins différents — pour le navigateur, c'est toujours same-origin.
boolean a = sameOrigin(
        URI.create("http://localhost:8080/api/public/articles"),
        URI.create("http://localhost:8080/api/me")
); // true

// Exemple : ports différents => origins différents (même si l'hôte est le même).
boolean b = sameOrigin(
        URI.create("http://localhost:8080"),
        URI.create("http://localhost:5173")
); // false

Remarquez un « piège psychologique » : lorsque vous voyez une URL, votre cerveau s'accroche au chemin et pense « mais on va sur le même serveur, juste un autre contrôleur ». Pour le navigateur, ce n'est pas un argument. Si le schéma/hôte/port ne correspondent pas, c'est déjà un scénario cross-origin, et le navigateur active des règles supplémentaires.

3. Same-Origin Policy : « la règle du gardien » dans le navigateur

Maintenant que l'origin est clair, on peut expliquer pourquoi CORS existe. Il n'existe pas parce que Spring Security « aime compliquer la vie », mais parce que le navigateur a une règle de sécurité de base appelée Same-Origin Policy (SOP). Cette règle est brutale : « une page chargée depuis un origin ne doit pas pouvoir lire les réponses d'un autre origin ». Si cette règle n'existait pas, n'importe quel onglet avec de « mignons chats » pourrait tranquillement lire les réponses de votre banque, de votre messagerie ou de votre gestionnaire de tâches professionnel – en faisant simplement des requêtes avec votre navigateur, qui est déjà connecté.

C'est un point clé : le navigateur protège l'utilisateur, pas le serveur. Le serveur n'est pas concerné – il se contente de répondre aux requêtes. Le navigateur dit : « Je ne donnerai pas à ton code JavaScript les données d'un autre origin, à moins que le serveur ne l'autorise explicitement ».

Pour visualiser l'ensemble, dessinons un petit schéma. Il est très « scolaire », mais c'est précisément pourquoi il est utile :

flowchart TD
    Page["Page (origin A)
http://localhost:5173"] -->|"fetch()"| API["API (origin B)
http://localhost:8080"] API -->|HTTP response| Browser["Navigateur comme 'gardien'"] Browser -->|Autorisé : JS lit la réponse| JS["JavaScript dans la page"] Browser -->|Interdit : CORS block| Error["Erreur CORS dans la console"]

L'important ici est la séquence : la requête peut partir et même revenir. Mais la décision de « transmettre la réponse au code JavaScript de la page » est prise par le navigateur.

Un détail intéressant et souvent inattendu : SOP ne signifie pas « le navigateur n'enverra pas du tout la requête ». Dans de nombreux cas, il l'enverra, mais bloquera l'accès à la réponse pour JS. Par conséquent, les actions dangereuses « modifiant l'état » (par exemple, un POST sur /api/payments/transfer) ne peuvent pas être protégées uniquement par CORS. CORS n'est pas une armure pour le serveur, c'est une « clôture autour de la page ».

4. CORS : le contrat entre le serveur et le navigateur

Quand on dit « CORS », on parle essentiellement de « comment le serveur peut autoriser le navigateur à lever certaines restrictions de la Same-Origin Policy pour un origin spécifique ». C'est-à-dire que CORS n'est pas un « système d'autorisation » distinct, mais un format d'accord : le navigateur envoie au serveur l'information « je viens d'une page avec tel origin », le serveur répond « cet origin est autorisé », et le navigateur décide « OK, alors je vais montrer la réponse à la page ».

En réalité, cet accord se traduit par des en-têtes HTTP. Le navigateur ajoute généralement dans la requête l'en-tête Origin, et le serveur peut renvoyer dans la réponse l'en-tête Access-Control-Allow-Origin (et quelques autres). Si le serveur ne les renvoie pas, le navigateur considère qu'il n'y a pas d'autorisation et bloque l'accès de la page à la réponse.

Et il est très important de ne pas confondre CORS avec ce que nous avons appris dans le modèle de sécurité :

401 Unauthorized — c'est quand le serveur dit : « tu n'es pas authentifié, connecte-toi normalement ».
403 Forbidden — c'est quand le serveur dit : « tu es authentifié, mais tu n'as pas le droit ».
L'erreur CORS — c'est quand le navigateur dit : « le serveur a peut-être répondu, mais je ne laisse pas la page lire la réponse parce qu'il n'y a pas d'autorisation cross-origin ».

Pour que cela ne paraisse pas « magique », ajoutons un mini-endpoint dans le projet pour observer. Il ne résout pas CORS (cela sera fait dans la prochaine leçon), mais permet de voir concrètement ce qui se passe au niveau HTTP.

Ajoutons un contrôleur qui se contente de journaliser la valeur de l'en-tête Origin :

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;

@RestController
class DebugOriginController {

    // Contrôleur technique : uniquement pour observer ce qui arrive réellement dans la requête HTTP.
    private static final Logger log = LoggerFactory.getLogger(DebugOriginController.class);

    @GetMapping("/api/debug/origin")
    String origin(@RequestHeader(value = "Origin", required = false) String origin) {
        // Dans les requêtes navigateur, l'en-tête Origin est généralement présent, alors que dans curl/Postman il peut manquer.
        log.info("Origin header = {}", origin);

        // Renvoie une simple chaîne pour qu'il soit facile de distinguer "le serveur a répondu" de "le navigateur n'a pas laissé lire".
        return "ok";
    }
}

Ce que vous verrez en pratique (en mots, sans « devoirs à la maison ») : si vous appelez cet endpoint depuis une page navigateur http://localhost:5173, dans les logs vous aurez souvent Origin header = http://localhost:5173. Si vous appelez depuis curl ou Postman, l'en-tête Origin peut être absent (ou présent si le client sait l'ajouter et décide de le faire).

Et c'est déjà une petite victoire sur le chaos : vous commencez à observer le contexte CORS non pas à travers les émotions « le navigateur râle », mais à travers un fait HTTP concret : l'en-tête Origin est-il présent, quelle est sa valeur, et qu'a répondu le serveur.

Après cela, la question habituelle est : « Attendez. Si CORS est une sécurité, pourquoi Postman affiche-t-il tranquillement la réponse sans aucun Access-Control-Allow-Origin ? » La réponse est simple : parce que CORS n'est pas une interdiction protocolaire universelle, mais une politique du navigateur. Postman et curl ne sont pas des navigateurs, ils n'ont pas pour mission de protéger une « page » contre un « serveur étranger ». Ils envoient simplement une requête HTTP et affichent la réponse HTTP.

Il est important de retenir cela pour une raison pratique. Parfois, les débutants (et malheureusement pas seulement eux) pensent : « puisque nous avons configuré CORS, notre API est protégée contre les clients étrangers ». Non. N'importe quel client externe (script sur le serveur d'un attaquant, application mobile, autre backend) peut appeler votre API directement. CORS ne l'en empêche pas.

Autrement dit, CORS ne remplace ni l'authentification ni l'autorisation. Dans notre projet, l'accès à /api/me doit toujours être fermé via Spring Security, les rôles / authorities / ownership via vos règles. CORS concerne uniquement la possibilité pour une page navigateur de lire la réponse. C'est comme un « badge d'entrée » que le gardien vérifie à l'entrée pour les visiteurs, mais qui ne protège pas votre bâtiment contre quelqu'un qui passerait un appel téléphonique (un autre canal de communication).

5. CORS, 401/403 et CSRF : trois niveaux différents

À ce stade, un « méli-mélo » apparaît généralement : CORS, cookies, session, jeton CSRF, 401, 403 — tout se mélange en une grande « erreur de sécurité ». Décomposons cela par niveaux. Un tableau comparatif est très utile car il donne au cerveau une « étagère » pour chaque problème :

Mécanisme Où il opère Ce qu'il protège Symptôme typique
CORS dans le navigateur (décision de délivrer la réponse à la page) l'utilisateur / la page contre la lecture de réponses étrangères « blocked by CORS policy », JS ne voit pas la réponse
Authentification (401) sur le serveur (Spring Security) accès aux ressources protégées sans connexion HTTP 401, parfois WWW-Authenticate
Autorisation (403) sur le serveur (Spring Security) interdire l'accès en cas de droits insuffisants HTTP 403
CSRF sur le serveur (Spring Security), mais la cause est le navigateur / les cookies protection des requêtes de modification d'état dans le modèle cookie/session HTTP 403 (souvent) avec un message CSRF, ou « Invalid CSRF token » dans les logs

La difficulté est que CORS peut « masquer » de véritables 401/403 côté serveur. Par exemple, le serveur peut répondre honnêtement avec un 401, mais sans en-têtes CORS. Alors JavaScript dans la page ne peut pas lire la réponse, et vous ne voyez qu'une erreur CORS. Au final, vous commencez à « soigner CORS » alors que le vrai problème est que la requête est anonyme ou sans cookie de session.

Un moyen très simple de ne pas se noyer est de retenir la règle : si l'erreur mentionne « CORS policy », c'est presque toujours non pas un code de réponse de votre contrôleur, mais une réaction du navigateur. Si vous voyez un statut HTTP 401/403, c'est une réaction de votre serveur (Spring Security), donc il faut traiter vos règles de sécurité, session, rôles ou flux CSRF.

Et une autre réflexion importante qui lie les niveaux : dans le modèle basé sur session, cookies et CSRF sont étroitement liés, et CORS ajoute une nouvelle dimension — « une page cross-origin peut-elle vivre avec ces cookies ? ». Plus tard, nous parlerons des credentials dans CORS et des attributs de cookie, mais retenons pour l'instant l'essentiel : CORS ne rend pas le cookie « valide » et ne rend pas CSRF « inutile ».

6. Mini-diagnostic d'un appel navigateur

Quand dans un vrai projet « l'appel depuis le navigateur ne fonctionne pas », il est utile d'arrêter de voir le problème comme une seule erreur, et de commencer à le décomposer en une chaîne de vérifications. Ce n'est pas magique, c'est juste de la discipline : vous parcourez les niveaux de haut en bas et à chaque niveau vous cherchez « qui exactement a bloqué la requête/réponse ». Pas d'héroïsme, juste une routine d'ingénieur calme.

D'abord, vous vérifiez le fait de l'origin. Si la page http://localhost:5173 appelle l'API http://localhost:8080, c'est du cross-origin, et sans autorisation CORS, le navigateur peut interdire la lecture de la réponse. À ce moment, vous pouvez même ne pas discuter des rôles ou de CSRF — vous constatez simplement que « le navigateur a activé des règles supplémentaires ». Ensuite, vous regardez les logs serveur et comprenez si la requête est arrivée. Si la requête n'est pas arrivée, cela peut être simplement un problème réseau/proxy/URL. Si elle est arrivée, vous examinez : quel Origin a été reçu, ce que le serveur a répondu et quel statut est réellement revenu.

Ensuite, vous vous demandez : « s'agit-il d'une lecture publique ou d'une zone protégée ? » Si c'est /api/public/articles, il ne devrait pas y avoir de 401 selon votre modèle d'accès (public). Si c'est /api/me/profile, alors sans cookie de session vous devriez voir un 401. Et seulement après, vous pensez à CSRF : si c'est un PATCH sur /api/me/profile dans le modèle basé session, même avec le cookie vous aurez besoin du jeton CSRF, sinon une erreur CSRF (le plus souvent un 403).

Et c'est là que l'essentiel se produit : vous arrêtez de lutter contre un grand « fantôme CORS » et commencez à diagnostiquer une étape spécifique. En général, après 2 ou 3 telles analyses, CORS passe du mystère à une technique ennuyeuse (donc maîtrisable).

7. Erreurs typiques lors de la tentative de compréhension de CORS

Erreur n°1 : penser que l'origin est « l'URL entière », chemin compris.
C'est l'un des bogues cognitifs les plus fréquents. Le développeur voit http://localhost:8080/api/public/articles et http://localhost:8080/api/me et s'attend à ce que ce soient « des endroits différents », donc « on est dans CORS ». En réalité, c'est un seul origin. En revanche, http://localhost:8080 et http://localhost:5173 sont déjà des origins différents, même si les deux parlent de « localhost ».

Erreur n°2 : considérer CORS comme « une coche de plus dans Spring Security ».
Il est vrai que CORS peut être configuré côté serveur et que Spring Security intervient dans cette histoire, mais la vérification est faite par le navigateur. Si vous oubliez cela, vous commencerez à chercher « quelle annotation mettre » au lieu de comprendre pourquoi Postman fonctionne et pas le navigateur. CORS n'est pas un hasRole supplémentaire, c'est un accord « le serveur a autorisé le navigateur à ouvrir la fenêtre ».

Erreur n°3 : vérifier le problème uniquement avec Postman et s'étonner que « tout marche ».
Postman est un outil utile, mais il ne simule pas les restrictions du navigateur. Donc « ça marche dans Postman » ne prouve pas que « ça marchera depuis le frontend ». Cela prouve seulement que l'endpoint est vivant, le contrat correct et que le serveur n'est pas tombé. Pour le diagnostic CORS, il faut prendre en compte la réalité du navigateur.

Erreur n°4 : confondre l'erreur CORS avec 401/403 ou CSRF.
401 et 403 sont des réponses du serveur. L'erreur CSRF est aussi une vérification serveur, simplement déclenchée par le modèle navigateur/cookie. L'erreur CORS est un message du navigateur indiquant qu'il ne transmet pas la réponse à la page. Extérieurement, elles peuvent sembler identiques (« ça ne marche pas »), mais elles se traitent avec des solutions fondamentalement différentes.

Erreur n°5 : « réparer » le problème en désactivant aveuglément les mécanismes de protection.
Si le premier réflexe est csrf().disable() ou « autorisons * », vous traitez le symptôme mais pas la compréhension. Dans un projet d'apprentissage, nous suivons délibérément le chemin inverse : d'abord construire un modèle mental, puis autoriser ponctuellement ce qui est réellement nécessaire. Et oui, c'est un peu plus lent, mais dans un mois, vous n'aurez plus peur des mots « CORS » et « preflight », comme s'il s'agissait de noms de boss de Dark Souls.

1
Mission
Spring Security, niveau 12, leçon 0
Bloqué
Comparaison de deux origines
Comparaison de deux origines
1
Mission
Spring Security, niveau 12, leçon 0
Bloqué
Echo Origin dans Spring Boot
Echo Origin dans Spring Boot
Commentaires
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION