← Toutes les fiches

Fiche 9 Parcours : le web Lecture : 25 minutes

Le fonctionnement du web

Ce qui se passe vraiment entre le clic de l'utilisateur et ton contrôleur Symfony.

Symfony cache beaucoup de choses : on reçoit un objet Request, on renvoie une Response, et le reste semble magique. Mais le jour où un cookie de session disparaît, où un site refuse d'appeler ton API, où l'adresse IP enregistrée est toujours la même, ou où un changement de serveur « ne marche que pour certains », il faut savoir ce qui circule entre le navigateur et le serveur.

Cette fiche suit le trajet d'une requête, étape par étape. Chaque étape est une source de bugs connue, et souvent de failles.

À propos des exemples de cette fiche

Les exemples sont écrits dans leur propre format : HTTP brut, YAML de configuration Symfony, JSON. Ce sont des illustrations, marquées « non exécuté » : aucune requête réseau n'a été faite pour cette fiche. Seul le découpage d'une adresse en PHP a été exécuté. Les noms de domaine et les adresses IP sont fictifs, choisis dans les plages réservées à la documentation.

1.Le trajet d'une requête

Une adresse web (une URL) se découpe en morceaux, et chacun sert à une étape différente :

exemples/09/url.php
<?php

declare(strict_types=1);

// Les morceaux d'une adresse, tels que PHP les découpe
$url = 'https://shop.example.com:8443/produits/clavier?couleur=noir&page=2#avis';

foreach (parse_url($url) as $part => $value) {
    printf("%-8s %s\n", $part, $value);
}

parse_str(parse_url($url, PHP_URL_QUERY), $query);
echo "\nParamètres de la requête : ", json_encode($query), "\n";
Sortie
scheme   https
host     shop.example.com
port     8443
path     /produits/clavier
query    couleur=noir&page=2
fragment avis

Paramètres de la requête : {"couleur":"noir","page":"2"}

Quand l'utilisateur valide cette adresse, voici ce qui se passe :

  1. Le DNS traduit le nom shop.example.com en adresse IP, l'adresse numérique du serveur (section 2).
  2. Une connexion s'ouvre vers cette adresse, sur le port 443 (le port par défaut de HTTPS, ici remplacé par 8443).
  3. TLS chiffre la connexion et vérifie l'identité du serveur grâce à son certificat (section 5).
  4. Le navigateur envoie la requête HTTP : la méthode, le chemin /produits/clavier, la requête ?couleur=noir&page=2, les en-têtes et les cookies (section 3).
  5. Côté serveur, un reverse proxy comme nginx reçoit la requête et la transmet à PHP-FPM, qui exécute Symfony (section 6).
  6. Symfony construit l'objet Request, trouve la route, appelle le contrôleur et renvoie une Response.
  7. Le navigateur reçoit la réponse et l'affiche. Puis il recommence pour chaque image, feuille de style et script de la page.
Le trajet d'une requête, du navigateur à Symfony Le navigateur demande l'adresse IP au résolveur DNS, puis ouvre une connexion chiffrée vers le serveur. Sur le serveur, nginx transmet la requête à PHP-FPM, qui exécute Symfony. La réponse repart vers le navigateur. Navigateur Résolveur DNS shop.example.com → 203.0.113.10 ① quelle IP ? Serveur 203.0.113.10 nginx reverse proxy PHP-FPM exécute PHP Symfony contrôleur ② ③ ④ HTTPS ⑤ ⑥ ⑦ la réponse HTTP
Schéma 9.1 · Le trajet d'une requête, du navigateur à Symfony

Le fragment (#avis) n'est jamais envoyé au serveur : il ne sert qu'au navigateur, pour se placer dans la page. C'est pour cela qu'il n'apparaît jamais dans l'objet Request.

2.Le DNS : l'annuaire du web

Le DNS (Domain Name System) traduit un nom de domaine en adresse IP. Chaque domaine publie des enregistrements :

exemples/09/dns_zone.txt · illustration, non exécuté
; Les enregistrements DNS d'un domaine (adresses de documentation, fictives)
; nom                  TTL    type   valeur
shop.example.com.      3600   A      203.0.113.10          ; adresse IPv4
shop.example.com.      3600   AAAA   2001:db8::10          ; adresse IPv6
www.shop.example.com.  3600   CNAME  shop.example.com.     ; un alias vers un autre nom
shop.example.com.      3600   MX     10 mail.example.com.  ; où livrer les e-mails
shop.example.com.      3600   TXT    "v=spf1 include:_spf.example.com -all"
                                                           ; texte libre : SPF, vérifications…

Pour trouver la réponse, le navigateur interroge d'abord son cache, puis celui du système, puis un résolveur (celui du fournisseur d'accès, ou un résolveur public). Si le résolveur ne la connaît pas, il remonte une chaîne de serveurs : la racine, puis le serveur de .com, puis le serveur qui fait autorité pour example.com.

