Skip to main content

Comment créer un serveur WebSocket sécurisé en Python

Écrit par

Kingsley Ubah

feature websocket server

5 juillet 2022

0 minutes de lecture

En général, lorsqu’une application web a besoin d’une information provenant d’un serveur externe, le client envoie une requête à ce serveur, qui lui répond, puis la connexion est fermée.

Prenons l’exemple d’une application web qui affiche les cours de la Bourse. Le client doit régulièrement demander au serveur les cours mis à jour pour afficher les dernières valeurs. Cette approche est inefficace, car elle ferme et rouvre constamment la connexion HTTP. Elle est aussi loin d’être idéale : le cours d’une action stocké dans la base de données peut changer alors que l’application affiche toujours l’ancienne valeur.

Avec une communication bidirectionnelle, le serveur peut toutefois envoyer de façon autonome les nouveaux cours au client à chaque mise à jour des informations. Les ingénieurs ont conçu le protocole WebSocket pour permettre ce type de connexion persistante, sans les problèmes associés aux anciennes méthodes de interrogation longue (long-polling).

La bibliothèque Socket.IO est un choix populaire pour mettre en œuvre les WebSockets. L’un de ses principaux avantages est sa compatibilité avec différents environnements de langages de programmation, notamment Python. Elle fournit également une API plus simple et cohérente dans les deux environnements, sans qu’il soit nécessaire d’utiliser directement les API WebSocket brutes. De plus, elle facilite la mise en œuvre de mesures de sécurité comme le partage des ressources entre origines (CORS), l’authentification des utilisateurs et la limitation de la taille des charges utiles.

Dans ce tutoriel, nous allons voir comment créer un serveur WebSocket sécurisé en Python avec python-socket.io et utiliser le client Socket.IO en JavaScript pour connecter notre client au serveur.

Créer un serveur WebSocket avec Python

Installation et configuration

Pour commencer, Python 3.x et son gestionnaire de paquets, pip, doivent être installés sur votre machine. Heureusement, toutes les versions récentes de Python incluent pip. Si Python n’est pas installé, téléchargez la dernière version adaptée à votre système.

Commençons par créer les fichiers source. Créez d’abord un dossier pour contenir le code d’exemple. Installez ensuite le paquet python-socketio avec pip :

mkdir quick-socket
cd quick-socket
pip install python-socketio

Créez ensuite un fichier app.py dans quick-socket. C’est là que vous écrirez le code de votre serveur socket.

Créer un serveur WebSocket en Python

Commençons par importer la bibliothèque Socket.IO de Python et créer une instance de serveur socket asynchrone dans app.py. Nous allons en faire un serveur ASGI en définissant async_mode sur asgi. Configurons ensuite les écouteurs d’événements sur l’instance :

import socketio

server_io = socketio.AsyncServer(async_mode='asgi')

# a Python dictionary comprised of some heroes and their names
hero_names = {
  "ironMan": "Tony Stark",
  "hulk": "Bruce Banner",
  "wonderWoman": "Diana",
  "batMan": "Bruce Wayne",
  "blackPanther": "T'Challa"
}

# Triggered when a client connects to our socket. 
@server_io.event
def connect(sid, socket):    
    print(sid, 'connected')

# Triggered when a client disconnects from our socket
@server_io.event
def disconnect(sid):
    print(sid, 'disconnected')

@server_io.event
def get_name(sid, data):
    """Takes a hero, grabs corresponding “real” name, and sends it back to the client

    Key arguments:
    sid - the session_id, which is unique to each client
    data - payload sent from the client
    """

    print(data["hero"])

    server_io.emit("name", {'hero_name': hero_names[data["hero"]]}, to=sid)

Le premier événement se déclenche lorsqu’un socket se connecte à notre serveur. La fonction connect correspondante prend deux arguments :

  • sid, ou session id, est un identifiant unique qui représente un client connecté.

  • socket est un dictionnaire contenant toutes les informations relatives au client. Transmettez-le à connect() pour vérifier les informations d’identification (par exemple, un nom d’utilisateur) lors de l’authentification du client.

