Aller au contenu
English

Accès depuis le navigateur et CORS

  • Leçon 9
  • 40 min
  • Module 3 : Concevoir un contrat utilisable

Ce que vous saurez faire

Permettez à une page Web à une autre adresse d’appeler votre API depuis un navigateur, comme celle d’une équipe d’interface utilisateur. Autorisez seulement les pages que vous choisissez. Sachez exactement ce que ce réglage protège, et ce qu’il ne protège pas.

Avant de commencerCe qu’il faut savoir et avoir sous la mainEnviron 40 minutes

Ce que vous devez savoir

Ce qu’il vous faut sur votre ordinateur

Testé avec Python 3.14.6, FastAPI 0.142.2 et Chrome 141 sur macOS.

Une origine comprend le schéma, l’hôte et le port d’une page. http://localhost:5500 et http://127.0.0.1:8000 sont deux origines différentes, même si elles sont toutes deux sur votre ordinateur.

Les navigateurs ont une protection appelée règle de même origine. Le code d’une page peut lire seulement les réponses de sa propre origine. Sans cette règle, toute page Web ouverte pourrait utiliser votre navigateur pour lire vos courriels ou votre compte bancaire dans un autre onglet. C’est parce que le navigateur envoie vos témoins avec chaque requête.

Votre API a une origine différente de celle de la page créée par une équipe d’interface utilisateur. Par défaut, le navigateur ne permet donc pas à cette page de lire les réponses de votre API.

CORS (partage des ressources entre origines) permet au serveur de dire au navigateur : « les pages de ces origines peuvent lire mes réponses ». Le serveur l’indique avec des en-têtes de réponse, comme Access-Control-Allow-Origin.

Amira crée une petite page de test dans un dossier browser-test de son projet. Elle envoie une requête à l’API avec fetch, la fonction de requête du navigateur :

browser-test/index.html
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Ticket classifier test</title></head>
<body>
<h1>Ticket classifier test</h1>
<button id="send">Classify "Charged twice for March"</button>
<pre id="result">No request yet.</pre>
<script>
document.getElementById("send").addEventListener("click", async () => {
const result = document.getElementById("result");
try {
const response = await fetch("http://127.0.0.1:8000/v1/classify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ subject: "Charged twice for March" }),
});
result.textContent = response.status + " " + (await response.text());
} catch (error) {
result.textContent = "The request failed: " + error;
}
});
</script>
</body>
</html>

Dans un nouveau terminal, elle sert la page depuis une deuxième origine avec le serveur Web intégré de Python :

A new terminal, in browser-test
python -m http.server 5500

Elle ouvre http://localhost:5500 et sélectionne le bouton. La page affiche The request failed: TypeError: Failed to fetch (la requête a échoué : erreur de type, récupération impossible). L’onglet Console (console) des outils de développement explique pourquoi :

Console
Access to fetch at 'http://127.0.0.1:8000/v1/classify' from origin 'http://localhost:5500' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.

Le terminal de l’API affiche quelque chose de surprenant :

API terminal
INFO: 127.0.0.1:59691 - "OPTIONS /v1/classify HTTP/1.1" 405 Method Not Allowed

Le navigateur n’a pas du tout envoyé la requête POST. Il a d’abord envoyé une requête OPTIONS.

Pour une requête qui pourrait modifier des données, comme un POST avec du JSON, le navigateur demande d’abord l’autorisation au serveur. Cette première requête est la requête préliminaire. C’est une requête OPTIONS avec l’origine de la page dans un en-tête Origin. Le navigateur envoie la vraie requête seulement si le serveur répond avec les bons en-têtes Access-Control-Allow-....

La page de http://localhost:5500 appelle fetch POST /v1/classify. Le navigateur envoie d'abord une requête préliminaire, OPTIONS /v1/classify avec l'en-tête Origin : http://localhost:5500, à l'API sur le port 8000. Si l'origine figure dans ALLOWED_ORIGINS, l'API répond 200 avec Access-Control-Allow-Origin : http://localhost:5500. Le navigateur envoie la vraie requête POST, reçoit 200 et le résultat JSON, puis transmet la réponse à la page. Si l'origine n'est pas autorisée, l'API répond sans cet en-tête, et la page reçoit TypeError : Failed to fetch.
Le navigateur demande l'autorisation avant d'envoyer. Seule une origine autorisée obtient la vraie requête et la réponse.
Description textuelle du diagramme

