En juin, nous avons pointé Penetrify sur notre propre application, comme nous le faisons avant une release importante. Le scan est revenu avec un problème dans notre flux de connexion Cognito, et en tirant sur ce fil, cela est devenu quelque chose que je n'ai pas aimé lire : le tableau de bord Penetrify gardait les jetons d'accès et de rafraîchissement de Cognito dans localStorage, en clair, lisibles par n'importe quel JavaScript exécuté sur la page.
Nous ne nous sommes pas assis pour décider cela. C'est le comportement par défaut de la bibliothèque d'authentification que nous avons utilisée, et nous ne l'avons jamais remis en question. C'est précisément la partie qui mérite d'être écrite.
Ce qu'était réellement la découverte
Notre tableau de bord est une application single-page. À la connexion, elle recevait les jetons d'AWS Cognito et le SDK les plaçait dans le stockage du navigateur, car c'est là que les SDK les placent quand aucun serveur ne participe à la session. N'importe quel script ayant accès à la page pouvait les lire.
Cela signifie qu'un seul bug de cross-site scripting n'importe où dans l'application, une seule extension de navigateur malveillante ou une seule dépendance npm compromise, et un attaquant repart avec les deux jetons. Le jeton d'accès le fait entrer dans le compte. Le jeton de rafraîchissement l'y maintient, car il reste valide longtemps après que la session semble fermée, jusqu'à ce que quelque chose le révoque explicitement.
Pour nos utilisateurs, la portée incluait les informations de facturation, les résultats de scan de leurs applications et les données de versements d'affiliation. Autant que nous puissions en juger, personne ne l'a exploité. Ce n'est pas le sujet. C'était atteignable, et c'était notre propre valeur par défaut à corriger.
Pourquoi chiffrer localStorage n'est pas la solution
Le premier réflexe est de chiffrer les jetons avant de les stocker. Cela n'aide pas. La clé de déchiffrement doit vivre dans le même JavaScript que l'attaquant exécute déjà. Vous avez ajouté une étape, pas une frontière.
Le second réflexe est de déplacer les jetons dans un cookie côté client. Cela n'aide pas non plus. Un cookie posé par JavaScript est lisible par JavaScript. La propriété qui compte est HttpOnly, et seul un serveur peut la définir, via un en-tête Set-Cookie. Si votre correctif tourne dans le navigateur, ce n'est pas un correctif.
Ce que nous avons livré à la place
Nous avons déplacé toute la session vers des cookies posés par le serveur, avec un motif token handler dans le backend que nous avions déjà :
- Le backend FastAPI effectue les appels à Cognito et renvoie la session sous forme de cookies avec
HttpOnly,SecureetSameSite. Le navigateur ne voit jamais de jeton. - Le middleware d'authentification lit le jeton d'accès depuis le cookie au lieu d'un en-tête
Authorization. - Comme les cookies sont envoyés automatiquement, les requêtes modifiantes portent désormais un en-tête de jeton CSRF, que le backend vérifie.
- La déconnexion effectue un global sign-out côté Cognito, pour qu'un jeton ayant fui plus tôt meure avec la session au lieu de lui survivre.
- Le SDK d'authentification a totalement disparu du navigateur.
Nous n'avions pas besoin d'un nouveau service. Notre application statique et notre API sont déjà de même origine : CloudFront sert l'application et route /api/* vers API Gateway. Un cookie first-party est donc envoyé à chaque appel d'API, sans infrastructure supplémentaire.
Nous avons d'abord regardé la voie recommandée par le fournisseur, qui aurait signifié adopter une interface de connexion hébergée, et nous l'avons rejetée. Elle aurait jeté nos propres écrans de connexion, de MFA et de réinitialisation de mot de passe pour résoudre un problème que nous savons résoudre dans notre backend existant.
Ce que cela a coûté
Environ une semaine de travail, et aucun changement visible pour les utilisateurs. C'est le résumé honnête de la plupart du travail de sécurité, et c'est exactement pourquoi ce genre de chose est reporté indéfiniment dans une startup. Rien ne bouge sur la roadmap. Aucun client ne le demande. La seule chose qui change, c'est ce qu'un unique script injecté peut faire à vos utilisateurs.
Un détail pratique qui a rendu le déploiement sûr : pendant le rollout, le backend acceptait encore l'ancien chemin par en-tête, donc revenir sur le déploiement du frontend était un rollback en une étape si quelque chose tournait mal.
Vérifiez votre propre application. Cela prend environ 30 secondes.
Ouvrez votre application, connectez-vous, puis ouvrez les DevTools :
- Onglet Application, Local Storage. Cherchez de longues valeurs contenant deux points. Cette forme est un JWT.
- Vérifiez Session Storage dans le même panneau. Même problème, durée de vie plus courte.
- Dans la console, tapez
document.cookie. Tout ce qui apparaît là est par définition lisible par JavaScript : un cookie de session que vous voyez dans cette sortie ne vous protège pas.
Si vous trouvez un jeton, c'est une découverte. Pas théorique, et pas quelque chose qu'un audit de dépendances au vert ou une analyse statique propre vous aurait signalé.
Pourquoi nous publions ceci
Deux raisons, et la seconde concerne notre propre produit.
D'abord, les valeurs par défaut sont des décisions que quelqu'un d'autre a prises pour vous. Elles arrivent avec une bibliothèque, elles sont pratiques, et elles ne sont presque jamais un modèle de menace. La nôtre était un défaut raisonnable pour un tutoriel et mauvais pour une application qui détient des données de paiement.
Ensuite, un scan est une indication, pas un verdict. Le nôtre a signalé le flux de connexion et prouvé qu'il était atteignable. Il ne nous a pas livré la phrase « votre bibliothèque d'authentification utilise localStorage par défaut, et c'est cela qu'il faut changer ». Passer de l'alerte au correctif a voulu dire ouvrir notre propre chemin d'authentification et le lire. C'est aussi ainsi que nous attendons que vous utilisiez nos rapports : la découverte vous dit où regarder et montre la requête qui le prouve, puis vous décidez quel est le bon correctif dans votre architecture.
Donc : lancez le scan sur votre propre application, puis lisez le code qu'il désigne. Nous avons fait les deux, et aucune des deux moitiés n'aurait suffi seule.
Si vous voulez le regard extérieur sur votre application tout de suite, notre contrôle de sécurité gratuit prend environ 60 secondes et ne demande aucune inscription. Si vous voulez le rapport complet avec preuves et correctifs, le premier scan coûte $29 et est crédité sur votre plan. Et si vous préférez la chose gratuite que nous venons de décrire, ouvrez les DevTools et allez regarder votre propre local storage. Celle-là ne coûte rien.