« J'ai changé le DNS, mais ça ne marche pas pour tout le monde »

Chaque réponse est gardée en cache pendant sa durée de vie, le TTL (time to live, en secondes : 3600 = une heure). Tant que le TTL n'est pas écoulé, une partie des utilisateurs reçoit encore l'ancienne adresse. Avant une migration de serveur, baisse le TTL à quelques minutes un ou deux jours à l'avance, change l'adresse, puis remonte le TTL.

3.HTTP : une requête, une réponse

HTTP est un échange très simple : le client envoie une requête, le serveur renvoie une réponse. En HTTP/1.1, les deux sont du texte lisible. Voici l'ajout d'un article au panier :

exemples/09/request.http · illustration, non exécuté
POST /panier/articles HTTP/1.1
Host: shop.example.com
Content-Type: application/json
Accept: application/json
Cookie: PHPSESSID=8f2c1e0b7d6a4c39
Content-Length: 34

{"productId": 42, "quantity": 2}
exemples/09/response.http · illustration, non exécuté
HTTP/1.1 201 Created
Content-Type: application/json
Location: /panier/articles/7
Cache-Control: no-store
Set-Cookie: PHPSESSID=8f2c1e0b7d6a4c39; Path=/; Secure; HttpOnly; SameSite=Lax

{"id": 7, "productId": 42, "quantity": 2}

Chaque message a la même forme : une première ligne (la méthode et le chemin, ou le code de statut), des en-têtes (Nom: valeur), une ligne vide, puis un corps facultatif. HTTP/2 et HTTP/3 transportent exactement les mêmes informations, mais en binaire et plus efficacement : plusieurs requêtes partagent une seule connexion. Pour toi, côté Symfony, rien ne change.

Voici la même requête, vue depuis Symfony :

exemples/09/CartController.php · illustration, non exécuté
<?php

declare(strict_types=1);

namespace App\Controller;

use App\Dto\AddToCart;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\Routing\Attribute\Route;