La page appelle fetch avec un POST. Le navigateur envoie d’abord une requête préliminaire à l’API : OPTIONS /v1/classify, avec Origin: http://localhost:5500. Si cette origine figure dans la liste autorisée de l’API, celle-ci répond 200 avec Access-Control-Allow-Origin: http://localhost:5500. Le navigateur envoie ensuite le vrai POST, reçoit 200 et le résultat JSON, puis le transmet à la page. Si l’origine n’est pas autorisée, la réponse de l’API n’a pas d’en-tête Access-Control-Allow-Origin. Le navigateur n’envoie alors pas la vraie requête, et la page reçoit seulement TypeError: Failed to fetch (erreur de type, récupération impossible).

FastAPI fournit un intergiciel prêt à l’emploi pour CORS. Un intergiciel est du code qui s’exécute autour de chaque requête : avant la route, puis après. L’intergiciel CORS répond aux requêtes préliminaires et ajoute les en-têtes aux vraies réponses.

La liste des origines autorisées diffère selon l’endroit où l’API s’exécute : http://localhost:5500 sur l’ordinateur d’Amira, l’adresse réelle de l’interface utilisateur en production. C’est donc de la configuration. Elle vient d’une variable d’environnement, comme dans Configuration et secrets :

ticket_api/main.py
import os
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from ticket_api.classifier import KeywordClassifier
from ticket_api.errors import add_error_handlers
from ticket_api.routes import router
app = FastAPI(title="Ticket Classifier API")
app.state.classifier = KeywordClassifier()
add_error_handlers(app)
allowed_origins = [
origin.strip()
for origin in os.environ.get("ALLOWED_ORIGINS", "").split(",")
if origin.strip()
]
if allowed_origins:
app.add_middleware(
CORSMiddleware,
allow_origins=allowed_origins,
allow_methods=["GET", "POST"],
allow_headers=["Content-Type"],
)
app.include_router(router)
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
Partie Pourquoi
ALLOWED_ORIGINS Une liste séparée par des virgules, par exemple http://localhost:5500,https://help.example.com. Sans valeur, aucune page de navigateur ne peut appeler l’API. C’est le réglage sûr par défaut.
allow_origins Les origines exactes, avec le schéma et le port. Jamais ["*"] (toutes les origines) pour une API avec des clés ou des données privées.
allow_methods, allow_headers Seulement ce dont les pages ont besoin.

Elle arrête l’API avec Ctrl+C, puis la redémarre avec la variable :

Fenêtre de terminal
ALLOWED_ORIGINS=http://localhost:5500 fastapi dev ticket_api/main.py

Elle sélectionne encore le bouton. La page affiche 200 {"category":"billing","priority":1,...}, et le terminal de l’API affiche les deux requêtes :

API terminal
INFO: 127.0.0.1:59702 - "OPTIONS /v1/classify HTTP/1.1" 200 OK
INFO: 127.0.0.1:59703 - "POST /v1/classify HTTP/1.1" 200 OK

Amira se fait maintenant passer pour une page d’une autre origine, avec curl. D’abord, la requête préliminaire :

Terminal (on Windows: curl.exe)
curl -i -X OPTIONS http://127.0.0.1:8000/v1/classify -H "Origin: https://evil.example" -H "Access-Control-Request-Method: POST"
Sortie (abrégée)
HTTP/1.1 400 Bad Request
Disallowed CORS origin

Refusée, comme prévu. Ensuite, la vraie requête, depuis la même « origine » :

Terminal (on Windows: curl.exe)
curl -X POST http://127.0.0.1:8000/v1/classify -H "Origin: https://evil.example" -H "Content-Type: application/json" --data @requests/billing.json
Sortie
{"category":"billing","priority":1,"confidence":0.9,"model_version":"keywords-1.0"}

Ça fonctionne. curl n’est pas un navigateur : il n’envoie pas de requête préliminaire et ne vérifie pas les en-têtes CORS. Postman, un script Python ou un serveur ne le font pas non plus. CORS contrôle seulement ce que les pages d’un navigateur peuvent lire. Il n’empêche personne d’appeler l’API. Pour contrôler qui peut appeler l’API, il faut de l’authentification, comme la clé d’API que vous ajoutez au module 6.

  1. Créez browser-test/index.html avec le code ci-dessus.
  2. Dans un nouveau terminal, allez dans browser-test et exécutez python -m http.server 5500.
  3. Ouvrez http://localhost:5500, ouvrez la Console des outils de développement et sélectionnez le bouton. Vous voyez l’erreur CORS, et le terminal de l’API affiche OPTIONS ... 405.
  4. Ajoutez le code CORS à main.py. Arrêtez l’API, puis redémarrez-la avec ALLOWED_ORIGINS=http://localhost:5500.
  5. Sélectionnez encore le bouton. La page affiche 200 et le résultat.
  6. Envoyez les deux requêtes curl avec Origin: https://evil.example. La requête préliminaire est refusée, et la vraie requête fonctionne.
  7. Arrêtez le serveur de la page de test avec Ctrl+C.