Le dernier événement se déclenche lorsqu’un bouton est cliqué dans l’application cliente. Le message de charge utile (hero) permet d’accéder au nom correspondant dans le dictionnaire défini en haut du fichier.

Vous devez exécuter un serveur web qui expose l’application socket côté serveur au client. Pour cela, nous utiliserons ASGI. Heureusement, la bibliothèque python-socket.io inclut ASGIApp, qui nous permet de transformer app.py en application ASGI pouvant se connecter à un serveur WSGI (comme Gunicorn).

Vous devrez instancier cette classe sur la ligne qui suit server_io = socketio.AsyncServer(), en lui transmettant l’instance du serveur socket et le chemin d’accès aux fichiers statiques que vous avez créés précédemment :

app = socketio.ASGIApp(server_io, static_files={
    '/': '/client/index.html'
    '/index.js': '/client/index.js'
})

Enfin, vous devez déployer l’application. Il existe différentes stratégies de déploiement pour les serveurs Socket.IO, comme Gunicorn et Eventlet. Consultez cette documentation et choisissez la stratégie la mieux adaptée à vos besoins.

Créer un client WebSocket avec Socket.IO

Maintenant que vous avez créé un serveur WebSocket, il est temps de créer un client WebSocket pour communiquer avec lui.

Créez d’abord un dossier client dans votre dossier racine. Créez ensuite deux fichiers : index.html et index.js.

Dans index.html, définissez une structure HTML simple et ajoutez des liens vers le client Socket.IO, Bootstrap CSS et votre fichier de script local, index.js :

<!DOCTYPE html>
<html lang="en">
<head>    
    <title>SocketIO Demo</title>

    <!-- Scripts -->
    <script src="https://cdn.socket.io/4.5.0/socket.io.min.js" integrity="sha384-7EyYLQZgWBi67fBtVxw60/OWl1kjsfrPFcaU0pp0nAh+i8FD068QogUvg85Ewy1k" crossorigin="anonymous"></script>
    <script src="index.js"></script>

    <!-- Bootstrap CDN -->
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@3.3.7/dist/css/bootstrap.min.css" integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous">

    <!-- Custom styles -->
    <style>
        h1 {
            text-align: center;
            margin-top: 10px;
            margin-bottom: 40px;
        }

        div {
            width: 80%;
            text-align: center;
            margin: 0 auto;
        }
    </style>
</head>
<body>
    <h1>Get Hero Name</h1>

    <div>
        <button id="ironMan" type="button" class="btn btn-primary btn-lg btn-block">Iron Man</button>
        <button id="hulk" type="button" class="btn btn-primary btn-lg btn-block">Hulk</button>
        <button id="wonderWoman" type="button" class="btn btn-primary btn-lg btn-block">Wonder Woman</button>
        <button id="blackPanther" type="button" class="btn btn-primary btn-lg btn-block">Black Panther</button>
        <button id="batMan" type="button" class="btn btn-primary btn-lg btn-block">Batman</button>
    </div>

</body>
</html>
Webpage mockup titled “Get Hero Name” with buttons labeled Iron Man, Hulk, Wonder Woman, Black Panther, and Batman.

Vous gérerez dans index.js tous les événements émis par le serveur et émettrez un événement lorsque l’on clique sur un bouton de la page :

  const client_io = io();
  const buttons = document.getElementsByTagName("button");

  client_io.on('connect', () => {
    console.log("connected")

    for (i=0; i<buttons.length; i++) {
      // add click event listener to all buttons
      buttons[i].addEventListener('click', function () {
      // emit hero to server
        client_io.emit("get_name", { hero: this.id})            
      })
    }    
  })

  client_io.on('disconnect', () => {
    console.log("disconnected")
  })

  client_io.on("name", (data) => {
    alert(data.hero_name)
  })

Le code ci-dessus récupère l’attribut id du bouton cliqué et envoie sa valeur au serveur. En réponse, le serveur déclenche l’événement name. Dans la fonction de rappel finale, qui s’exécute lorsque l’événement name est déclenché, nous affichons le nom à l’aide de la fonction alert de JavaScript.

Dans votre navigateur, accédez à 127.0.0.1:8000/index.html. La page devrait afficher une liste de boutons, un pour chaque héros.

