Québec, Canada

403-1381 1re Avenue

+1 581.849.27.96

bdgouthiere@gmail.com

Demander un magic link sans révéler qui existe

Ou : Comment tenir un guichet qui répond à tout le monde sans rien dire à personne

Il existe une question qui vaut de l’argent et que presque tous les sites posent gratuitement : cette adresse a-t-elle un compte ici ? Pour un attaquant, la réponse trie une liste de dix mille adresses achetées en deux tas, celles qui méritent un hameçonnage ciblé et les autres. Pour un concurrent, elle dessine ta base clients. Et ton formulaire de connexion par magic link, celui qui reçoit une adresse et envoie un lien, est exactement l’endroit où cette question se pose. Il suffit qu’il réponde un peu différemment selon les cas, par le texte, par le code HTTP ou par le temps qu’il met à répondre.

Le deuxième article de la série, sur le token de magic link, a fabriqué le secret et l’a rangé sous forme d’empreinte. Celui-ci construit l’endpoint qui le demande : POST /auth/magic-link. Il doit envoyer un lien aux bonnes personnes, ne rien révéler aux autres, résister à quelqu’un qui l’appelle mille fois par minute, et rester testable sans serveur de mail. Le premier article avait rangé la « réponse constante » parmi les cinq propriétés d’un bon lien. C’est ici qu’on la paie.

Le guichet trop bavard

Voici la version naturelle, celle qu’on écrit un vendredi et qui fonctionne parfaitement.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// The helpful version. Helpful to everyone.
func (s *Service) handleRequest(w http.ResponseWriter, r *http.Request) {
	email := r.FormValue("email")
	userID, found, err := s.lookup(r.Context(), email)
	if err != nil {
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	if !found {
		http.Error(w, "Aucun compte associé à cette adresse", http.StatusNotFound)
		return
	}
	raw, _ := s.issue(r.Context(), userID)
	s.mailer.SendMagicLink(r.Context(), email, s.baseURL+"/auth/verify?token="+raw)
	w.Write([]byte("Lien envoyé !"))
}

Ce handler répond honnêtement, et c’est tout le problème. Un 404 pour une adresse inconnue, un 200 pour une adresse connue : n’importe qui peut interroger ta base clients une adresse à la fois, sans jamais recevoir un seul mail. Les recommandations de l’OWASP sur la réinitialisation de mot de passe, qui est exactement la même mécanique, tiennent en une phrase : « Return a consistent message for both existent and non-existent accounts ».

La correction évidente consiste à répondre la même chose dans les deux cas. Le code qui convient s’appelle 202 Accepted, que la RFC 9110 définit ainsi : la requête « has been accepted for processing, but the processing has not been completed ». C’est littéralement vrai, puisque le mail n’est pas encore parti. Et le corps de la réponse est une seule phrase, la même pour tout le monde.

1
2
// The only answer this endpoint ever gives to a well-formed request.
var acceptedBody = []byte(`{"message":"If this address has an account, a link is on its way."}` + "\n")

Une précision, parce qu’on me la pose souvent : une adresse mal formée peut recevoir un 400. Elle ne dit rien sur l’existence d’un compte, seulement sur la syntaxe, et l’utilisateur qui a tapé jean.dupont@gmail,com mérite de le savoir.

Le chronomètre, l’autre oracle

Supposons Dave corrigé : même code, même corps, même en-tête pour tout le monde. Il reste un canal qu’aucun texte ne ferme. Pour une adresse inconnue, son handler cherche l’utilisateur, ne le trouve pas et répond aussitôt. Pour une adresse connue, il génère un token, écrit en base, puis parle à un serveur SMTP. La deuxième réponse arrive nettement plus tard que la première, et l’écart se mesure depuis n’importe quel script.

L’OWASP prévoit le cas, et propose deux sorties : faire en sorte que les réponses reviennent « in a consistent amount of time », soit par des appels asynchrones, soit en suivant la même logique dans tous les cas « instead of using a quick exit method »1. Égaliser les durées à coups de time.Sleep est une course perdue d’avance : il faudrait connaître la durée du chemin le plus lent, et elle change avec la charge de la base et l’humeur du relais SMTP. Je préfère la première sortie, parce qu’elle règle le problème par construction : le handler ne fait plus rien qui dépende de l’existence du compte.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
func (h *Requester) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	var in struct {
		Email string `json:"email"`
	}
	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<10)).Decode(&in); err != nil {
		http.Error(w, "invalid request", http.StatusBadRequest)
		return
	}
	email, ok := normalizeEmail(in.Email)
	if !ok {
		// A malformed address says nothing about who has an account.
		http.Error(w, "invalid email", http.StatusBadRequest)
		return
	}

	// Per IP: visible, because it is about the caller, not the account.
	if !h.perIP.Allow(clientIP(r)) {
		w.Header().Set("Retry-After", strconv.Itoa(60))
		http.Error(w, "too many requests", http.StatusTooManyRequests)
		return
	}

	// Per address: silent, otherwise the 429 becomes the oracle.
	if h.perEmail.Allow(email) {
		select {
		case h.queue <- email:
		default:
			h.log.Warn("magic link queue full", "email", h.pseudonym(email))
		}
	}

	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusAccepted)
	w.Write(acceptedBody)
}

