

Découvrez plus de Ruby Biscuit
Bonjour à tous,
J'espère que vous avez passé de belles fêtes de fin d'année et que vous êtes d’attaque pour 2023 !
Je suis ravie de vous présenter notre première newsletter de l'année 🚀
Aujourd'hui, je vais vous parler d’un sujet qui fait débat chez nous : Comment renforcer vos sessions Devise contre les attaques par rejeu de session (session replay attack) ?
Bref, on parle cookie dans la newsletter biscuit 😉 🍪
Lors d'un audit de sécurité récent, nous avons découvert que lorsqu'un utilisateur se déconnectait, son cookie de session restait actif, ce qui lui permettait de se reconnecter via son inspecteur.
Nous nous sommes donc penchés sur le sujet pour remédier à ce problème sur l’ensemble de nos plateformes.
Ismaël vous explique, étape par étape, le fonctionnement des sessions de Rails et comment Devise l’utilise. Il nous montre comment vérifier manuellement puis avec des tests unitaires si cette faille est présente sur votre application avant d’explorer deux solutions différentes pour y remédier.
Spoiler : le choix entre les deux solutions ne sera pas simple, chez nous, elles font encore débat. 😉
Je donne la parole à Ismaël :
Chez Capsens, nous sommes une agence spécialisée dans la Fintech et le Ruby on Rails. Nos clients sont principalement des plateformes d'investissement, des banques ou des startups qui manipulent des flux financiers ou des données sensibles.
Naturellement, la sécurité est donc une priorité pour nous. Nous sommes régulièrement audités par des experts de la cybersécurité.
Lors d'un de ces audits, un élément intéressant nous a été remonté : si un utilisateur se déconnecte, son cookie de session est toujours actif, c'est à dire qu'on peut le réutiliser pour être connecté en tant que cet utilisateur. Cela rendrait la plateforme vulnérable aux vols de sessions dans le cas où l'utilisateur se connecterait via un ordinateur infecté par un logiciel qui enregistrerait ses actions.
Nous utilisons systématiquement la gem Devise pour gérer l'authentification sur nos plateformes Ruby on Rails. Cette librairie existe depuis plus de 10 ans, a été installée presque 150 millions de fois sur sa dernière version et est utilisée par plus de 480 000 utilisateurs Github. C'est l'une des gems phare de la communauté Rails & Ruby avec ses 484 contributeurs.
L'idée de cette article est de s'intéresser aux raisons qui font que cette faille existe et de proposer une solution pour la combler.
Dans cet article, nous nous intéresserons à ce point précis du fonctionnement des sessions de Rails et comment Devise l'utilise pour gérer l'authentification. Comment reproduire cette faille via des tests unitaires avec Rspec, la comprendre et enfin comment la corriger avec Active Record Session Store.
Comprendre les sessions de Rails & Devise
Avant de s'y atteler, il est important de comprendre comment les sessions de Devise fonctionnent. Pour rappel, le but d'une session est de permettre d'identifier un client dans ses interactions avec le serveur.
Le fonctionnement classique de Rails, et du web en général, est d'utiliser pour cela un cookie. Il s'agit d'un petit fichier texte stocké sur le navigateur de l'utilisateur qui contient plusieurs couples clé-valeur (xxxx=yyyy). Ce fichier est déposé par le serveur et va être transmis avec chaque requête HTTP.
Exemple de cookie de session Rails :
session_id="23929"
expire_at="2023-01-01 20h30"
domain="localhost"
HttpOnly=true
Devise utilise ce système pour l'authentification. Lorsque l'utilisateur se connecte, il ajoute au cookie de session une clé avec en valeur l'identifiant de l'utilisateur en base de données. Ainsi à chaque requête, il sait comment identifier l'utilisateur. Le système est sécurisé puisque la valeur du cookie de session est chiffrée et déchiffrée avec la SECRET_KEY_BASE
de votre application pour éviter que quelqu'un ne puisse modifier son identifiant et ainsi s'authentifier en tant que quelqu'un d'autre.
Pour résumer, lorsque l'utilisateur se connecte, Devise ajoute dans la session de l'utilisateur son identifiant en base de données. Ensuite à chaque requête, le serveur déchiffre le cookie de session et assigne donc current_user
à l'utilisateur en base de données qui correspond à cet identifiant.
Ce système provient de Warden, un middleware Rack et qui va ajouter à la session un mécanisme d'authentification en injectant un objet dans l'environnement Rack à request.env['warden']
. Plus d'informations sur Warden disponibles à ce lien.
Afin de mieux comprendre, prenons un exemple concret. Je suis donc allé mettre un breakpoint dans mon code grâce à pry de manière à intercepter une requête authentifiée.
Je déchiffre alors le contenu de la session, plus particulièrement de la partie qui nous concerne :
$ pry(<AccountController>)> session
# =>
{
"warden.user.user.key" => [
[1],
"35VBDJgZO7F1sPfhvPJ57u"
],
# [...d'autres attributs (session_id, flashes, request...)],
}
On obtient une clé warden.user.user.key
qui contient une liste de deux valeurs :
Le
1
correspond à l'identifiant de mon utilisateur en base de données$2a$12$35VBDJgZO7F1sPfhvPJ57us
correspond aux 30 premiers caractères duencrypted_password
de l'utilisateur.
Le deuxième paramètre permet d'avoir un mécanisme pour invalider une session en cas de modification du mot de passe. Il est défini par la méthode authenticatable_salt
de Devise:
# devise/models/database_authenticatable.rb
# A reliable way to expose the salt regardless of the implementation.
def authenticatable_salt
encrypted_password[0,29] if encrypted_password
end
En cas de déconnexion, le serveur supprime simplement ce paramètre warden.user.user.key
du cookie de session du navigateur l'utilisateur.
Comprendre le problème
Le problème se situe au niveau de la déconnexion. En effet, comme évoqué, la déconnexion native proposée par Devise se résume à réinitialiser le cookie de session de l'utilisateur. Ainsi, si on suit la logique du fonctionnement, il suffit de réutiliser n'importe quel cookie de l'utilisateur, et celui-ci serait encore valide même après déconnexion tant qu'il n'aura pas changé de mot de passe. Essayons !
Je me connecte sur une application Rails avec une configuration Devise classique.
J'inspecte la valeur de mon cookie de session et je la copie
Le cookie de session s'appelle ici _doclift_session
et sa valeur est UgGIeMB2jbpOrtHOQsGq[...]2FmoSlrg1YBQ%2Bg%3D%3D
(mise de côté pour notre test).