Servez aussi la même page sur le port 5600 (python -m http.server 5600 dans un troisième terminal), puis ouvrez http://localhost:5600. Prédisez ce qui se passe quand vous sélectionnez le bouton. Modifiez ensuite ALLOWED_ORIGINS pour que les deux pages de test fonctionnent, et aucune autre.

Afficher la réponse

Sur le port 5600, la requête est bloquée : http://localhost:5600 est une autre origine, absente de la liste. Démarrez l’API avec ALLOWED_ORIGINS=http://localhost:5500,http://localhost:5600. Notez que http://127.0.0.1:5600 reste bloquée. Pour CORS, localhost et 127.0.0.1 sont des hôtes différents, donc des origines différentes.

Erreur courante

Autoriser toutes les origines pour faire disparaître l'erreur

Symptôme : une erreur CORS bloque une page, et une recherche rapide suggère allow_origins=["*"]. L’erreur disparaît.

Cause : * permet à une page de n’importe quel site Web de lire les réponses de l’API dans le navigateur d’un visiteur. Pour une API entièrement publique, sans clés ni données privées, cela peut être acceptable. Certaines API sont appelées par un navigateur avec des identifiants, ou renvoient des données sur un utilisateur. Pour ces API, ce réglage permet à n’importe quel site Web de lire ces données par l’intermédiaire de ses visiteurs.

Solution :

  1. Trouvez l’origine exacte de la page : dans l’onglet Network (réseau) des outils de développement, l’en-tête Origin de la requête préliminaire.
  2. Mettez exactement cette origine dans ALLOWED_ORIGINS, avec son schéma et son port.
  3. Gardez une liste distincte pour chaque endroit où l’API s’exécute. En production, inscrivez seulement l’origine réelle de l’interface utilisateur.

Vérifiez votre compréhension

Un collègue dit : « Notre API est protégée contre les inconnus, car CORS autorise seulement notre propre site Web. » Qu'est-ce qui cloche?

Afficher la réponse

CORS indique seulement aux navigateurs quelles pages peuvent lire les réponses. N’importe qui peut appeler directement l’API avec curl, Postman ou un script et obtenir la réponse, comme l’exemple evil.example l’a montré. Pour bloquer les inconnus, l’API a besoin d’authentification, par exemple une clé d’API ou une connexion utilisateur. CORS et l’authentification règlent des problèmes différents.

Vérifiez votre compréhension

Vérification du module : dans votre tableau de contrat, faites l'appel de chaque ligne pour /v1/classify et /v1/categories. Confirmez le code d'état et la structure du corps. Quelle ligne une page de navigateur ne pourrait-elle pas tester, et pourquoi?

Afficher la réponse

Chaque ligne donne le code d’état prévu et, pour les erreurs, la structure {"error": {"code", "message", "fields"}}. Une page peut toutes les tester une fois son origine autorisée. Mais un navigateur ne peut pas envoyer un corps JSON invalide avec fetch et JSON.stringify, ni omettre l’en-tête Origin. Testez ces lignes avec curl ou Postman.

Récapitulatif

  • Une origine comprend un schéma, un hôte et un port. Les navigateurs laissent une page lire seulement les réponses de sa propre origine, sauf si le serveur en autorise d’autres avec CORS.
  • Pour un POST avec du JSON, le navigateur envoie d’abord une requête préliminaire OPTIONS. L’intergiciel CORS y répond.
  • Autorisez des origines exactes depuis la configuration (ALLOWED_ORIGINS). N’utilisez pas * pour une API avec des clés ou des données privées.
  • CORS n’est pas de l’authentification. Les clients qui ne sont pas des navigateurs l’ignorent.

Vous avez maintenant conçu un contrat : des routes claires, une seule structure d’erreur et un accès sûr depuis le navigateur. Dans le prochain module, vous le documentez avec OpenAPI et Swagger UI.

Leçon suivante : OpenAPI et Swagger UI