Le handler valide, limite, dépose l’adresse dans une file et répond. Il ne touche ni à la base ni au mail. Le travail qui prend du temps se fait ailleurs, dans une goroutine qui vide la file.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
func (s *Service) send(ctx context.Context, email string) error {
	userID, found, err := s.lookup(ctx, email)
	if err != nil || !found {
		return err // unknown address: nothing to send, nothing to say
	}
	raw, err := s.issue(ctx, userID)
	if err != nil {
		return err
	}
	return s.mailer.SendMagicLink(ctx, email, s.baseURL+"/auth/verify?token="+raw)
}

Deux remarques. lookup n’est pas un quatrième contrat du paquet auth : c’est une fonction fournie par le reste de l’application, qui sait où vivent les comptes, et les trois interfaces du premier article n’ont pas bougé. Ensuite, un chan string en mémoire perd son contenu au redémarrage du serveur. Pour un projet modeste, un utilisateur qui ne reçoit rien redemande un lien trente secondes plus tard. Pour un service qui ne peut pas se le permettre, la file devient une table Postgres, lue par le même travailleur.

Deux limites, une visible et une muette

Reste l’appelant qui ne cherche pas à savoir qui existe, mais à t’utiliser comme canon à mails : il soumet l’adresse de sa victime cinq cents fois, et c’est ton domaine qui risque de finir sur les listes de spam. L’OWASP recommande là encore une limitation de débit par compte1. En pratique, il en faut deux, et elles ne se comportent pas de la même façon.

Deux limites de débit : l'une allume sa lampe, l'autre ne dit rien.

La limite par adresse IP est visible. Elle répond 429 Too Many Requests avec un en-tête Retry-After, comme le prévoit la RFC 6585. Elle ne révèle rien sur les comptes, seulement sur le comportement de l’appelant, et un humain qui s’acharne sur le bouton mérite de savoir qu’il doit attendre. La limite par adresse, elle, est muette : au-delà du seuil, la requête reçoit le même 202 que les autres, et rien n’est envoyé. C’est le choix que j’avais fait dans le retour d’expérience de Presentation Control, et le dialogue qui suit explique pourquoi un 429 à cet endroit ruinerait tout le reste.

Pour les compteurs, golang.org/x/time/rate fournit ce qu’il faut. Sa documentation décrit un limiteur qui « implements a “token bucket” of size b, initially full and refilled at rate r tokens per second » : un seau de jetons, plein au départ, qui se remplit à vitesse constante. Il suffit d’en tenir un par clé.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func (l *Limiters) Allow(key string) bool {
	now := l.clock.Now()
	l.mu.Lock()
	defer l.mu.Unlock()
	b, ok := l.buckets[key]
	if !ok {
		b = &bucket{lim: rate.NewLimiter(rate.Every(l.every), l.burst)}
		l.buckets[key] = b
	}
	b.seen = now
	return b.lim.AllowN(now, 1)
}