final class CartController extends AbstractController
{
    // La même requête, vue par Symfony : la méthode, le chemin, le corps JSON
    #[Route('/panier/articles', methods: ['POST'])]
    public function add(#[MapRequestPayload] AddToCart $payload): JsonResponse
    {
        // $payload est déjà désérialisé ET validé : sinon Symfony répond 422 tout seul
        $item = [
            'id' => 7,
            'productId' => $payload->productId,
            'quantity' => $payload->quantity,
        ];

        return $this->json($item, Response::HTTP_CREATED, [
            'Location' => '/panier/articles/7',
        ]);
    }
}

Les méthodes

MéthodeRôleSûreIdempotente
GETLire une ressourceOuiOui
POSTCréer, ou déclencher une actionNonNon
PUTRemplacer entièrement une ressourceNonOui
PATCHModifier une partie d'une ressourceNonPas forcément
DELETESupprimer une ressourceNonOui

Les codes de statut

FamilleSensLes plus utiles
2xxSuccès200 OK, 201 Created (avec l'en-tête Location), 204 No Content
3xxRedirection301 et 308 (définitive), 302, 303 et 307 (temporaire), 304 Not Modified (cache)
4xxErreur du client400 requête invalide, 401 non authentifié, 403 interdit, 404 introuvable, 409 conflit, 422 données invalides, 429 trop de requêtes
5xxErreur du serveur500 erreur interne, 502 et 504 (le proxy n'obtient pas de réponse correcte de PHP), 503 indisponible

La différence entre 401 et 403 revient souvent : 401 veut dire « je ne sais pas qui tu es, connecte-toi », 403 veut dire « je sais qui tu es, et tu n'as pas le droit ». Et une erreur 502 ou 504 renvoyée par nginx indique presque toujours que PHP-FPM est arrêté, saturé ou trop lent.

4.Cookies et sessions : se souvenir de l'utilisateur

HTTP est sans état : chaque requête est indépendante, et le serveur ne se souvient pas de la précédente. Pour savoir que la requête vient du même utilisateur, il faut une information qui voyage avec chaque requête : un cookie.

  1. Le serveur envoie l'en-tête Set-Cookie (voir la réponse de la section 3).
  2. Le navigateur le garde, et le renvoie automatiquement dans l'en-tête Cookie de chaque requête vers ce site.
  3. Une session s'appuie sur ce mécanisme : le cookie ne contient qu'un identifiant au hasard (PHPSESSID), et les données (l'utilisateur connecté, le panier) restent sur le serveur.
Le cookie de session, requête après requête À la connexion, Symfony crée une session côté serveur et envoie son identifiant dans un cookie. Ensuite, le navigateur renvoie ce cookie à chaque requête, et Symfony relit la session correspondante. Navigateur Symfony Sessions fichiers, Redis… POST /login (e-mail, mot de passe) crée la session 8f2c… 302 · Set-Cookie: PHPSESSID=8f2c… puis, à chaque requête suivante : GET /panier · Cookie: PHPSESSID=8f2c… lit la session 8f2c… → Alice 200 · le panier d'Alice Le cookie ne contient qu'un identifiant : les données restent sur le serveur.
Schéma 9.2 · Le cookie de session, requête après requête

Les attributs d'un cookie décident où et comment il voyage :

exemples/09/session.yaml · illustration, non exécuté
# config/packages/framework.yaml : le cookie de session
framework:
    session:
        cookie_secure: auto      # Secure dès que la requête arrive en HTTPS
        cookie_httponly: true    # invisible pour JavaScript
        cookie_samesite: lax     # pas envoyé par les formulaires POST venus d'un autre site
Sécurité : le cookie de session est une clé

Qui possède l'identifiant de session est connecté à la place de l'utilisateur. Il ne doit donc jamais apparaître dans une URL, un log ou un message d'erreur. Et il doit changer à la connexion : sinon, un attaquant qui a fait adopter un identifiant connu à sa victime se retrouve connecté avec elle. C'est la fixation de session. Le composant Security de Symfony régénère l'identifiant à la connexion par défaut : ne désactive pas ce comportement.

Session ou jeton ?

Pour un site classique, la session avec un cookie HttpOnly est simple et sûre. Pour une API appelée par une application mobile ou un autre serveur, on utilise plutôt un jeton envoyé dans l'en-tête Authorization. Stocker un jeton dans le localStorage du navigateur, comme on le voit souvent, l'expose à n'importe quel script injecté dans la page.

5.HTTPS et TLS

HTTPS, c'est HTTP transporté dans une connexion TLS. TLS apporte trois garanties :

  1. La confidentialité : personne sur le chemin (le Wi-Fi du café, le fournisseur d'accès) ne peut lire les échanges.
  2. L'intégrité : personne ne peut les modifier en route, par exemple pour injecter une publicité ou un script.
  3. L'authentification du serveur : le certificat prouve que le serveur contrôle bien le nom de domaine.

Un certificat est signé par une autorité de certification que le navigateur connaît. Let's Encrypt en délivre gratuitement, avec une durée de vie courte, et des outils les renouvellent automatiquement. Le cadenas veut dire « la connexion avec ce domaine est protégée ». Il ne veut pas dire « ce site est honnête » : un site d'hameçonnage a aussi son certificat.

Une fois le site en HTTPS, l'en-tête HSTS demande au navigateur de ne plus jamais l'appeler en HTTP simple, même si l'utilisateur tape http:// :

exemples/09/hsts.http · illustration, non exécuté
HTTP/1.1 200 OK
Strict-Transport-Security: max-age=31536000; includeSubDomains
Attention avec HSTS

Le navigateur retient la consigne pendant toute la durée indiquée (ici un an). Avec includeSubDomains, un sous-domaine encore en HTTP simple devient inaccessible. Commence avec une durée courte, vérifie tous les sous-domaines, puis allonge.

6.Proxys, répartiteurs de charge et CDN

Entre le navigateur et Symfony, la requête traverse souvent plusieurs intermédiaires :

Une requête à travers les proxys, et ce que voit Symfony La requête passe par un CDN, un répartiteur de charge et nginx avant d'atteindre Symfony. Symfony voit l'adresse du dernier proxy, et l'adresse réelle du visiteur dans l'en-tête X-Forwarded-For, qu'il ne lit que si le proxy fait partie de trusted_proxies. Un attaquant peut envoyer lui-même un faux en-tête. Navigateur CDN Répartiteur de charge nginx 10.0.0.5 Symfony 198.51.100.7 Ce que reçoit Symfony REMOTE_ADDR = 10.0.0.5 (nginx) X-Forwarded-For = 198.51.100.7 lu seulement si 10.0.0.5 est dans trusted_proxies Attention Un attaquant peut envoyer lui-même un faux X-Forwarded-For
Schéma 9.3 · Une requête à travers les proxys, et ce que voit Symfony

Problème : pour Symfony, la requête vient maintenant de nginx, plus de l'utilisateur. L'adresse IP vue est celle du proxy, et le protocole est HTTP alors que l'utilisateur est en HTTPS. Les proxys transmettent les vraies valeurs dans les en-têtes X-Forwarded-*, mais Symfony ne les lit que si on le lui demande :

exemples/09/trusted_proxies.yaml · illustration, non exécuté
# config/packages/framework.yaml : faire confiance aux en-têtes X-Forwarded-*
# SEULEMENT quand ils viennent de nos propres proxys
framework:
    trusted_proxies: '10.0.0.0/8'   # le réseau privé où se trouvent nos proxys
    trusted_headers:
        - 'x-forwarded-for'
        - 'x-forwarded-proto'
        - 'x-forwarded-port'
        - 'x-forwarded-host'
Sécurité : un en-tête se falsifie

N'importe qui peut envoyer X-Forwarded-For: 1.2.3.4 dans sa requête. Si Symfony fait confiance à cet en-tête quelle que soit sa provenance, un attaquant choisit l'adresse IP que tu enregistres : il contourne une limitation par IP, une liste d'adresses autorisées, ou fausse tes logs. Ne déclare comme trusted_proxies que les adresses de tes proxys, et vérifie que l'application n'est pas joignable en les contournant.

7.Les API REST

Une API permet à un programme (une application mobile, un front JavaScript, un autre serveur) d'utiliser ton application. REST est un style de conception qui s'appuie entièrement sur HTTP : les ressources sont des noms dans l'URL, et les méthodes HTTP sont les verbes.

RequêteSensRéponse en cas de succès
GET /ordersLister les commandes200, avec une pagination
GET /orders/42Lire la commande 42200, ou 404 si elle n'existe pas
POST /ordersCréer une commande201, avec l'en-tête Location: /orders/43
PATCH /orders/42Modifier une partie de la commande 42200 avec la ressource, ou 204
DELETE /orders/42Supprimer la commande 42204
POST /orders/42/cancelUne action qui n'est pas un simple changement de champ200 ou 204
  1. Des noms, pas des verbes : /orders, pas /getOrders. La méthode HTTP dit déjà ce qu'on fait.
  2. Les bons codes de statut : un client d'API lit le code avant le corps. Une erreur renvoyée avec un code 200 casse tous les outils.
  3. Des erreurs dans un format standard, les problem details (RFC 9457), avec le type de contenu application/problem+json :
exemples/09/problem.json · illustration, non exécuté
{
    "type": "https://shop.example.com/errors/out-of-stock",
    "title": "Stock insuffisant",
    "status": 409,
    "detail": "Il ne reste que 1 exemplaire de « Souris », 2 demandés.",
    "instance": "/panier/articles"
}
  1. Une pagination pour toute liste qui peut grandir (fiche 7, section 11).
  2. Un versionnage (/v1/orders, ou un en-tête) dès qu'une API est utilisée par des clients que tu ne contrôles pas : tu ne peux pas les obliger à se mettre à jour le jour où tu changes un champ.

Dans Symfony, #[MapRequestPayload] transforme le corps JSON en objet et le valide (section 3), et $this->json() produit la réponse. Pour une API riche, API Platform génère une grande partie de ce travail.

Quand ne pas suivre REST à la lettre

REST colle bien aux ressources qu'on crée, lit, modifie et supprime. Pour une action métier (annuler, payer, renvoyer une facture), une route comme POST /orders/42/cancel est plus claire qu'un PATCH sur un champ status. Et pour une API interne utilisée par un seul front, la cohérence et la documentation comptent plus que la pureté du style.

8.CORS : quand le navigateur bloque ton API

Ton front est sur https://app.example.com, ton API sur https://api.example.com. Le code JavaScript du front appelle l'API, et le navigateur bloque la réponse, avec une erreur « CORS » dans la console.

C'est la politique de même origine : par défaut, un script ne peut lire que les réponses de sa propre origine, c'est-à-dire du même protocole, du même domaine et du même port. Sans elle, n'importe quel site que tu visites pourrait lire tes e-mails dans ton webmail, avec tes cookies. CORS (Cross-Origin Resource Sharing) permet au serveur d'autoriser explicitement certaines autres origines :

CORS : la permission demandée avant la vraie requête Le navigateur envoie d'abord une requête OPTIONS avec l'origine du script. Si l'API autorise cette origine, le navigateur envoie la vraie requête POST, et le script peut lire la réponse. Sinon, le navigateur bloque à la deuxième étape. Navigateur script de app.example.com API api.example.com ① OPTIONS /api/orders · Origin: https://app.example.com ② 204 · Allow-Origin: https://app.example.com ③ POST /api/orders (la vraie requête) ④ 201 · le script peut lire la réponse Origine refusée à l'étape ② : le navigateur bloque, le script ne reçoit rien. curl ou un autre serveur ignorent CORS : l'API doit vérifier les droits quand même.
Schéma 9.4 · CORS : la permission demandée avant la vraie requête
exemples/09/cors.http · illustration, non exécuté
# 1. Le navigateur, sur https://app.example.com, demande d'abord la permission (« preflight »)
OPTIONS /api/orders HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type

# 2. L'API répond quelles origines, méthodes et en-têtes elle accepte
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST
Access-Control-Allow-Headers: content-type
Access-Control-Max-Age: 3600

# 3. Seulement ensuite, le navigateur envoie la vraie requête POST

Dans Symfony, le bundle NelmioCorsBundle gère ces en-têtes à partir d'une configuration, plutôt que de les écrire à la main dans chaque contrôleur.

Sécurité : CORS n'est pas une protection de ton API
  1. CORS est appliqué par le navigateur, pour protéger l'utilisateur. Un script curl ou un autre serveur l'ignore complètement. Ton API doit donc toujours vérifier l'authentification et les droits.
  2. Access-Control-Allow-Origin: * autorise tous les sites. C'est acceptable pour des données publiques, sans cookie. Pour une API qui utilise les cookies de l'utilisateur, liste les origines autorisées une par une. Et ne renvoie jamais en écho l'en-tête Origin reçu sans le comparer à cette liste : ce serait autoriser tout le monde, cookies compris.

Pièges fréquents

PiègeCe qui arriveLa parade
Changer un DNS sans baisser le TTL avantUne partie des utilisateurs reste sur l'ancien serveur pendant des heuresBaisser le TTL un ou deux jours avant
Une action destructrice en GETUn robot ou un préchargement la déclenchePOST ou DELETE, avec protection CSRF
Une erreur renvoyée avec un code 200Les clients et les outils croient que tout va bienLe bon code 4xx ou 5xx, et des problem details
Confondre 401 et 403Le client ne sait pas s'il doit se connecter ou abandonner401 : non authentifié ; 403 : non autorisé
Un cookie de session sans HttpOnly ni SecureVolé par un script injecté, ou en HTTP simpleLa configuration de session de Symfony
Un jeton dans le localStorageLisible par n'importe quel script de la pageUn cookie HttpOnly, ou un jeton gardé en mémoire
Des sessions dans des fichiers avec plusieurs serveursL'utilisateur est déconnecté au hasardSessions dans Redis ou en base
Faire confiance à X-Forwarded-For partoutUn attaquant choisit son adresse IPtrusted_proxies limité à tes proxys
Access-Control-Allow-Origin: * par réflexeN'importe quel site appelle l'APIUne liste d'origines autorisées
Compter sur CORS pour protéger l'APIcurl l'appelle quand mêmeAuthentification et contrôle d'accès côté serveur

Checklist

Conseils

  1. Ouvre l'onglet Réseau des outils de développement de ton navigateur sur tes propres pages. Clique sur une requête : tu y retrouves la méthode, le statut, les en-têtes et les cookies de cette fiche. C'est le meilleur outil pour comprendre un bug HTTP.
  2. Utilise le profiler de Symfony : son panneau « Request / Response » montre ce que Symfony a reçu et renvoyé, y compris les en-têtes ajoutés par les proxys.
  3. Lis les messages d'erreur CORS en entier. La console du navigateur dit précisément quel en-tête manque ou quelle origine a été refusée.
  4. Commence petit avec les API. Quelques routes bien nommées, avec les bons codes et des erreurs claires, valent mieux qu'un grand cadre théorique.

Documentation officielle