Les attaques Cross Site Request Forgery (CSRF) sont particulièrement redoutables. Si une équipe de développeurs n’est pas consciente du problème, les applications qu’elle produira seront très certainement vulnérables. Ces dernières années, de gros efforts ont été faits par les éditeurs pour équiper leurs frameworks d’outils de protection adéquats, allégeant ainsi la tâche du développeur. Leurs travaux ont été payants : la prévalence des vulnérabilités de ce type a chuté. Néanmoins, avec l’essor des applications SPA (Single Page Application), la mise en place d’une protection anti-CSRF n’est plus aussi directe. Dans cet article, nous proposons une approche pour défendre une application AngularJS reposant sur un backend ASP.NET Web API.
Pour la suite de l’article, nous allons prendre l’exemple d’une application (ultra) simplifiée de gestion de documents. L’utilisateur peut lister les documents du système et supprimer ceux dont il est l’auteur.
1. Rappels sur le CSRF
Une attaque CSRF consiste à faire jouer à un utilisateur, authentifié sur un système, une action à effet de bord (création, modification, suppression…) qu’il a le droit d’effectuer. L’action se passe à l’insu et bien souvent contre la volonté de la victime.
Imaginons par exemple une application de gestion de documents ayant différents niveaux de confidentialité (publics, restreints, confidentiels, etc.). Avec un système vulnérable, une personne n’ayant pas accès à un document confidentiel pourrait faire exécuter à un administrateur une modification du statut, le passant de confidentiel à public. Ainsi, elle pourrait consulter l’information qui lui était jusqu’à présent inaccessible.
Dans le cas de notre application exemple, un utilisateur pourrait exploiter une faille CSRF pour forcer un autre utilisateur à supprimer ses propres documents.
1.1 Mode opératoire
L’utilisateur malveillant remarque que son navigateur émet la requête suivante lorsqu’il supprime un document :
POST /api/documents/delete/a4ab6134-c20f-4f24-8ada-0f6b26e4416c HTTP/1.1
Host: myhost.com
Connection: keep-alive
Accept: application/json, text/plain, */*
Cookie: .AspNet.ApplicationCookie= L-7wYJg6yM52gr0tnil-TxsH77ymj...
Il souhaiterait supprimer un document pour nuire à un de ses collègues. N’ayant pas les droits nécessaires sur ce fichier, il lui faut trouver un moyen détourné pour arriver à ses fins. Il parvient à récupérer l’identifiant du document en question via l’application qui lui permet de les lister. Il met alors en place une page web trompeuse invitant le visiteur à cliquer sur un bouton pour gagner un smartphone.
En réalité, lors du clic sur le bouton, la page émet la requête de suppression avec l’identifiant du fichier à supprimer.
POST /api/documents/delete/4abcd580-8d7d-4571-9b3b-0c7f54cb81fe HTTP/1.1
Host: myhost.com
Connection: keep-alive
Accept: application/json, text/plain, */*
Cookie: .AspNet.ApplicationCookie= L-7wYJg6yM52gr0tnil-TxsH77ymj...
L’attaquant envoie ensuite à son collègue un e-mail contenant un lien sur la page piégée. Si la victime est authentifiée sur l’application de gestion de documents au moment où elle clique sur le lien de l’e-mail, le navigateur ouvrira un onglet sur le site frauduleux. Dès lors que l’utilisateur cliquera sur le bouton pour gagner son lot, son client web va envoyer la requête de suppression au système de gestion de documents accompagnée du cookie de session de l’utilisateur.
Du point de vue du serveur, la requête est parfaitement légitime :
- elle provient d’un utilisateur authentifié ;
- cet utilisateur a les droits de supprimer le fichier.
Elle est donc traitée tout à fait normalement, et l’utilisateur supprime le document sans s’en apercevoir.
1.2 Mécanismes de protection
On peut adopter plusieurs stratégies pour se protéger des attaques CSRF. Toutes cependant consistent à vérifier la légitimité de la requête reçue par l’application. Pour cela, il nous faut nous assurer que la requête a bien été émise par l’utilisateur que l’on pense, et qu’il veut effectivement procéder à l’action demandée. On peut donc :
- demander confirmation après contrôle côté serveur ;
- pour les opérations sensibles, demander confirmation à l’utilisateur avec saisie de mot de passe ;
- s’assurer que la requête provient bien de la page censée en être à l’origine dans le workflow (vérification du referer).
Chacune de ces techniques comporte néanmoins son lot d’inconvénients. Soit elles ne sont pas fiables en étant utilisées seules (referer), soit elles perturbent l’utilisation (confirmations).
Toutes ces techniques peuvent être utilisées en complément d’un mécanisme plus fiable et moins invasif. Elles participent à la conception d’un système sûr. En les combinant, on applique le principe de défense en profondeur.
Une technique plus fiable et transparente pour l’utilisateur est communément utilisée. Elle consiste à utiliser un jeton de validation de requête, aussi appelé Token Anti-Forgery(anti-falsification).
Avec ce modèle, appelé Synchronizer Token Pattern, chaque réponse du serveur est accompagnée d’une séquence de caractères – nommée jeton – générée de manière aléatoire ou cryptographique. Selon les implémentations, ce jeton peut être à usage unique, à durée de vie limitée (expiration) ou peut être associé à la session de l’utilisateur (ou un subtil mélange). Lorsqu’une requête provient du client, elle doit être accompagnée de la séquence émise par le serveur pour être validée. Ainsi, il n’est plus possible de prédire le format des requêtes, l’introduction d’un paramètre non prédictible rendant celles-ci uniques.
Pour être valide, notre requête de suppression devra prendre la forme suivante :
POST /api/documents/delete/a4ab6134-c20f-4f24-8ada-0f6b26e4416c HTTP/1.1
Host: myhost.com
Connection: keep-alive
Accept: application/json, text/plain, */*
Cookie: .AspNet.ApplicationCookie= L-7wYJg6yM52gr0tnil-TxsH77ymj...__RequestVerificationToken=km52SnEBO2NhoZcbgnSOqOZNKmh8Bqk_luGqFIQ6KWMQh6…
__RequestVerificationToken=H1AUE3_MkZpz4np3U7wL5IOkfsiB3vax6_pvw1E1vNZz19Y...
2. Protection Anti-Forgery d'ASP.NET
L’implémentation de la protection CSRF du framework ASP.NET repose sur une variante du Synchronizer Token Pattern, appelée Double Submit Cookies, qui consiste à valider les requêtes après une double-vérification.
Lorsqu’un utilisateur demande une page permettant d’exécuter des actions à effets de bord, un jeton est inséré dans un champ de type hidden :
<input name="__RequestVerificationToken" type="hidden" value="Mj33sWdAnGfqKX2CKsl_U0KbnSSX_vphRKsjYEUk-lCBsDurOMsaozMUJmlDleyLtz26pMYEvFjk4WyrnW44XGkjtAdS1S3NMcZbc0v3wlk1" />
En plus de ce jeton de formulaire, ASP.NET envoie un cookie de validation au navigateur. Les 2 jetons vont de pair. Leurs valeurs sont liées et les deux sont nécessaires à la validation des requêtes.
Fig. 1 : Cookie de validation installé par ASP.NET.
3. Protection d’une ASP .NET MVC classique
Protéger une application ASP .NET MVC est très simple pour le développeur : il lui suffit d’invoquer la méthode helper @Html.AntiforgeryToken dans le code de la vue Razor – le moteur de templating du framework .Net – pour que le serveur insère le champ caché contenant le jeton dans le formulaire et affecte le cookie de validation à la réponse.
@using (Html.BeginForm("Delete", "Documents"))
{
@Html.AntiForgeryToken()
}
Pour la validation, il ne reste qu’à décorer l’action « cible » du formulaire avec l’attribut ValidateAntiForgeryToken.
[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult DeleteDocument(Guid id)
{
Document document = db.Documents.Find(id);
if (IsActionAuthorized(DocumentAction.Delete, User, document))
{
db.Documents.Remove(document);
}
else
{
return NotAuthorized();
}
return View();
}
Lors de la soumission du formulaire, le navigateur enverra le cookie __RequestVerificationToken et le contenu du champ hidden __RequestVerificationToken. L’attribut ValidateAntiForgeryToken s’assure de la validation côté serveur, contrôlant la correspondance entre les 2 valeurs incluses dans la requête.
4. Protection d’une application SPA
4.1 Génération des jetons
Les applications de type SPA sont plus complexes à défendre. Les vues sont générées côté client, et il n’est pas rare que plusieurs requêtes soient nécessaires au chargement d’un formulaire et de ses données (listes de sélections, options, etc.). Comme il n’est plus possible d’affecter un jeton de validation aux formulaires, nous devons trouver un moyen pour mettre à disposition du navigateur les 2 jetons pour les requêtes XHR.
AngularJS fournit, pour la gestion des requêtes AJAX , les services $http et $resource qui sont équipés nativement d’un système de protection CSRF. Par défaut, le service $http – et par extension le $resource s'appuyant dessus – cherche un cookie nommé XSRF-TOKEN puis en extrait le contenu pour l’affecter à un header « custom » X-XSRF-TOKEN.
Côté Web API, nous avons donc 3 choses à faire :
- Générer des jetons pour les requêtes et les passer dans un cookie nommé XSRF-TOKEN ;
- Créer un système de validation des requêtes similaire à l’attribut ValidateAntiForgeryToken du framework MVC. Seulement, nous utiliserons une liste blanche plutôt qu'une liste noire : au lieu de dire explicitement quelles actions protéger, nous les protégerons par défaut et dirons explicitement quelles actions ne pas protéger* ;
- Prévoir un système d’exclusion, permettant de marquer les actions à ne pas protéger.
* Procéder ainsi respecte le principe de moindre privilège. L’utilisation d’un attribut décorant les actions à protéger risque d’introduire des vulnérabilités en cas d’oubli.
4.1.1 Création d'un filtre d'actions global
Nous voulons envoyer un couple de jetons avec chaque réponse du serveur, afin d’être sûr que les requêtes futures disposeront de tokens valides.
ASP.NET offre un mécanisme simple permettant d’ajouter un comportement après l’exécution de toutes les actions : les filtres globaux (Global Filters). Nous allons ici implémenter un filtre qui générera le cookie attendu par le service $http d’Angular et l’ajoutera à la réponse.
Les filtres peuvent être vus comme des décorateurs ou des triggers SQL. Ils permettent d’ajouter du comportement avant ou après l’exécution d’une méthode. Pour implémenter un filtre, nous pouvons étendre la classe de base ActionFilterAttribute. Dans notre cas, nous devons redéfinir la méthode OnActionExecuted, dont l’exécution suit celle de chaque action.
public class XsrfCookieGeneratorFilter : ActionFilterAttribute
{
public override void OnActionExecuted(HttpActionExecutedContext actionExecutedContext)
{
var xsrfTokenCookie = new HttpCookie("XSRF-TOKEN");
xsrfTokenCookie.Value = ComputeXsrfTokenValue();
xsrfTokenCookie.Secure = true;
HttpContext.Current.Response.AppendCookie(xsrfTokenCookie);
}
private string ComputeXsrfTokenValue()
{
string cookieToken, formToken;
AntiForgery.GetTokens(null, out cookieToken, out formToken);
return cookieToken + ":" + formToken;
}
}
Dans le code ci-dessus, nous créons un cookie nommé XSRF-TOKEN, comme l’attend le service $http d’Angular. La valeur du cookie est générée à l’aide du la méthode AntiForgery.GetTokens du framework.Net, qui nous fournit les valeurs des 2 jetons corrélés dont nous avons besoin. C’est cette même méthode qui est utilisée par la méthode Html.AntiForgeryToken vue précédemment. Ces valeurs sont concaténées avec un séparateur – qui doit être différent du séparateur de cookies – entre elles. Le résultat est affecté au cookie, ajouté à la réponse pour envoi au navigateur.
Notons que le cookie doit être créé avec l’attribut secure afin de garantir son transit uniquement par canal sécurisé HTTPS. Il est important de ne pas ajouter de date d’expiration pour que sa durée de vie soit limitée à la durée de la session. Enfin, n’utilisez pas l’attribut HttpOnly : Angular a besoin du cookie et HttpOnly aurait pour effet d’empêcher la récupération des tokens par le code JavaScript.
4.1.2 Enregistrement du filtre
L’instruction suivante permet d’indiquer à Web API de charger le filtre pour l’exécuter après chaque action. On la place dans la méthode Register de la classe WebApiConfig située dans le répertoire App_Start de l’application :
config.Filters.Add(new XsrfCookieGeneratorFilter());
Une fois l’instruction ajoutée, le système d’affectation systématique du cookie est opérationnel.
Il peut arriver que dans une application, certains controllers n’aient pas accès aux informations de session. Il faudra dans ce cas utiliser un mécanisme d’exclusion comme présenté dans la suite de l’article pour éviter tout problème de validation de tokens.
4.2 Logique de validation
La première étape terminée, il nous faut maintenant implémenter le mécanisme de validation des jetons. Comme précédemment, nous allons utiliser un filtre. Par défaut, le contrôle s’effectuera pour toutes les actions Web API, assurant ainsi une protection systématique.
public class XsrfTokensValidationFilter : ActionFilterAttribute
{
public override void OnActionExecuting(HttpActionContext actionContext)
{
var cookieToken = "";
var formToken = "";
IEnumerable<string> headerValues;
actionContext.Request.Headers.TryGetValues("X-XSRF-TOKEN", out headerValues);
if (headerValues != null)
{
var xsrfTokensValue = headerValues.FirstOrDefault();
// Count(char) est une méthode d'extension "maison"
if (!string.IsNullOrEmpty(xsrfTokensValue) && xsrfTokensValue.Count(':') == 1)
{
var values = xsrfTokensValue.Split(':');
cookieToken = values[0];
formToken = values[1];
}
}
AntiForgery.Validate(cookieToken, formToken);
}
}
Nous redéfinissons cette fois la méthode OnActionExecuting, qui s’exécute juste avant l’exécution de l’action à protéger. La valeur du header X-XSRF-TOKEN ajouté par Angular est utilisée pour extraire les 2 tokens de validation. Ceux-ci sont contrôlés par la méthode AntiForgery.Validate du framework, qui lève une exception dans le cas où ils sont invalides. Dans un tel cas, l’action protégée n’est donc pas exécutée. S’ils sont valides, l’action protégée s’exécute normalement.
Là encore, nous devons donner une portée globale au filtre pour protéger par défaut toutes nos actions.
config.Filters.Add(new XsrfTokensValidationFilter());
4.3 Système d’exclusion
Il peut arriver que nos actions n’aient pas besoin d’être protégées. C’est le cas pour les opérations publiques, accessibles avant authentification, ou sans effet de bord (GET/HEAD). Nous devons donc maintenant ajouter des exceptions, ce qui peut être fait de 2 manières. Le choix de l’approche à adopter dépendra de votre projet.
4.3.1 Exception systématique sur méthodes http
Un simple test préalable à la validation des jetons suffit pour désactiver le contrôle des requêtes utilisant une des méthodes http sans effet de bord. Ainsi, nous pouvons modifier la méthode OnActionExecuting de la classe XsrfTokensValidationFilter précédemment créée de la manière suivante :
if (actionContext.Request.Method != HttpMethod.Get &&
actionContext.Request.Method != HttpMethod.Head)
{
AntiForgery.Validate(cookieToken, formToken);
}
La validation ne se fait plus alors que si les requêtes n’utilisent ni GET ni HEAD.
4.3.2 Exception explicite par attribut
Pour une granularité plus fine de la gestion de validation de tokens, on peut demander explicitement que le contrôle soit omis pour une méthode donnée. Un système de décoration convient tout à fait à ce type de cas. On définit donc un simple attribut vide qui servira uniquement à marquer les actions à ne pas contrôler.
public class SkipXsrfValidationAttribute : Attribute
{
}
Nous allons à nouveau ajouter quelques lignes de code à notre méthode OnActionExecuting de la classe XsrfTokensValidationFilter pour éviter la validation lorsque l’action est décorée.
var isDecorated = actionContext.ActionDescriptor
.GetCustomAttributes<SkipXsrfValidationAttribute>(false)
.Any();
if (actionContext.Request.Method != HttpMethod.Get &&
actionContext.Request.Method != HttpMethod.Head &&
!isDecorated)
{
AntiForgery.Validate(cookieToken, formToken);
}
Il ne reste plus qu’à décorer les méthodes que nous ne voulons pas protéger.
[HttpPost]
[SkipXsrfValidation]
public void SomeMethod()
{
// Code ne nécessitant pas de protection
}
Conclusion
Bien qu’il soit moins aisé de protéger une application SPA qu’une application ordinaire, les frameworks récents tels qu’ASP.NET offrent de nombreux mécanismes permettant une défense efficace. Sans surprise, c’est une fois encore côté serveur que les choses se passent. C’est aussi dû au fait que l’équipe d’AngularJS ait pensé à proposer un mécanisme « out-of-the-box » pour la protection de nos applications. Les efforts des éditeurs nous poussent vers un Web plus sûr. À nous maintenant de faire le reste.