Avec NewLimiters(5*time.Minute, 3, clock) pour les adresses, chacune peut recevoir trois liens d’affilée, puis un toutes les cinq minutes. Le limiteur reçoit l’heure de l’interface Clock au lieu de la lire lui-même, ce qui permet de tester le rechargement sans attendre cinq minutes. Une méthode Sweep, appelée par un time.Ticker, oublie les clés inactives : sans elle, la table grossit de chaque adresse jamais tapée dans le formulaire, y compris les dix mille de l’attaquant.

Deux pièges attendent ce code en production. Le premier est l’adresse IP. Derrière un proxy inverse, RemoteAddr est celle du proxy, et la tentation est de lire X-Forwarded-For. Or cet en-tête est fourni par le client, et MDN prévient qu’une valeur non ajoutée par un proxy de confiance peut mener au « rate-limiter avoidance »2. On ne lit que la partie ajoutée par son propre proxy. Le second est le nombre d’instances : chaque processus tient ses propres seaux, et trois instances derrière un répartiteur de charge laissent passer trois fois plus de requêtes. À partir de là, les compteurs doivent vivre dans un stockage partagé, Redis ou Postgres3.

Le moment où Dave compte ses 429

DevOps Dave : J’ai tout corrigé. Même message pour tout le monde, 202 partout, et j’ai ajouté une limite : trois liens par adresse, sinon 429 avec « Trop de demandes pour cette adresse ».

Security Sarah : Ta limite, tu la vérifies avant ou après avoir cherché le compte ?

DevOps Dave : Après, évidemment. Je ne vais pas compter les demandes pour des adresses qui n’existent pas, ça ne sert à rien.

Security Sarah : Donc j’envoie quatre fois la même adresse. Si la quatrième répond 429, le compte existe. Si elle répond 202, il n’existe pas.

DevOps Dave : Mais c’est un mécanisme de sécurité.

Security Sarah : C’est un mécanisme de sécurité qui répond à la question que ton 202 refusait de trancher. Tu as déplacé l’oracle de la première requête à la quatrième.

Dave n’a pas écrit de bug. Il a optimisé un compteur. La règle qui en découle est simple à énoncer et facile à oublier : tout ce que l’endpoint fait de visible doit dépendre uniquement de ce que l’appelant a envoyé, jamais de ce que la base contient.

Le Mailer, et le faux qui dit la vérité

L’interface Mailer du premier article n’a qu’une méthode, SendMagicLink(ctx, email, link). Elle accepte deux implémentations réelles et une fausse.

La première passe par un relais SMTP avec net/smtp. La bibliothèque standard la fournit, mais sa documentation précise que le paquet est « frozen and is not accepting new features », et que SendMail bascule en TLS « if possible », c’est-à-dire seulement si le serveur le propose4. Pour un relais sur ton propre réseau, ça suffit. Pour un envoi à travers Internet, la seconde implémentation est souvent plus raisonnable : une API transactionnelle appelée en HTTPS, qui prend souvent en charge la réputation du domaine d’envoi et le suivi des rebonds. Le service ne voit pas la différence.

La fausse implémentation est la plus utile des trois.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// FakeMailer records what would have been sent. Tests read it, nobody receives it.
type FakeMailer struct {
	mu   sync.Mutex
	Sent []SentMail
}

type SentMail struct{ Email, Link string }

func (f *FakeMailer) SendMagicLink(_ context.Context, email, link string) error {
	f.mu.Lock()
	defer f.mu.Unlock()
	f.Sent = append(f.Sent, SentMail{email, link})
	return nil
}

