6.3 · CORS

Les diapositives de la séance 6

  • définir ce qu’est une origine et énoncer la same-origin policy
  • expliquer pourquoi le navigateur bloque certaines réponses et en quoi cela protège l’utilisateur
  • reconnaître une erreur CORS et savoir qu’elle ne vient pas d’un bug de votre code
  • citer les en-têtes Access-Control-Allow-* et dire qui doit les envoyer
  • contourner légitimement le problème en exécutant la requête côté serveur
  • transmettre des données du serveur Astro vers le navigateur sans second appel réseau

Savoir appeler une API avec 6.2 · fetch et servir une page avec 1.2 · Astro.

Une origine, c’est quoi ?

Une origine est le triplet schéma + hôte + port. Deux URL ont la même origine si et seulement si ces trois éléments sont identiques.

URL comparée à https://exemple.fr/a Même origine ? Pourquoi
https://exemple.fr/b/c oui seul le chemin diffère, il ne compte pas
http://exemple.fr/a non schéma différent (http au lieu de https)
https://api.exemple.fr/a non hôte différent, un sous-domaine est un autre hôte
https://exemple.fr:8080/a non port différent
http://localhost:4321 et http://localhost:3000 non port différent

Ce dernier cas vous concerne directement : votre serveur Astro sur localhost:4321 et votre json-server sur localhost:3000 sont deux origines distinctes.

La same-origin policy

Par défaut, un navigateur autorise une page à envoyer une requête vers une autre origine, mais lui interdit d’en lire la réponse.

La raison tient en un scénario. Vous êtes connecté à votre banque dans un onglet. Dans un autre onglet, vous ouvrez un site quelconque. Sans cette politique, le JavaScript de ce site pourrait faire fetch("https://mabanque.fr/api/comptes") (requête qui partirait avec vos cookies de session, donc authentifiée) et lire tranquillement vos relevés pour les exfiltrer.

La same-origin policy est ce qui rend ce scénario impossible. Elle n’est pas une gêne accidentelle : c’est un des piliers de la sécurité du web.

CORS (Cross-Origin Resource Sharing) est le mécanisme standard qui permet à un serveur de lever sélectivement cette restriction, pour les origines de son choix.

Lire l’erreur

Dans la console du navigateur, elle ressemble à ceci :

Access to fetch at 'https://api.deezer.com/genre/' from origin
'http://localhost:4321' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.

Trois points à comprendre, et ce sont les trois qui font perdre le plus de temps :

  1. Ce n’est pas un bug de votre code. Votre fetch est correct. Le même code s’exécuterait sans problème depuis Node.js.
  2. Le serveur a répondu normalement. La requête est partie, Deezer a renvoyé un 200 avec les données. C’est le navigateur qui refuse de vous laisser lire cette réponse, parce que l’en-tête d’autorisation manque.
  3. L’erreur remonte comme un échec réseau. La promesse de fetch est rompue, donc le catch se déclenche. Vous n’aurez ni response.status ni corps à inspecter : le navigateur ne vous donne délibérément aucune information.
Importantcurl n’est pas concerné
curl https://api.deezer.com/genre/

Cette commande fonctionne parfaitement. CORS est une règle appliquée par les navigateurs, pas par les serveurs. Un client qui n’est pas un navigateur (curl, Node.js, Python, Postman) n’a aucune notion d’origine et n’applique aucune restriction.

Conséquence : CORS ne protège pas les données du serveur. Il protège l’utilisateur du navigateur contre les sites qui essaieraient d’agir en son nom.

Ce que le serveur doit envoyer

C’est le serveur distant, et lui seul, qui autorise. Il ajoute un en-tête à sa réponse :

Access-Control-Allow-Origin: https://monsite.fr

ou, pour une API ouverte à tous :

Access-Control-Allow-Origin: *

Pour les requêtes non triviales (une méthode PUT ou DELETE, un en-tête Authorization, un Content-Type: application/json), le navigateur envoie d’abord une requête OPTIONS de preflight pour demander la permission. Le serveur doit alors répondre en plus avec Access-Control-Allow-Methods et Access-Control-Allow-Headers.