Cliquez sur un bouton pour émettre l’événement get_name. Une alerte affichant le nom du héros apparaîtra en réponse.

Browser demo showing a popup message, “Bruce Wayne,” above buttons labeled Hulk, Wonder Woman, Black Panther, and Batman.

Passons maintenant à la sécurité des WebSockets.

Sécuriser vos WebSockets

Vous devez mettre en œuvre certaines fonctionnalités sur le serveur pour sécuriser vos WebSockets.

Certaines de ces fonctionnalités sont activées par défaut dans python-socketio, comme dans de nombreuses autres bibliothèques de serveurs WebSocket. Par exemple, la bibliothèque compresse automatiquement les données HTTP dans les connexions WebSocket, ce qui améliore la vitesse de transfert et l’utilisation de la bande passante.

Vous devez mettre en œuvre ou activer d’autres fonctionnalités lors de l’initialisation du serveur WebSocket.

Utiliser CORS pour autoriser ou bloquer des domaines

Le partage des ressources entre origines (CORS) est un mécanisme qui permet aux clients web d’effectuer des requêtes inter-origines. Les restrictions CORS contribuent à protéger le serveur contre les attaques de falsification de requête intersite (CSRF), où l’attaquant incite généralement la victime à effectuer des actions involontaires, souvent préjudiciables, comme le transfert de fonds personnels.

Toutefois, la protection offerte par CORS se limite aux connexions HTTP. Les politiques CORS ne s’appliquent pas aux WebSockets, car les connexions WebSocket utilisent le protocole WebSocket (WS) ou WebSocket Secure (WSS). Avec ces protocoles, la négociation initiale s’effectue via une requête HTTP Upgrade, le corps de la réponse est ignoré et le protocole HTTP/HTTPS passe au protocole WS/WSS.

Comme CORS ne restreint pas l’accès aux protocoles WebSocket, un utilisateur malveillant peut facilement établir une connexion WebSocket inter-origines pour envoyer et recevoir des données malveillantes. La solution consiste à faire évoluer la connexion vers WS en ajoutant le champ d’en-tête upgrade à la requête du client.

Connection: Upgrade
Upgrade: websocket

En réponse, le serveur envoie un message 101 switching protocols, confirmant que la communication ultérieure peut se poursuivre via WebSocket. Heureusement, la bibliothèque cliente Socket.IO effectue automatiquement cette opération. Vous pouvez consulter ici les autres options de configuration.

Le serveur vérifie l’en-tête Origin de la requête Upgrade afin d’empêcher les connexions WS inter-origines indésirables. La bibliothèque python-socket.io permet de le faire avec cors_allowed_origins. Lors de l’initialisation du WebSocket côté serveur, ce paramètre peut prendre une origine unique ou une liste d’origines (un tableau d’URL).

Vous pouvez également définir cet argument sur '*' pour autoriser toutes les origines ou sur [] pour les bloquer toutes :

server_io = socketio.Server(cors_allowed_origins = '*')

Vous pouvez aussi empêcher les connexions WebSocket provenant d’autres origines que la vôtre afin de prévenir les attaques de détournement de WebSocket intersite (CSWSH). Ce type d’attaque est une variante des attaques CSRF qui permet de lire et d’écrire des données via WebSockets.

Dans ce scénario, les attaquants peuvent créer une page web malveillante sur leur domaine et établir une connexion au socket du serveur en se faisant passer pour l’utilisateur. L’application malveillante peut alors lire les messages envoyés par le serveur et lui écrire directement.

Notez que certains clients non-navigateurs peuvent facilement définir l’en-tête Origin. Veillez donc à compléter cette approche par d’autres méthodes d’authentification des clients.

Authentifier les clients WebSocket avant la connexion

À mesure que le web gagne en ampleur et en complexité, les cyberattaques évoluent elles aussi. Un système d’authentification est l’un des moyens les plus efficaces pour empêcher le vol de données et garantir la confidentialité des communications entre client et serveur.

Un système d’authentification utilisateur sert de rempart entre le client et le serveur. Tout utilisateur qui souhaite accéder aux ressources du serveur doit d’abord fournir des informations d’identification uniques dans sa requête de connexion, généralement un nom d’utilisateur et un mot de passe.