C’est le principe du faux derrière une interface, et il permet d’écrire le test qui compte vraiment pour cet article : une adresse connue et une adresse inconnue reçoivent exactement la même réponse, octet pour octet, et une seule des deux reçoit un mail. Avec httptest et son NewRecorder, aucun serveur n’est lancé.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
func TestSameAnswerForKnownAndUnknown(t *testing.T) {
	hs := newHarness("alice@example.com")

	known := post(hs.h, "192.0.2.1", `{"email":"alice@example.com"}`)
	unknown := post(hs.h, "192.0.2.1", `{"email":"nobody@example.com"}`)

	if known.Code != http.StatusAccepted || unknown.Code != known.Code {
		t.Fatalf("status differs: known %d, unknown %d", known.Code, unknown.Code)
	}
	if !bytes.Equal(known.Body.Bytes(), unknown.Body.Bytes()) {
		t.Fatalf("body differs:\n%s\n%s", known.Body, unknown.Body)
	}

	hs.drain(t)
	if hs.mailer.Count() != 1 || hs.mailer.Sent[0].Email != "alice@example.com" {
		t.Fatalf("expected one mail to alice, got %+v", hs.mailer.Sent)
	}
}

Les autres tests suivent le même moule : la limite par adresse reste muette et plafonne les envois à trois, puis en laisse passer un quatrième quand la fausse horloge avance de cinq minutes ; la limite par IP renvoie bien un 429 avec Retry-After ; cinquante goroutines qui demandent le même lien en même temps ne produisent que trois mails.

Un mail qui ne ressemble pas à du phishing

Le message lui-même mérite autant de soin que l’endpoint, parce que c’est lui que l’utilisateur voit. Il porte un seul lien, affiche sa durée de validité, dit quoi faire si on n’a rien demandé, et existe en texte brut en plus du HTML.

1
2
3
text := fmt.Sprintf("Voici votre lien de connexion, valable %d minutes :\n\n%s\n\n"+
	"Vous n'avez rien demandé ? Ignorez ce message, personne ne se connectera sans ce lien.\n",
	int(ttl.Minutes()), link)

La version texte n’est pas une politesse envers les clients de messagerie des années 1990. Elle garantit que le lien reste lisible et copiable quand le HTML est bloqué, et elle rend le message moins suspect aux yeux de l’utilisateur, qui voit l’URL complète plutôt qu’un bouton. Les deux versions partent dans un seul message multipart/alternative, construit avec mime/multipart.

Ce que le message ne contient pas compte tout autant : aucune image distante et aucun pixel de suivi. Un mail de connexion n’est pas une campagne marketing, et savoir si quelqu’un a ouvert son lien avant de cliquer n’apporte rien à la sécurité. En revanche, c’est une donnée de plus qu’il faudrait justifier, stocker et protéger.

Journaliser sans ficher

Il faut bien garder une trace de ce qui se passe, ne serait-ce que pour comprendre pourquoi un utilisateur jure n’avoir jamais reçu son lien. log/slog, arrivé dans la bibliothèque standard avec Go 1.21 en août 2023, fait le travail. La question est ce qu’on y écrit.

L’adresse en clair, non : un journal est copié, expédié vers un service tiers, conservé plus longtemps que prévu, et il devient une deuxième base de comptes, moins bien gardée que la première. Le réflexe suivant consiste à la hacher en SHA-256, et c’est une fausse bonne idée. Une empreinte d’adresse se renverse en calculant les empreintes d’une liste d’adresses et en comparant, ce que la FTC américaine a résumé dans un billet au titre explicite5. Le RGPD ne s’y trompe pas non plus : une donnée qu’on peut rattacher à une personne avec une information supplémentaire reste une donnée personnelle, comme le rappelle la CNIL à propos de la pseudonymisation.

La bonne réponse est un HMAC avec une clé que seul le serveur connaît.

1
2
3
4
5
6
7
8
// pseudonym gives logs a stable identifier without the address itself.
// HMAC with a server key, because a plain SHA-256 of an email is reversible
// by anyone holding a list of addresses.
func (h *Requester) pseudonym(email string) string {
	m := hmac.New(sha256.New, h.logKey)
	m.Write([]byte(email))
	return hex.EncodeToString(m.Sum(nil))[:16]
}