Deux fausses solutions circulent, et aucune ne doit être utilisée :

  • une extension navigateur « CORS unblock » : elle désactive une protection de sécurité pour tous les sites que vous visitez, y compris votre messagerie et votre banque. Pour vous et pour vous seul : votre application reste cassée chez tous les autres utilisateurs ;
  • lancer Chrome avec --disable-web-security : même chose, en pire, car cela affecte tout le profil.

Elles font disparaître le message d’erreur sans rien résoudre. En production, l’application ne fonctionnera pas.

Les vraies solutions

Situation Solution
L’API est à vous ajoutez Access-Control-Allow-Origin dans ses réponses
L’API est publique et documentée pour le navigateur rien à faire, elle envoie déjà l’en-tête
L’API refuse les origines externes (cas de Deezer) faites la requête côté serveur

La troisième ligne est le cas courant. Le principe : votre serveur (Astro, ici) appelle l’API, puis transmet le résultat à votre page. Votre page ne parle jamais à Deezer, donc il n’y a plus de requête entre origines dans le navigateur. Ce montage s’appelle un proxy.

Bénéfice annexe et important : si l’API exige une clé, celle-ci reste sur le serveur. Une clé placée dans du code client est lisible par n’importe qui via l’onglet Réseau du navigateur.

En pratique avec Astro

Le code du cours interroge l’API Deezer pour lister des genres musicaux :

app.ts
interface GenreInterface {
  id: number;
  name: string;
  picture_small: string;
}

interface GenreAPI {
  data: GenreInterface[];   // l'API Deezer enveloppe tout dans un champ "data"
}

async function getGenresFromDeezer(): Promise<GenreInterface[]> {
  const response = await fetch("https://api.deezer.com/genre/");
  const genrejson: GenreAPI = await response.json();
  return genrejson.data;
}

télécharger app.ts

Ce code échoue s’il tourne dans le navigateur, et réussit s’il tourne côté serveur. Dans Astro, « côté serveur » signifie : dans le frontmatter, entre les deux lignes ---.

1. Appeler l’API dans le frontmatter : ce code s’exécute sur le serveur Astro, jamais dans le navigateur :

---
const response = await fetch('https://api.deezer.com/genre/');
const genreJson: GenreAPI = await response.json();

const serverData = {
  total: genreJson.data.length,
  firstGenre: genreJson.data[0]?.name ?? 'aucun',
  names: genreJson.data.slice(0, 5).map((genre) => genre.name),
};
---

2. Afficher directement, si le client n’a rien à recalculer :

<p>Premier genre : {serverData.firstGenre}</p>

3. Transmettre au client si un script du navigateur doit réutiliser les données : tri, filtrage, bouton « afficher plus ». On les sérialise dans un attribut data-* :

<div id="server-data" data-payload={JSON.stringify(serverData)}></div>

4. Relire côté client avec dataset puis JSON.parse :

const dataElement = document.getElementById('server-data');
const json = dataElement?.dataset.payload ?? '{}';
const parsedData: unknown = JSON.parse(json);

JSON.parse retourne any. On l’annote délibérément en unknown pour se forcer à vérifier avant usage, à l’aide d’une fonction de garde :

function isServerData(value: unknown): value is ServerData {
  return (
    typeof value === 'object' && value !== null &&
    'total' in value && typeof value.total === 'number' &&
    'names' in value && Array.isArray(value.names) &&
    value.names.every((item) => typeof item === 'string')
  );
}

if (isServerData(parsedData)) {
  // ici TypeScript sait que parsedData est un ServerData
}

Le value is ServerData en type de retour s’appelle un prédicat de type : il indique au compilateur que si la fonction retourne true, la variable peut être traitée comme un ServerData dans la suite du bloc.

NotePourquoi data-* et pas define:vars ?

Astro propose define:vars pour injecter des variables dans un script. Ce mécanisme transforme le script en script inline, avec des conséquences sur le typage et sur la politique de sécurité du contenu. Pour un premier cours, data-* + dataset + JSON.parse est plus simple à relire et à déboguer.

Exercices

Exercice 6.8 : Constater le blocage