Je me déconnecte
Je modifie la valeur de mon nouveau cookie de session pour remettre l'ancien
Via l'inspecteur, dans l'onglet Application → Cookie, je remplace la valeur de _doclift_session
par l'ancienne que j'avais mise de côté.
Je rafraichis la page, je suis connecté 💣
La session de l'utilisateur n'étant géré que via le cookie de session de Rails qui contient uniquement l'ID de l'utilisateur, aucun autre mécanisme que le changement de mot de passe ne permet d'invalider définitivement un cookie de session. Même si l'utilisateur s'est déconnecté, si le cookie a été intercepté, alors il pourra être réutilisé.
Tests unitaires
Avant de corriger le problème, nous allons écrire un test unitaire simple qui le reproduit afin de nous assurer facilement que notre solution le corrige bien.
Pour cela nous utiliserons des request_specs
de rspec :
rails g rspec:request users/sessions/delete
# => create spec/requests/users/sessions/delete_spec.rb
Nous souhaitons que notre test fasse ce que nous avons fait manuellement, c'est-à-dire récupérer un cookie de session d'un utilisateur authentifié, puis se déconnecter et remplacer son nouveau cookie de session par l'ancien : le résultat attendu est de ne pas être connecté.
# spec/requests/users/sessions/delete_spec.rb
require "rails_helper"
RSpec.describe "POST Users::Sessions#delete", type: :request do
let(:user) { create(:user) } # FactoryBot
subject(:perform_log_out_request) { delete(destroy_user_session_path) }
before do
sign_in(user)
# la requête est necessaire pour que la session
# soit créée et son cookie déposé
get(root_path)
end
context "when user signs out" do
it "deletes user_session" do
expect {
perform_log_out_request
}.to change {
session["warden.user.user.key"]&.dig(0, 0)
}.from(user.id).to(nil)
end
it "has invalidated previous session" do
previous_cookie = cookies[:_doclift_session]
perform_log_out_request
cookies[:_doclift_session] = previous_cookie
get(root_path)
expect(response).to redirect_to(
new_user_session_path
)
end
end
end
Lorsque l'on exécute les tests, on constate bien que le dernier échoue, le cookie de l'utilisateur est toujours valide même après sa déconnexion :
$ rspec spec/requests/users/sessions/delete_spec.rb
.F
Failures:
1) POST Users::Sessions#delete when user signs out has invalidated previous session
Failure/Error: expect(response).to redirect_to(new_user_session_path)
Expected response to be a <3XX: redirect>, but was a <200: OK>
# ./spec/requests/users/sessions/delete_spec.rb:21:in `block (3 levels) in <top (required)>'
Stocker la session côté serveur : Active Record Session Store
Active Record Session Store est une gem qui a était d'abord incluse directement dans Rails puis proposée sous la forme d'une gem externe. Il permet de stocker la session non plus côté client mais directement en base de données, donc côté serveur.
Les avantages d'utiliser une telle solution sont multiples :
Réduit fortement le risque de cookie overflow, c'est-à-dire de dépasser la taille maximum (4 Ko) autorisée par les navigateur lorsque l'on tente de stocker une trop grande quantité d'informations dans la DB.
Permet de garder un contrôle total des sessions : les sessions étant en base de données, on peut facilement imaginer un système qui permet de révoquer une session lorsque le besoin se présente.
Permet d'éviter de stocker côté client des informations potentiellement sensibles (même s'il est fortement recommandé d'éviter de stocker des informations sensibles dans la session).
Réduit la taille réseau de chaque requête puisqu'on n'a plus besoin de transférer toutes les informations de la session à chaque requête, elles peuvent rester côté serveur.
Cette solution présente également des inconvénients :
Cela implique d'ajouter une nouvelle dépendance (lié à une fonctionnalité coeur) à son application et son lot de problème que cela engendre.
Lorsque une application cherche à scaler, elle tend à réduire le nombre de requêtes à la base de données dés que c'est possible. La session côté client est une bonne solution pour économiser de nombreuses requêtes en lecture et écriture.
Depuis Rails 6, il est plus facilement possible de connecter plusieurs bases de données à son application notamment pour séparer les requêtes de lecture des requêtes en écritures (plus lentes). Active Record Session Store complique ce genre de pratiques puisqu'il serait plus complexe de faire la séparation entre les requêtes qui impliquent de l'écriture des autres en se basant sur les verbes HTTP (GET, POST, PUT ...)
Ceci étant dit, la gem est très simple à mettre en place, il suffit de :
Ajouter la gem gem 'activerecord-session_store'
à son Gemfile & bundle exec install
puis lancer la commande rails generate active_record:session_migration
qui crée la migration suivante :
# db/migrate/20230116171912_add_sessions_table.rb
class AddSessionsTable < ActiveRecord::Migration[7.0]
def change
create_table :sessions do |t|
t.string :session_id, null: false
t.text :data
t.timestamps
end
add_index :sessions, :session_id, :unique => true
add_index :sessions, :updated_at
end
end
La migration va donc créer une table sessions
composée de 4 champs :
un champ string unique
session_id
avec un index qui va servir à stocker l'identifiant de la sessionun champ text
data
qui contiendra le contenu de la session utilisateurles 2 timestamps
created_at
&updated_at
Lançons cette migration avec rake db:migrate
.
Il nous reste plus qu'à indiquer à Rails que l'on souhaite utiliser active_record_store
plutôt que cookie_store
en modifiant ou créant le fichier config/initializers/session_store.rb
:
# config/initializers/session_store.rb
Rails.application.config.session_store(
:active_record_store,
key: "_doclift_session",
)
Lançons les tests pour vérifier que l'on corrige bien notre faille de sécurité :
rspec spec/requests/users/sessions/delete_spec.rb
..
Finished in 2.11 seconds (files took 1.11 seconds to load)
2 examples, 0 failures
Le correctif corrige bien le problème ... mais comment ?
Intéressons-nous un peu plus en détail à ce qu'il se passe.
Active Record Session Store utilise sans surprise également un cookie mais il y a deux différences majeures :
Sa taille est significativement réduite : 48 bytes contre 488 bytes avec le cookie_store
Sa valeur est beaucoup plus courte, en effet, ici l'ID mesure seulement 32 caractères
Ces deux différences s'expliquent par le fait que ce cookie ne sert qu'à lier le client à une session qui est cette fois stockée en base de données.
# console Rails
SESSION_CLASS = ActionDispatch::Session::ActiveRecordStore.session_class.freeze
$ SESSION_CLASS.last
=>
#<ActiveRecord::SessionStore::Session:0x000000010f145db0
id: 26,
session_id: "2::d92dd021553fb0826be17dbc6bf1e17f77b34bf5384f45b4517f6f8e187060ff",
data: "BAh7BkkiEF9jc3JmX3Rva2VuBjoGRUZJIjBoREZseTB3dGt4QXpHVWFodEd6\nRzE2Q2xGODhaamdIMzlIaXJGUE5YdXc4BjsARg==\n",
created_at: Mon, 16 Jan 2023 22:05:42.567198000 CET +01:00,
updated_at: Mon, 16 Jan 2023 22:05:42.579124000 CET +01:00
>
Ici le session_id
est différent de celui visible dans le cookie de l'utilisateur pour mitiger le risque d'attaque temporelle mais on peut relativement facilement le retrouver :
# console Rails
session_public_id = "7b0ef645ef6d6bbe468b01664bd11c44"
sid = Rack::Session::SessionId.new(session_public_id)
sid.private_id
# => "2::d92dd021553fb0826be17dbc6bf1e17f77b34bf5384f45b4517f6f8e187060ff"
Le champ data
est encodé avec Marshall en base64
mais la gem implémente des méthodes #load
& #dump
afin de pouvoir les lire et modifier facilement :
# console Rails
SESSION_CLASS = ActionDispatch::Session::ActiveRecordStore.session_class.freeze
SESSION_CLASS.last.data
=> ActiveRecord::SessionStore::Session Load (0.7ms) SELECT "sessions".* FROM "sessions" ORDER BY "sessions"."id" DESC LIMIT $1 [["LIMIT", 1]]
{
"_csrf_token" => "hDFly0wtkxAzGUahtGzG16ClF88ZjgH39HirFPNXuw8",
"user_return_to" => "/"
}
Tant que l'utilisateur n'est pas connecté, il n'a pas de clé warden.user.user.key
en revanche on peut retrouver les éléments classiques que contiennent une session tel que l'url de redirection après connexion.
Il est pertinent de faire remarquer qu'à chaque fois qu'un client effectue une requête à l'application, une session en base de données est crée pour lui si il n'en a pas déjà une. Cela signifie qu'un utilisateur malintentionné peut facilement créer des millions de lignes dans votre base de données avec un simple script qui fait une requête et supprime le cookie de session en boucle. Il est donc important de se protéger contre ce genre d'attaques avec un système de bannissement d'IP ou autre système de protection contre les bots (Cloudflare avec challenge avant d'accéder au site par exemple).
Une fois l'utilisateur connecté, sa session est augmentée avec les mêmes informations que dans le cas d'une session côté client :
# console Rails
SESSION_CLASS = ActionDispatch::Session::ActiveRecordStore.session_class.freeze
def find_session_with_public_id(public_id)
rack_sid = Rack::Session::SessionId.new(public_id)
private_id = rack_sid.private_id
SESSION_CLASS.find_by_session_id(private_id)
end
$ session = find_session_with_public_id("eb7a620b9f947590bcd917e3a985f018")
$ session.data
# ActiveRecord::SessionStore::Session Load (0.5ms) SELECT "sessions".* FROM "sessions" ORDER BY "sessions"."id" DESC LIMIT $1 [["LIMIT", 1]]
# =>
{
"warden.user.user.key"=>[
[1],
"$2a$12$35VBDJgZO7F1sPfhvPJ57u"
],
"flash" => { ... }
}
En revanche, une fois que l'on se déconnecte, la session est supprimée de la base de données :
# console Rails
$ find_session_with_public_id("eb7a620b9f947590bcd917e3a985f018")
# ActiveRecord::SessionStore::Session Load (0.3ms) SELECT "sessions".* FROM "sessions" WHERE "sessions"."session_id" = $1 ORDER BY "sessions"."id" ASC LIMIT $2 [["session_id", "2::eb635a939ad94353db96019c812621cefd16d723b90794634411de35961d1b5b"], ["LIMIT", 1]]
=> nil
De manière générale, à chaque fois que la session a besoin d'être modifiée, elle est supprimée puis recréée :
# console Rails
$ SESSION_CLASS.ids
=> [32]
# On se connecte
$ SESSION_CLASS.ids
=> [33]
# On se déconnecte
$ SESSION_CLASS.ids
=> [34]
Pour conclure, Active Record Session Store nous apporte un moyen simple de se prémunir contre les attaques par rejeu de session après que l'utilisateur se soit déconnecté.
Ce système ne permet d'invalider facilement une session dans le cas où le client (de cette session) ne se déconnecterait pas. S'il a oublié de se déconnecter de cet ordinateur, sa seule possibilité pour invalider immédiatement toutes ses sessions est de changer son mot de passe, ce qui invaliderait son salt comme évoqué précédemment grâce à la méthode authenticatable_salt
.
De plus, cette solution présente tout de même des défauts non négligeables :
elle implique d'avoir un système pour purger périodiquement les sessions obsolètes sinon vous vous retrouvez avec des millions de sessions inutiles en bases de données
elle complexifie énormément le scaling de votre application en rendant très fastidieuse la séparation des requêtes en lecture et en écriture
elle sollicite la base de données à chaque requête avec une grande partie de requête en écriture
elle rend (plus) vulnérable aux attaques DDOS
Pour ces raisons, le choix de Rails de déprécier et d'externaliser cette solution est compréhensible. Il serait donc intéressant d'explorer une autre solution.
Corriger le problème avec authenticatable_salt
# devise/models/database_authenticatable.rb
# A reliable way to expose the salt regardless of the implementation.
def authenticatable_salt
encrypted_password[0,29] if encrypted_password
end
Comme évoqué précédemment, cette méthode permet d'invalider une session lorsque l'utilisateur change de mot de passe. Nous pourrions donc augmenter sa logique pour ajouter un second paramètre qui permettrait de faire changer ce salt en cas de déconnexion.
Nous allons donc ajouter un token stocké sur la table de l'utilisateur qui sera regeneré à chaque fois que l'utilisateur se déconnecte et ajouter ce token à la méthode authenticatable_salt
.
Créons donc une migration pour ajouter session_token
à la table users :
class AddSessionTokenToUsers < ActiveRecord::Migration[7.0]
def change
add_column(
:users,
:session_token,
:text,
default: -> { "md5((random())::text)" },
null: false,
)
add_index :users, :session_token
end
end
La migration ci-dessus ajoute un champ texte session_token
sur la table utilisateur et lui assigne une valeur par défaut aléatoire de type md5 (exemple : 314809aaf64aa80d35e117ae1111ee82
) différente (sans garantie d'unicité) pour chaque ligne en base de données.
Pour que la solution soit fonctionnelle, il reste encore à modifier ce token à chaque fois que l'utilisateur se déconnecte. Pour cela il suffit de modifier la méthode de déconnexion de Devise et donc overrider le controller de sessions :
# On génére le controller SessionsController
# dans le scope "users"
$ rails g devise:controllers users -c sessions
# => create app/controllers/users/sessions_controller.rb
Puis ensuite on spécifie à Rails où chercher le controller pour les routes de sessions :
# config/routes.rb
Rails.application.routes.draw do
devise_for :users,
controllers: {
sessions: "users/sessions",
}
# [...]
end
On modifie alors le SessionsController
que l'on vient de créer pour overrider la méthode de déconnexion afin qu'elle regenère le token stocké dans session_token
# app/controllers/users/sessions_controller.rb
class Users::SessionsController < Devise::SessionsController
# [...]
# DELETE /resource/sign_out
def destroy
current_user.invalidate_all_sessions
super
end
end
Enfin on crée la méthode invalidate_all_sessions
et on override la méthode authenticatable_salt
sur le modèle utilisateur :
# app/models/user.rb
class User < ApplicationRecord
# [...]
def authenticatable_salt
"#{super}#{session_token}"
end
def invalidate_all_sessions
self.update_column :session_token, SecureRandom.hex(8)
end
end
Désormais à chaque déconnexion, le salt sera mis à jour ce qui implique d'invalider tous les anciens cookies de session.
Vous n'avez pas besoin de définir une valeur trop élevée pour ce session_token
, en effet, il sert ici uniquement de salt, c'est à dire que même si par hasard deux utilisateurs venaient à avoir le même token, cela ne serait pas gênant puisqu'ils n'auront pas pour autant le même ID en base de données.
Vérifions que notre correctif permet bien d'invalider le cookie de session de l'utilisateur en cas de déconnexion :
$ rspec spec/requests/users/sessions/delete_spec.rb
..
Finished in 2.11 seconds (files took 1.12 seconds to load)
2 examples, 0 failures
Si votre application contient un dispositif permettant à vos administrateurs de se connecter en tant qu'un utilisateur, il conviendrait alors de ne pas réinitialiser le token de l'user en cas de déconnexion de l'administrateur sinon celui-ci se retrouverait déconnecté alors qu'il est potentiellement en train de naviguer sur le site.
Cette solution a été proposée initialement par Natalie Zeumann dans son article Devise: Invalidating all sessions for a user.
De là, on peut donc très facilement utiliser notre méthode user.invalidate_all_sessions
pour permettre à un administrateur d'invalider immédiatement les sessions pour un utilisateur donné sans avoir besoin de lui demander de changer de mot de passe. Notez que cette solution est également compatible avec Active Record Session Store afin de permettre de s'assurer que toutes les sessions sont bien purgées lorsque l'utilisateur se déconnecte.
Même si elle est simple à mettre en place, cette solution présente un défaut non négligeable, elle repose sur une méthode qui n'est pas exposée dans l'API publique de Devise. En d'autres termes, celle-ci peut être dépréciée à n'importe quel moment dans une future version de Devise.
D'autres solutions existent, nous explorerons notamment dans une prochaine lettre le stockage des sessions dans Redis.
Alors à vous de jouer maintenant, plus d'excuses pour laisser cette faille risquer de mettre en péril les sessions de vos utilisateurs, vous avez toutes les clés en main. D’ailleurs, je serais curieuse de savoir laquelle vous avez choisie et pour quelles raisons, peut-être arriveriez vous à mettre tout le monde d’accord !
En attendant, nous nous penchons sur une troisième solution, j’espère pouvoir vous en parler bientôt ! 😉
Toujours dans le thème de la sécurité, je souhaitais également vous partager cet article qui liste les principales en-têtes de sécurité que vous pouvez utiliser pour protéger vos sites Web.
Cet article nous a récemment été partagé par Robert sur notre canal #technique_links
. C’est un canal Slack que nous utilisons au quotidien pour se partager des articles intéressants et pour débattre de sujets techniques (notre passe-temps préféré).
Bonne fin de semaine à tous et à la semaine prochaine !
Mélanie
🍪 ⚡ Ce cookie de session Devise qui divise nos développeurs
Merci pour cet article qui rentre dans le détail des sessions, super intéressant, ça me fait reréfléchir au sujet et comparer avec ma base de code !
Stocker les sessions en dehors d'un cookie comme c'est fait là ça permets également en tant qu'admin d'avoir un moyen de forcer la déconnexion de quelqu'un, ce qui peut être parfois utile.
Quelques autres pistes et questions qui me passent en tête :
- est-ce qu’ActiveRecord Session Store permets de supprimer les sessions de quelqu'un dans la base au moment d'une déconnexion, directement ?
- utiliser une date comme salt (par exemple `disconnected_at`), ça pourrait donner une information potentiellement plus « intéressante » qu'un md5, j'ai l'impression ?
- on peut également utiliser Redis pour stocker les sessions et ainsi palier aux défauts de séparation des requêtes, augmentation des requêtes, et simplifie la purge périodique