Le serveur vérifie l’utilisateur à partir de ces informations, puis accepte ou rejette la demande de connexion. Si l’ordinateur serveur rejette la demande, il indique à l’utilisateur ce qui n’a pas fonctionné, par exemple : « Vos identifiants sont incorrects ». Cela contribue à éviter que les ressources ne tombent entre de mauvaises mains.

Les utilisateurs peuvent créer un mot de passe robuste et utiliser un gestionnaire de mots de passe pour renforcer la sécurité de l’authentification. Toutefois, ces mesures dépendent entièrement de l’utilisateur. C’est une autre raison essentielle de mettre en place des systèmes d’authentification robustes côté serveur de l’application.

De nombreuses bibliothèques d’authentification sont disponibles côté serveur. Votre choix dépend du framework côté serveur utilisé pour créer l’application.

Pour notre exemple WebSocket, vous pouvez créer un fichier login.html dans le dossier public qui affiche un formulaire HTML dans le navigateur. L’utilisateur pourra alors saisir son nom d’utilisateur et son mot de passe.

Dans le script client, public/index.js, attendez ensuite un événement submit. Lorsque l’utilisateur envoie le formulaire de connexion, vous initialisez la connexion WebSocket entre le client et le serveur et transmettez le nom d’utilisateur et le mot de passe fournis dans extraHeaders :

const client_io = null

document.getElementById('submit-btn').addEventListener('submit', function(ev){
  ev.preventDefault();

  client_io = io({
    transport_options: {
      polling: {
        extraHeaders: {
          'USERNAME': document.getElementById('username')
          'PASSWORD': document.getElementById('password')
        }
      }
    }
  })
})

La configuration ci-dessus récupère le nom d’utilisateur et le mot de passe dans les champs à partir de leurs attributs id, les encode sous forme d’en-têtes HTTP, puis les transmet au serveur WebSocket lors de la connexion du client.

Pour authentifier l’utilisateur dans le code serveur, app.py, nous allons récupérer le nom d’utilisateur et le mot de passe depuis request :

@server_io.event
def connect(sid, request):    

    username = request.get('USERNAME')
    password = request.get('PASSWORD')

    if not username && password:
        return False

    // Authenticate user here. Check if user exists in database, get related info.        

    with server_io.session(sid) as session:
        session['username'] = username

    server_io.emit('user_authorized', userdata, to=sid)

Nous récupérons ici le nom d’utilisateur et le mot de passe, puis vérifions si des données d’authentification sont présentes. Si les identifiants n’existent pas, l’utilisateur ne les a pas transmis et nous renvoyons False. Dans ce cas, nous ne pouvons pas l’authentifier.

Sinon, nous procédons à l’authentification de l’utilisateur. En général, vous vérifierez si son nom d’utilisateur existe dans une base de données et vous l’utiliserez pour récupérer les contenus qui lui sont propres. Vous créerez ensuite une session utilisateur et émettrez le message user_authorized au client. Le code client utilisera alors JavaScript pour afficher ces contenus à l’utilisateur.

Sans authentification des WebSockets, n’importe qui peut potentiellement se connecter au serveur et dérober des données sensibles. Notez qu’une fois l’utilisateur authentifié et connecté, les authentifications suivantes doivent s’effectuer avec des jetons plutôt que de lui demander de saisir à nouveau son nom d’utilisateur et son mot de passe.

Vous devez également faire preuve de prudence avec les données envoyées par le client. Veillez à valider toute entrée du client avant de la traiter. Une attaque par injection SQL peut être menée via WebSockets, tout comme avec les connexions HTTP traditionnelles.

Utiliser la limitation du débit pour protéger votre serveur WebSocket contre les attaques

La limitation du débit est une technique qui protège contre les attaques par déni de service (DoS) et déni de service distribué (DDoS). Lors de ces attaques, l’attaquant tente de submerger un service, voire de le faire tomber, en multipliant les appels jusqu’à le rendre inaccessible aux utilisateurs légitimes.

