Un paramètre URL non assaini contrôle la destination d'une redirection côté serveur ou client, permettant phishing et vol de tokens OAuth.
TL;DR
?next=, ?redirect=, ?goto= directement à res.redirect() sans validation d'hôte//evil.com, \evil.com, %09https://evil.com, https://trusted.com@evil.comSet exact — jamais endsWith ou includesLa redirection par paramètre URL est la forme la plus répandue de redirection ouverte (CWE-601). L'application extrait une URL d'un paramètre de requête et la passe directement à la fonction de redirection du serveur — res.redirect(req.query.next) dans Express, return redirect(request.args["next"]) dans Flask, HttpResponseRedirect(request.GET["next"]) dans Django — sans valider si l'hôte de destination est sur une allowlist.
La vulnérabilité est classifiée sous OWASP A01:2021 (Broken Access Control) car l'application ne parvient pas à appliquer le contrôle d'accès sur les domaines valides comme destinations de redirection. Noms de paramètres courants par ordre de fréquence : next, redirect, redirect_uri, redirect_url, url, goto, target, dest, destination, return, returnTo, forward, link, continue, callback, success, failure, r, u.
Le code côté serveur vulnérable en Express.js :
// VULNÉRABLE — passe l'entrée utilisateur directement à redirect
app.get("/login", (req, res) => {
// Logique d'authentification...
const next = req.query.next || "/dashboard";
res.redirect(next); // Pas de validation d'hôte — redirection ouverte
});GET /login?next=https://attacker.com/capture HTTP/1.1
Host: app-de-confiance.com
HTTP/1.1 302 Found
Location: https://attacker.com/capture
Set-Cookie: session=...https://app-de-confiance.com/login?next=https://attacker.com
https://app-de-confiance.com/logout?redirect=https://attacker.com
https://app-de-confiance.com/auth/callback?return=https://attacker.com# Ces payloads exploitent l'écart entre validation Python et parsing navigateur
from urllib.parse import urlparse
urlparse("\\evil.com").netloc # '' — Python voit chemin relatif, navigateur voit evil.com
urlparse("%09https://evil.com").netloc # '' — tab supprimé, schéma vide pour Python
# Mais WHATWG normalise : \\evil.com → https://evil.com, %09 supprimé → https://evil.com# Protocol-relative (navigateur résout en https://attacker.com)
?next=//attacker.com
# Normalisation antislash (WHATWG normalise \\ en /)
?next=\attacker.com
# Préfixe caractère de contrôle
?next=%09https://attacker.com
# Abus userinfo (trusted-app.com est le nom d'utilisateur, attacker.com est l'hôte)
?next=https://trusted-app.com@attacker.com
?next=https://trusted-app.com%40attacker.com
# Abus de suffixe (passe la vérification endsWith, mais l'hôte est attacker.com)
?next=https://trusted-app.com.attacker.com
# Double encodage
?next=%2540attacker.com
# Null byte
?next=https://attacker.com%00.trusted-app.comLe bypass le plus dangereux est https://trusted-app.com@attacker.com. Les revues de code manquent souvent cela car trusted-app.com est littéralement présent dans l'URL. La méthode Python urllib.parse.urlparse("https://trusted-app.com@attacker.com").hostname retourne correctement attacker.com — mais les validateurs utilisant url.includes("trusted-app.com") ou url.startsWith("https://trusted-app.com") accepteront cela comme sûr.
CVE-2025-69725 — go-chi RedirectSlashes (CVSS 6.1)
Le middleware RedirectSlashes de go-chi normalisait les chemins à double slash. GET //evil.com HTTP/1.1 était interprété comme un chemin nécessitant normalisation, produisant 302 Location: //evil.com. Les navigateurs résolvent //evil.com en https://evil.com. CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:L/A:N.
CVE-2025-4123 + CVE-2025-6023 — Grafana (CVSS 7.6 chacun)
L'endpoint /public/dashboards/ de Grafana traitait le contenu de la chaîne de requête comme un composant URL. GET /public/dashboards/?attacker.com produisait une redirection vers attacker.com. Le correctif initial (CVE-2025-4123) utilisait une validation par chaîne qui fut contournée (CVE-2025-6023) car la validation opérait sur l'entrée brute plutôt que sur la forme normalisée WHATWG.
CVE-2024-39322 — Strapi (CVSS 6.1)
Strapi CMS passait les paramètres URL contrôlés par l'utilisateur directement à response.redirect(). Les administrateurs authentifiés pouvaient être redirigés vers des pages de phishing via des URLs d'interface admin forgées. Corrigé en ajoutant une validation d'allowlist d'hôte dans le gestionnaire de redirection.
CVE-2021-29156 — ForgeRock OpenAM paramètre goto (CVSS 9.8)
Le paramètre goto dans les flux d'authentification et OAuth de ForgeRock acceptait des URLs externes arbitraires. Le code d'autorisation était ajouté à l'URL goto avant la redirection — goto=https://attacker.com?code=CODE_AUTH. L'attaquant échangeait le code contre des tokens. CVSS 9.8 car la chaîne contournait l'authentification entièrement.
gf redirect (tomnomnom/gf) contre les données Wayback Machine du domaine cible.Location pour du contenu fourni par l'utilisateur.Location contient-il votre canary ? Le navigateur navigue-t-il là ?//attacker.com, \attacker.com, %09https://attacker.com, https://trusted-app.com@attacker.com.# OpenRedireX — fuzzer async avec 50+ payloads de bypass
cat urls.txt | openredirex -p /opt/open-redirect-payloads.txt
# nuclei — templates CVE + fuzzing de paramètres
nuclei -l targets.txt -t fuzzing/redirect-params.yaml
nuclei -l targets.txt -t cves/2025/CVE-2025-69725.yaml
# dalfox v3+ mode open-redirect
dalfox url "https://target.com/login?next=1" --open-redirect
# gf + waybackurls pour la découverte de paramètres
waybackurls target.com | gf redirect | sort -u# Flask — BONNE PRATIQUE
from urllib.parse import urlparse
ALLOWED_HOSTS = frozenset({"app.example.com", "dashboard.example.com"})
def _safe_redirect(url: str, fallback: str = "/dashboard") -> str:
if not url:
return fallback
# Rejeter les caractères de contrôle
if any(c in url for c in ["\r", "\n", "\t", "\x00"]):
return fallback
# Rejeter l'antislash (WHATWG normalise en /, Python ne le fait pas)
if "\\" in url:
return fallback
parsed = urlparse(url)
# Autoriser les URLs relatives commençant par /
if not parsed.netloc and url.startswith("/") and not url.startswith("//"):
return url
# Les URLs externes doivent être sur l'allowlist
if parsed.netloc not in ALLOWED_HOSTS:
return fallback
return url
@app.route("/login")
def login():
next_url = request.args.get("next", "/dashboard")
return redirect(_safe_redirect(next_url))// Express.js — BONNE PRATIQUE
const ALLOWED_HOSTS = new Set(["app.example.com", "dashboard.example.com"]);
function isSafeRedirect(url) {
if (!url) return false;
if (url.startsWith("/") && !url.startsWith("//")) return true;
try {
// Constructeur WHATWG URL — normalise de la même façon que les navigateurs
const parsed = new URL(url);
return ALLOWED_HOSTS.has(parsed.hostname);
} catch {
return false;
}
}
app.get("/login", (req, res) => {
const next = req.query.next;
const destination = isSafeRedirect(next) ? next : "/dashboard";
res.redirect(destination);
});# Stocker la destination avant la redirection — aucune URL dans le paramètre
import secrets
import redis
redis_client = redis.Redis()
@app.route("/pre-login")
def pre_login():
state = secrets.token_urlsafe(32)
redis_client.set(f"redirect:{state}", "/user/dashboard", ex=300)
return redirect(f"/login?state={state}")
@app.route("/post-login")
def post_login():
state = request.args.get("state", "")
raw = redis_client.get(f"redirect:{state}")
destination = raw.decode() if raw else "/dashboard"
redis_client.delete(f"redirect:{state}")
return redirect(destination)Ce pattern élimine complètement la surface d'attaque. Il n'existe aucun paramètre URL contenant une destination de redirection — seulement un token opaque mappé vers un chemin stocké côté serveur.
Une redirection par paramètre URL se produit quand un paramètre de requête — next, return, redirect, goto, url, dest, callback — est passé directement à la fonction de redirection du serveur sans valider l'hôte de destination. Le serveur émet une réponse HTTP 30x avec Location contenant l'URL contrôlée par l'attaquant, et le navigateur la suit.
Par ordre de fréquence : next, redirect, redirect_uri, redirect_url, url, goto, target, dest, destination, return, returnTo, forward, link, continue, callback, success, failure, r, u, redir, ref, return_url, back. Les paramètres next et redirect représentent la majorité des redirections ouvertes reportées.
Les payloads de bypass exploitent l'écart entre la validation URL côté serveur (Python urllib.parse ou Node.js url.parse) et le parsing navigateur (WHATWG). Python urlparse traite \evil.com comme un chemin relatif (pas de netloc) ; WHATWG normalise l'antislash en slash et résout en https://evil.com. Le préfixe tabulation (%09https://evil.com) cause l'abandon du schéma par Python, tandis que le navigateur supprime le tab et résout l'URL absolue.
Le middleware RedirectSlashes de go-chi gérait les chemins à double slash en les normalisant et émettant un 302. Une requête GET //evil.com était traitée comme un chemin nécessitant normalisation, produisant 302 Location: //evil.com. Les navigateurs résolvent les URLs protocol-relative — //evil.com devient https://evil.com. CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:L/A:N (6.1).
Les redirections 301 (Permanently Moved) sont mises en cache par les navigateurs — une redirection exploitée peut persister après correction de la vulnérabilité. Les redirections 302 ne sont pas mises en cache par défaut. D'un point de vue sécurité, les redirections ouvertes 301 sont pires car elles ne peuvent pas être immédiatement corrigées sans vider également les caches navigateur.
Le composant userinfo URL apparaît avant le symbole @ : https://user:pass@host.com. Un attaquant crée https://trusted.com@evil.com — l'hôte RFC 3986 est evil.com, et trusted.com est le nom d'utilisateur. Un validateur vérifiant si trusted.com apparaît dans l'URL le marque comme sûr. WHATWG et la plupart des navigateurs parsent correctement l'hôte comme evil.com. Correction : n'inspecter que le hostname parsé, jamais chercher des chaînes autorisées dans l'URL brute.
Les redirections basées sur le chemin suivent des motifs comme /go/https://evil.com, /redirect/https://evil.com, /out?url=, /r/<id>. Tester en remplaçant le segment de chemin ou la valeur de requête par une URL canary. Vérifier aussi le double encodage : /go/%68%74%74%70%73%3A%2F%2Fevil.com. Utiliser nuclei avec fuzzing/redirect-params.yaml et étendre avec des motifs basés sur le chemin.
Utiliser un token d'état opaque. Avant d'initier la connexion, stocker la destination côté serveur indexée par un token aléatoire (Redis, session). Après authentification, lire la destination depuis le stockage côté serveur en utilisant le token. Ne jamais passer l'URL de destination dans un paramètre. Cela élimine complètement la surface d'attaque.
BreachVex injecte des URLs canary out-of-band uniques dans tous les paramètres de redirection découverts pendant la reconnaissance. Un finding confirmé nécessite une réponse 30x où Location contient le domaine canary, plus vérification eTLD+1 pour éliminer les faux positifs same-origin. Le scan vérifie également les valeurs Location contre l'allowlist CDN et partenaires OAuth de la cible.
Grafana CVE-2025-4123 est noté CVSS 7.6 (Élevé). L'endpoint /public/dashboards/ traitait le contenu de la chaîne de requête comme une cible de redirection via une confusion de parsing chemin/requête. Combiné avec la gestion des cookies de session de Grafana, cela permettait une prise de contrôle de compte. CVE-2025-6023 (également CVSS 7.6) était un bypass du correctif initial — démontrant l'insuffisance des validations par chaîne sans normalisation WHATWG préalable.