Votre lien d’invitation est un mot de passe collé dans Slack

security

Un nouvel ingénieur arrive lundi. Vous collez le lien d’invitation dans le canal d’onboarding, il clique, et le voilà dans le projet — pas d’aller-retour par e-mail, pas d’attente d’un mail de vérification tombé dans les spams.

Quatre mois plus tard, le canal existe toujours, et le lien aussi. Il fonctionne encore. Il fonctionne pour le prestataire ajouté au canal la sixième semaine, pour tous ceux à qui il l’a transféré, et pour quiconque ouvrira un jour l’archive exportée.

Un lien d’invitation est un secret au porteur. Il ne sait pas qui vous êtes, seulement que vous l’avez. C’est tout son intérêt, et tout le problème. Voici ce qu’un lien doit porter avant de pouvoir être collé n’importe où sans risque, ce que j’ai livré dans Kookee, et les parties plus difficiles qu’elles n’en avaient l’air.

Une invitation nomme une personne, un lien non

Une invitation par e-mail a un destinataire. Le système sait déjà qui a le droit d’entrer — l’adresse à laquelle il a envoyé le mail —, et le jeton contenu dans ce mail ne sert à personne d’autre. C’est précis, et c’est lent : quelqu’un doit saisir les adresses, le mail doit arriver, le destinataire doit le trouver.

Un lien fait le compromis inverse. Il est instantané et n’a pas de destinataire. Celui qui l’ouvre est celui qu’il fait entrer.

La plupart des équipes veulent les deux, pour la même raison qu’on veut à la fois une liste d’invités et une porte. L’erreur, c’est de livrer le lien comme s’il s’agissait de l’invitation, la saisie en moins.

Quatre questions auxquelles un lien doit répondre

Qui peut l’utiliser ? Sans réponse, la description honnête de la fonctionnalité est : « n’importe qui sur Internet qui voit cette chaîne rejoint votre projet ». Un lien qu’on peut restreindre aux domaines qu’utilisent vraiment les adresses de vos collègues n’est pas le même objet qu’un lien qui ne le peut pas.

Combien de temps reste-t-il ouvert ? Tout lien qui n’expire jamais finit par devenir un lien que personne ne se souvient d’avoir créé. La valeur par défaut compte plus que l’option : si « jamais » est la valeur par défaut, la plupart des liens seront réglés sur « jamais ».

Qui peut le reprendre ? La révocation doit tenir en un clic, et la liste des liens actifs doit se trouver là où quelqu’un ira regarder. Un secret qu’on ne peut pas lister est un secret qu’on ne peut pas révoquer.

Que donne-t-il ? La réponse devrait être le plus petit rôle utile, et toujours celui-là. Un lien capable de distribuer des droits d’administrateur est un lien capable de distribuer le pouvoir de créer d’autres liens.

Une règle de domaine ne vaut rien sans adresse vérifiée

La façon évidente de restreindre un lien est de le limiter à un domaine d’e-mail. Ce qui l’est moins : cette restriction n’a de sens que si l’adresse a été vérifiée.

TypeScript
if (link.allowedDomains?.length) {
  const allowed = link.allowedDomains.includes(domainOf(user.email));
  if (!user.emailVerified || !allowed) throw Forbidden('INVITE_LINK_DOMAIN_MISMATCH');
}

Retirez la vérification emailVerified et la fonctionnalité s’inverse : n’importe qui ouvre le lien, s’inscrit en tant que someone@yourcompany.com, n’ouvre jamais le mail de confirmation, et entre. La règle de domaine ne serait plus alors qu’une indication sur les personnes que vous espériez voir arriver.

Deux décisions plus modestes se cachent en dessous.

Les entrées de la liste d’autorisation sont comparées, pas lues par un humain. Un « а » cyrillique est un autre caractère qu’un « a » latin, et il s’affiche à l’identique : un homoglyphe dans une liste d’autorisation est une porte ouverte qui a l’air fermée. Les entrées sont faites de lettres ASCII, de chiffres et de tirets, et un domaine internationalisé s’y saisit sous sa forme punycode :

TypeScript
// Lettres ASCII, chiffres et tirets, avec un TLD. Les domaines IDN se saisissent sous la forme xn--…
const DOMAIN = /^(?!-)[a-z0-9-]{1,63}(?<!-)(\.(?!-)[a-z0-9-]{1,63}(?<!-))+$/;

Le domaine que vous stockez doit être celui que vous comparez. Kookee normalise les adresses à l’entrée, et googlemail.com désigne la même boîte que gmail.com. Une règle saisie comme googlemail.com ne correspondrait donc jamais à personne : elle a l’air juste, s’enregistre sans erreur, et refuse tout le monde sans rien dire. Normaliser la règle de la même façon au moment de sa création tient en trois lignes, et évite un fil de support impossible à diagnostiquer de l’extérieur.

Un lien survit à la personne qui l’a créé

Un admin crée un lien en mars. En juin, il change d’équipe et redevient simple membre, ou il part et il est retiré du projet, ou il est banni. Le lien, lui, est toujours dans le canal.