essentiel

  1. Placez le code Deezer dans un <script> d’une page Astro, donc côté client. Ouvrez la console : relevez le message d’erreur exact.
  2. Ouvrez l’onglet Réseau des outils de développement. La requête apparaît-elle ? Quel est son code de statut ? Que concluez-vous sur l’endroit où se produit le blocage ?
  3. Lancez curl https://api.deezer.com/genre/ dans un terminal. Que se passe-t-il ? Expliquez la différence en deux phrases.

Critère de réussite : vous êtes capable d’expliquer pourquoi la requête réussit au niveau réseau mais échoue au niveau du navigateur.

1. Le message de la console. À la formulation près selon le navigateur :

Access to fetch at 'https://api.deezer.com/genre/' from origin 'http://localhost:4321'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present
on the requested resource.

Il est souvent suivi d’un second message, TypeError: Failed to fetch, qui est la promesse de fetch rompue.

2. L’onglet Réseau. La requête apparaît bien, et son statut est 200. Vous pouvez même voir la taille de la réponse. En revanche l’onglet « Réponse » est vide ou signale que le corps n’est pas disponible.

Conclusion : la requête est partie, Deezer a répondu normalement, la réponse est arrivée dans le processus réseau du navigateur. Le blocage se produit au dernier instant, quand le navigateur doit remettre cette réponse au JavaScript de la page : il constate l’absence de l’en-tête Access-Control-Allow-Origin et refuse.

3. curl. La commande affiche le JSON complet des genres, sans la moindre difficulté.

curl https://api.deezer.com/genre/

La différence : CORS est une règle appliquée par le navigateur, pas par le serveur. curl n’a aucune notion d’origine : il n’y a pas de page, pas d’onglet, pas de cookie de session à protéger, donc il n’applique aucune restriction.

Le point clé. « La requête a réussi » et « je peux lire la réponse » sont deux choses distinctes. C’est ce qui rend l’erreur si déroutante : votre code est correct, le serveur a fait son travail, et pourtant catch se déclenche. Corollaire important : CORS ne protège pas les données de Deezer, puisque n’importe qui les obtient avec curl. Il protège l’utilisateur du navigateur contre un site qui essaierait d’exploiter ses sessions ouvertes ailleurs.

Exercice 6.9 : Une playlist Deezer côté serveur

approfondissement transfert

Écrivez une page Astro qui affiche la playlist https://api.deezer.com/playlist/11846226041 (une playlist « Top France » de 96 titres), récupérée côté serveur.

Note

Si cette playlist venait à disparaître, remplacez l’URL par https://api.deezer.com/chart/0/tracks, qui renvoie le classement du moment. La structure de la réponse change légèrement : les morceaux sont directement dans data, sans passer par tracks.

Affichez une liste non ordonnée où chaque <li> contient :

  • le numéro de position, obtenu avec le second paramètre de forEach (l’index), et non avec l’id du morceau ;
  • le titre, le nom de l’artiste et le titre de l’album, en texte seul ;
  • la pochette (album.cover_small) dans une balise <img> ;
  • un lecteur audio pour l’extrait (preview).

Exemple de rendu attendu pour un élément :

<li>
  <h3>1. Flowers</h3>
  <p>Miley Cyrus</p>
  <p>Flowers</p>
  <img src="https://e-cdns-images.dzcdn.net/.../56x56-000000-80-0-0.jpg" alt="cover">
  <audio controls preload="none">
    <source src="https://cdns-preview-0.dzcdn.net/stream/....mp3" type="audio/mpeg">
    Your browser does not support the audio element.
  </audio>
</li>
Avertissementpreload="none" est obligatoire

Sans cet attribut, le navigateur télécharge les cent extraits MP3 au chargement de la page.

Les données utiles sont dans playlist.tracks.data, un tableau dont chaque élément porte title, preview, artist.name et album.cover_small. Ne décrivez que ces champs : une interface n’a pas à recopier tout le JSON. Le site quicktype.io peut générer les interfaces complètes à partir d’un exemple de réponse, à élaguer ensuite.

Critères de réussite : la page s’affiche sans erreur dans la console, aucune requête vers api.deezer.com n’apparaît dans l’onglet Réseau du navigateur, et la lecture d’un extrait fonctionne.