La limitation du débit permet de prévenir ces attaques en limitant la fréquence des requêtes API que chaque utilisateur peut effectuer. Nous pouvons appliquer cette limite en fonction de clés telles qu’une adresse IP, une clé API ou un autre identifiant unique, comme UniqueId.

Les limites de débit et d’allocation, ou quotas, définissent le nombre de requêtes qu’un client peut envoyer à une API pendant une période donnée. Le fournisseur de services applique généralement cette méthode pour garantir une utilisation équitable des services et des ressources basés sur des API.

Pour mettre en place une limitation du débit sur votre serveur, vous devez d’abord savoir pourquoi elle est nécessaire : protéger un service, définir des quotas pour différents forfaits, etc. Vous devez ensuite identifier et choisir la clé de limitation la mieux adaptée à votre situation. Enfin, vous utilisez une solution de limitation pour suivre l’utilisation de l’API en fonction de la clé choisie.

Il n’existe actuellement aucun package de limitation du débit spécifique à python-socketio, mais vous pouvez mettre en œuvre une solution avec un peu de travail.

Limitez la taille des charges utiles pour protéger votre serveur WebSocket

L’envoi d’une charge utile volumineuse via WebSockets risque fortement de nuire aux performances et, à terme, de faire planter le serveur socket.

Pour éviter de ralentir ou de faire planter WebSocket, vous souhaiterez probablement limiter la taille maximale des messages envoyés via la connexion WebSocket. Vous éviterez ainsi de faire planter le serveur en envoyant un message trop volumineux, comme lors d’une attaque par bombe ZIP.

La régulation de la taille des messages réduit également considérablement la latence de la connexion WebSocket et peut améliorer significativement la vitesse de transfert. Le protocole WebSocket limite la taille d’une trame et aide à déterminer le niveau de compression nécessaire pour le message. La compression du flux de messages peut consommer des ressources mémoire et CPU, mais elle en vaut souvent la peine, car elle peut réduire considérablement le trafic réseau.

Utilisez TLS pour sécuriser les communications par socket

L’utilisation de TLS/SSL est essentielle pour déployer un site web sur Internet. Sans cette protection, les informations sensibles échangées entre le client et le serveur peuvent facilement être volées lors d’une attaque de l’homme du milieu. Pour sécuriser le transport, utilisez un protocole sécurisé comme https:// plutôt que le protocole non sécurisé http://.

Pour WebSockets, utilisez le protocole wss:// (WebSockets chiffrés avec TLS) plutôt que le protocole non sécurisé ws://. Cette dernière méthode expose la connexion aux interférences de tiers. À l’inverse, le protocole wss:// chiffre toutes les données échangées via WebSocket.

Grâce à ce chiffrement, aucun tiers ne peut lire ni modifier les messages transmis via WebSocket, ce qui protège les informations sensibles. D’autres types d’attaques deviennent également impossibles lorsque la connexion est sécurisée.

De plus, avant d’établir une connexion WebSocket avec le serveur, assurez-vous que le site web à l’origine de la requête utilise lui aussi https://. Sans cette protection, des acteurs malveillants peuvent facilement modifier les requêtes.

TLS est indispensable à tout site web, qu’il utilise ou non WebSockets. Heureusement, de nombreux services d’hébergement proposent gratuitement TLS lors du déploiement d’applications. Google Cloud, AWS, Azure, Heroku et Fly.io figurent parmi les plateformes d’hébergement les plus populaires pour les applications Python.

En résumé : WebSocket

Dans ce tutoriel, nous avons créé un serveur WebSocket de base avec python-socketio et l’avons connecté à un client JavaScript. Nous avons également découvert le fonctionnement de WebSockets et abordé quelques considérations de sécurité pour vous aider à les utiliser en toute sécurité.

Comme de nombreux autres protocoles web, WebSockets s’appuie sur des mesures similaires (TLS, CORS, limitation du débit, etc.) pour sécuriser correctement les communications. Aucune de ces mesures n’est propre à WebSockets, mais il est essentiel de les comprendre pour éviter les problèmes de sécurité inutiles.

Outil gratuit de vérification de code en ligne

Sécurisez votre code avant votre prochain commit.