Le même utilisateur produit toujours le même identifiant, ce qui suffit pour suivre une série d’échecs. Sans la clé, la liste d’adresses de l’attaquant ne sert plus à rien. C’est toujours une pseudonymisation, pas une anonymisation, mais c’est une pseudonymisation qui ne se défait pas avec un fichier CSV.

Un dernier morceau manque, et il est volontaire. Un lien demandé depuis un navigateur peut être cliqué depuis un autre, transféré, ou ouvert par un scanner de sécurité de messagerie. Lier la demande au navigateur qui l’a faite, avec un cookie posé au moment de la demande, fermerait une partie de ces portes. Mais ce cookie n’a de sens qu’avec sa vérification, et elle appartient au quatrième article, avec la consommation du token et l’ouverture de la session.

Tout le code de cet article a été compilé et testé sous Go 1.27, go vet et le détecteur de courses compris. Il ne dépend que de la bibliothèque standard et de golang.org/x/time/rate.

À la fin, l’endpoint répond la même phrase à tout le monde, dans le même délai, avec le même code. L’attaquant qui lui soumet sa liste d’adresses repart avec une seule certitude, que tout le monde avait déjà : ton site existe, et il est poli.



  1. La fiche OWASP sur l’authentification formule le même problème côté connexion classique : la logique métier elle-même peut créer un écart, parce que « the processing time can be significantly different according to the case (success vs failure) allowing an attacker to mount a time-based attack ». Son exemple est celui de la sortie rapide quand l’utilisateur n’existe pas, et la correction consiste à parcourir le même chemin quoi qu’il arrive. Sur la limitation, la fiche consacrée à la réinitialisation de mot de passe cite explicitement « rate-limiting on a per-account basis », un CAPTCHA ou d’autres contrôles. ↩︎ ↩︎

  2. La page de MDN sur l’en-tête X-Forwarded-For est d’une netteté rare : si le serveur est joignable directement depuis Internet, même s’il est aussi derrière un proxy de confiance, aucune partie de l’en-tête ne peut servir à un usage de sécurité. Sinon, seules les adresses ajoutées par ton propre proxy comptent, en lisant la liste depuis la droite. Un limiteur qui croit la valeur envoyée par le client limite surtout les attaquants qui n’ont pas pensé à la changer. ↩︎

  3. Pour Redis, la bibliothèque go-redis/redis_rate, sous licence BSD-2-Clause, implémente l’algorithme GCRA, dit aussi du seau percé, et garde l’état dans Redis pour toutes les instances. Si Redis n’est pas déjà dans l’architecture, une table Postgres avec un compteur par clé et par fenêtre de temps fait le travail pour le débit d’un formulaire de connexion, et elle évite d’ajouter un service à surveiller pour une seule fonctionnalité. ↩︎

  4. La même documentation précise un garde-fou utile : PlainAuth n’envoie les identifiants que si la connexion est chiffrée en TLS ou établie vers localhost, sinon l’authentification échoue « without sending the credentials ». Le mot de passe du relais ne part donc jamais en clair. Le message lui-même, en revanche, part en clair si le relais ne propose pas STARTTLS et n’exige pas d’authentification, ce qui mérite d’être vérifié avant de le choisir. ↩︎

  5. Le billet de l’Office of Technology de la FTC, « No, hashing still doesn’t make your data anonymous », publié en juillet 2024, rappelle qu’une empreinte reste « a unique signature that can track a person or device over time », et que des empreintes de numéros de sécurité sociale américains se renversent aujourd’hui en quelques secondes. Une adresse mail ne s’énumère pas comme un numéro à neuf chiffres, mais l’attaquant n’en a pas besoin : les listes d’adresses circulent, et il lui suffit de hacher les siennes pour comparer. ↩︎