Deux conceptions se défendent. Vérifier le créateur au moment de l’arrivée — lire son rôle actuel à chaque arrivée et refuser s’il ne l’a plus — ou révoquer le lien au moment de l’événement. La première paraît plus correcte et elle est moins bonne : elle ajoute une requête à chaque arrivée, et elle doit répondre à des questions gênantes sur ce que vaut le lien d’un propriétaire une fois que le projet a changé de mains.

Je révoque au moment de l’événement. Retirer un membre, lui retirer le rôle de propriétaire ou d’admin, ou le bannir révoque tous les liens qu’il a créés, dans la même transaction de base de données que le changement d’appartenance lui-même. La règle se dit simplement : un lien prolonge l’autorité de la personne qui l’a créé, et il prend fin quand cette autorité prend fin.

Les places, l’autre moitié de la question

Un lien que n’importe qui peut transférer, sur un forfait au nombre de places fixe, est aussi un moyen de dépenser l’argent de quelqu’un d’autre. La vérification des places se fait donc à l’arrivée, pas seulement à l’invitation :

TypeScript
const seatsInUse = members + pendingInvitations;
if (seatsInUse + 1 > plan.members.maxPerProject) throw Conflict('MEMBER_LIMIT_REACHED');

Compter les invitations en attente, c’est la partie qu’on oublie facilement. Sans elle, trois places absorbent dix invitations, tout le monde reçoit un mail de bienvenue, et les sept derniers tombent sur une erreur en acceptant — le pire moment possible pour annoncer à quelqu’un que l’équipe est complète.

Les compter deux fois, c’est le bug inverse. Si une personne a déjà été invitée par e-mail puis rejoint le projet par un lien, sa place est déjà réservée ; lui en compter une deuxième revient à refuser une arrivée manifestement légitime. Sa propre invitation en attente est donc exclue du décompte, et close par son arrivée.

Ce que j’ai livré

Les propriétaires et les admins disposent d’une section Liens d’invitation sur la page de l’équipe du projet. Chaque lien actif y figure avec ce qu’il autorise et sa date d’expiration, à côté d’un bouton pour le copier et d’un autre pour le révoquer.

Page de l’équipe avec un compteur de places à 2 sur 3, la liste des membres et deux liens d’invitation, l’un ouvert à toutes les adresses e-mail, l’autre limité à lumen.example

En créer un, c’est prendre deux décisions : quels domaines d’e-mail peuvent l’utiliser, et combien de temps il reste ouvert. Laissez les domaines vides et n’importe quelle adresse peut rejoindre le projet ; remplissez-les et le lien n’admet que les adresses vérifiées sur ces domaines. L’expiration est de 7 jours, 30 jours ou jamais, et c’est 30 jours si vous ne choisissez rien.

Fenêtre de création d’un lien d’invitation, avec le champ des domaines d’e-mail autorisés rempli avec lumen.example et l’expiration du lien réglée sur 30 jours

La personne qui ouvre le lien voit le projet qu’elle rejoint, et la restriction s’il y en a une. Elle doit être connectée avec un vrai compte — une session d’invité ne peut pas occuper une place — et elle arrive toujours comme membre, jamais comme admin.

La page pour rejoindre le projet via un lien limité à un domaine, qui indique au visiteur que le lien exige une adresse e-mail vérifiée sur lumen.example

Une limite qu’on découvre en la heurtant est un rapport de bug

Les places sont l’une des limites qu’un forfait fixe à un projet, et le même raisonnement vaut pour toutes : un chiffre qu’on ne découvre qu’en butant dessus vous arrive sous forme de message furieux plutôt que de passage au forfait supérieur.

Chaque projet affiche donc désormais ce que son forfait autorise à côté de ce qu’il utilise — places, entrées, clés API, catégories, tags, types d’entrées et configurations —, et chaque liste prévient avant d’être pleine plutôt qu’après.

Panneau du forfait et de l’utilisation sur le forfait gratuit, avec 2 places sur 3 dans l’équipe, 0 clé API sur 10, 5 entrées sur 2 000, et des compteurs pour les catégories, les tags, les types d’entrées et les configurations

Si vous développez des liens d’invitation

  • Traitez le lien comme un secret au porteur, parce que c’en est un. Concevez-le comme vous concevriez un mot de passe partagé, pas comme un e-mail.

  • Restreignez-le à des domaines vérifiés, et refusez net les adresses non vérifiées. Une adresse non vérifiée rend la règle de domaine purement décorative.

  • Gardez les entrées de la liste d’autorisation en ASCII, et normalisez-les de la même façon que les adresses auxquelles vous les comparez.

  • Donnez-lui une expiration par défaut, et faites de « jamais » un choix délibéré plutôt que le choix facile.

  • Accordez le rôle utile le plus bas. Un lien ne doit jamais pouvoir créer d’autres liens.

  • Révoquez un lien quand la personne qui l’a créé perd l’autorité avec laquelle elle l’a créé.

  • Comptez les invitations en attente dans votre limite de places, et comptez chaque personne une seule fois.

  • Listez chaque lien actif là où un humain ira regarder, et faites que la révocation tienne en un clic.


Les projets Kookee ont désormais des liens d’invitation partageables, et chaque projet affiche les limites fixées par son forfait. Consultez Inviter votre équipe dans un projet pour en créer un, ou Limites de débit, forfaits et gestion des erreurs pour savoir ce que chaque forfait autorise.