Tout tient dans le frontmatter : le fetch s’exécute sur le serveur Astro, qui envoie au navigateur une page HTML déjà remplie.

src/pages/index.astro
---
interface Track {
  title: string;
  preview: string;
  artist: { name: string };
  album: { title: string; cover_small: string };
}

interface DeezerPlaylist {
  title: string;
  tracks: { data: Track[] };
}

const response = await fetch("https://api.deezer.com/playlist/11846226041");
if (!response.ok) {
  throw new Error(`Deezer a répondu ${response.status}`);
}
const playlist: DeezerPlaylist = await response.json();
const tracks: Track[] = playlist.tracks.data;
---

<!DOCTYPE html>
<html lang="fr">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>{playlist.title}</title>
  </head>
  <body>
    <h1>{playlist.title}</h1>

    <ul>
      {tracks.map((track, index) => (
        <li>
          <h3>{index + 1}. {track.title}</h3>
          <p>{track.artist.name}</p>
          <p>{track.album.title}</p>
          <img src={track.album.cover_small} alt="cover" />
          <audio controls preload="none">
            <source src={track.preview} type="audio/mpeg" />
            Your browser does not support the audio element.
          </audio>
        </li>
      ))}
    </ul>
  </body>
</html>

Le point clé. Il n’y a plus de requête entre origines dans le navigateur : la seule requête qu’il émet va vers localhost:4321, sa propre origine. C’est le serveur Astro qui a parlé à Deezer, et un serveur n’applique pas CORS. Vérifiez-le dans l’onglet Réseau : vous n’y verrez aucune ligne api.deezer.com, seulement les images et les extraits audio servis par le CDN de Deezer, qui eux sont autorisés puisque <img> et <audio> ne sont pas soumis à la même règle que fetch.

Deux détails de l’énoncé comptent :

  • le numéro de position vient du second paramètre de map (l’index, d’où index + 1), pas de track.id. Un identifiant Deezer est un nombre arbitraire à neuf chiffres, il n’indique pas le rang dans la playlist ;
  • preload="none" évite le téléchargement immédiat de cent fichiers MP3.

Les titres et noms d’artistes sont insérés via l’interpolation {...} d’Astro, qui échappe le HTML : une donnée Deezer contenant <script> resterait du texte.

QCM

1. Une page servie par http://localhost:4321 appelle http://localhost:3000/restaurants. Est-ce la même origine ?

A. Oui : c’est le même hôte. B. Non : le port est différent. C. Oui : les deux sont en http. D. Cela dépend du chemin demandé.

B. Une origine, c’est schéma, hôte et port. Le chemin ne compte pas.

2. La console affiche blocked by CORS policy. Qui a refusé ?

A. Le serveur distant, qui n’a pas traité la requête. B. Le navigateur, qui refuse à la page de lire la réponse. C. Le compilateur TypeScript. D. Le pare-feu de la machine.

B. Le serveur a répondu normalement ; c’est le navigateur qui bloque la lecture, faute d’en-tête Access-Control-Allow-Origin. Le même appel fonctionne avec curl.

3. Une API publique refuse les origines extérieures. Quelle est la bonne solution ?

A. Installer une extension « CORS unblock ». B. Lancer le navigateur avec --disable-web-security. C. Appeler l’API côté serveur, par exemple dans le frontmatter d’Astro. D. Ajouter mode: "no-cors" à l’appel de fetch.

C. A et B désactivent une protection pour tous les sites, et l’application reste cassée chez les autres. D rend une réponse opaque, que le script ne peut pas lire.

À retenir

  • Une origine = schéma + hôte + port ; un port différent suffit à changer d’origine
  • La same-origin policy empêche une page d’exploiter votre session sur un autre site
  • Une erreur CORS vient du navigateur, pas du serveur ni de votre code : la réponse est bien arrivée, sa lecture est refusée
  • Seul le serveur distant peut autoriser, via Access-Control-Allow-Origin
  • curl et Node.js ignorent CORS : la solution est d’appeler l’API depuis le serveur
  • Ne désactivez jamais la protection dans votre navigateur : cela ne corrige rien et vous expose sur tous les sites