Skip to main content

Comment maintenir une connexion HTTP ouverte pendant 9 heures

Écrit par
feature keep http alive

23 octobre 2023

0 minutes de lecture

Le protocole HTTP est devenu si omniprésent qu’on en oublie facilement à quel point sa spécification est remarquable. Lorsque vous accédez à un site Web comme https://snyk.io, cela déclenche une multitude de requêtes HTTP supplémentaires pour récupérer du JavaScript, des images, des vidéos et d’autres ressources. En quelques secondes, une page entièrement rendue s’affiche. De fait, l’objectif de tout site Web destiné au grand public est d’afficher une page complète en quelques secondes tout au plus, au risque de perdre du trafic au profit d’un site légèrement plus rapide (chaque seconde compte !).

Il arrive toutefois qu’un cas d’usage nécessite un processus plus long, qui envoie régulièrement des mises à jour via la connexion HTTP. Examinons l’un de ces cas.

Comment Snyk organise un événement Capture the Flag

Chaque année, Snyk organise un événement Capture the Flag appelé Fetch the Flag (un nom inspiré de notre mascotte, Patch). Cette année, nous sommes ravis d’accueillir la légende du CTF John Hammond comme animateur de l’événement.

En coulisses, nous utilisons la plateforme CTF open source CTFd. CTFd dispose de son propre système d’inscription et de connexion. Cependant, pour des raisons de style et de suivi, nous voulions utiliser notre propre page d’inscription. Voici les exigences de notre équipe marketing :

  1. S’inscrire exclusivement via notre page d’inscription.

  2. Créer automatiquement un compte sur le serveur CTFd.

    1. Générer un alias unique.

    2. Définir un mot de passe unique et complexe.

    3. NE PAS encore envoyer de notification aux utilisateurs.

  3. À l’approche de l’événement, lancer un processus pour envoyer en masse un e-mail aux utilisateurs préinscrits afin de leur communiquer leurs identifiants.

    1. Modifier le mode d’inscription de CTFd afin que les nouveaux utilisateurs reçoivent un e-mail contenant leurs identifiants au moment de leur inscription.

Dans cet article, je vous explique comment le projet open source que j’ai créé, ctfd-account-hook, a évolué pour prendre en charge une requête HTTP sécurisée de longue durée afin d’envoyer un e-mail à près de 4 000 participants inscrits.

Utiliser l’API CTFd

Le système CTFd intègre une API pour les tâches courantes, comme les opérations CRUD sur les utilisateurs, ainsi qu’un point de terminaison pour les notifications par e-mail. Il est facile de configurer le système pour utiliser un fournisseur de messagerie : il suffit de définir l’hôte, le port et les identifiants d’authentification.

Comme la plupart des API modernes, ces points de terminaison sont soumis à des limites de débit. En particulier, le point de terminaison de notification par e-mail est soumis à des limites de débit strictes, car le service de messagerie configuré a généralement ses propres limites de débit d’API. Le point de terminaison d’envoi d’e-mails autorise l’envoi de 10 e-mails avant de renvoyer le code d’état HTTP standard 429, qui indique « trop de requêtes ». Une fois une minute écoulée, vous pouvez envoyer une nouvelle série de 10 requêtes à l’API d’e-mail. Ces informations nous ont aidés à déterminer comment créer l’application ctfd-account-hook.

Développer l’application CTFd account hook

Le système utilisé par notre équipe marketing pour créer des pages d’inscription peut effectuer un appel d’API lorsque le formulaire est envoyé. Compte tenu des exigences ci-dessus, je savais que je voulais :

  1. Un point de terminaison sécurisé

  2. Un minimum de données en entrée pour l’account hook : une adresse e-mail seulement

  3. La possibilité de passer d’un mode où aucune notification par e-mail n’est envoyée lors de l’inscription à un mode où un e-mail est envoyé au moment de l’inscription

    1. Idéalement, il ne faudrait apporter AUCUNE modification à la configuration de la page d’inscription au moment de changer de mode.

J’ai opté pour Spring Boot avec Spring Security et WebFlux. Ces outils ont grandement facilité la prise en charge des points de terminaison sécurisés, les appels d’API vers CTFd, la gestion des limites de débit de l’API et la configuration simple des deux modes de fonctionnement.

Créer des comptes

La seule donnée d’entrée de l’application ctfd-account-hook est une adresse e-mail. L’application doit créer un alias unique, puis créer un compte utilisateur CTFd au moyen de son API.

Nous avons choisi un système d’alias qui sélectionne des éléments dans des dictionnaires internes. Chaque alias se compose d’un adjectif, d’une couleur et d’une race de chien. Avec 900 adjectifs, 52 couleurs et 80 races de chiens, nous disposons de 3 744 000 alias possibles.

Le point de terminaison de l’API CTFd, /api/v1/users, permet de créer un utilisateur. Il accepte un paramètre de chaîne de requête facultatif : notify. Pour créer un utilisateur et lui envoyer ses identifiants par e-mail en même temps, il suffit d’envoyer une requête POST comme celle-ci :

POST /api/v1/users?notify=true

Sans le paramètre de chaîne de requête notify, l’utilisateur ne recevra PAS de notification par e-mail.

Le premier avantage concret de Spring Boot résidait dans sa gestion des variables d’environnement. Dans la classe CtfdApiServiceImpl, un champ booléen appelé notifyOverride est défini automatiquement à partir d’une variable d’environnement, selon la syntaxe suivante :

@Value("#{ @environment['ctfd.api.notify-override'] ?: false }")
private Boolean notifyOverride;

Par défaut, notifyOverride est défini sur false. En revanche, si la variable d’environnement ctfd.api.notify-override est définie sur true, chaque nouveau compte CTFd créé reçoit également une notification par e-mail. La suite du code gère ce comportement :

…
String notify = (notifyOverride || req.getNotify()) ? "?notify=true" : "";
String uri = API_URI + "/users" + notify;

L’application est déployée sur Heroku. Au moment de passer au mode d’envoi d’un e-mail à la création du compte, il a suffi de modifier une variable d’environnement :

heroku config:set ctfd.api.notify-override=true

Envoyer des e-mails en masse

Une fois la création de comptes (avec ou sans notification par e-mail) en place, le prochain grand défi consistait à mettre en œuvre l’envoi de notifications en masse. Le plan était de permettre aux participants de s’inscrire plusieurs semaines avant notre événement Fetch the Flag. Un compte CTFd leur serait attribué (avec un alias généré), mais ils ne recevraient PAS leurs identifiants.

Environ une semaine avant l’événement, nous activerions l’envoi immédiat d’une notification par e-mail aux nouveaux inscrits. Nous lancerions ensuite un processus de longue durée pour envoyer une notification à tous les utilisateurs déjà inscrits.

Ce processus de longue durée devait prendre en charge le point de terminaison paginé de l’API CTFd pour récupérer la liste des utilisateurs existants, ainsi qu’une stratégie raisonnable de temporisation et de nouvelle tentative en cas de limite de débit de l’API. C’est là que la prise en charge asynchrone de Spring Boot et le client HTTP WebFlux excellent. Examinons une requête API WebFlux au point de terminaison d’envoi d’e-mails de CTFd :

this.webClient.post().uri(uri)
    .bodyValue(emailText)
    .retrieve()
    …
    .bodyToMono(CtfdUserResponse.class)
    .retryWhen(retryBackoffSpec)
    .block();

Cette ligne, .retryWhen(retryBackoffSpec), garantit que si une limite de débit de l’API est atteinte, la requête sera relancée de manière appropriée. Voici la définition de retryBackoffSpec :

this.retryBackoffSpec = Retry.backoff(maxAttempts, Duration.ofSeconds(backoffSeconds))
    .doBeforeRetry(retrySignal -> log.debug(
        "Waiting {} seconds. Retry #{} of {} after exception: {}",
        backoffSeconds, (retrySignal.totalRetriesInARow()+1), maxAttempts,
        retrySignal.failure().getLocalizedMessage()
    ))
    .onRetryExhaustedThrow((retryBackoffSpec, retrySignal) -> retrySignal.failure());

Dès la première ligne, les variables d’environnement maxAttempts et backoffSeconds servent à définir le comportement en cas d’erreur sur la requête HTTP. Le plus intéressant, c’est que cette définition couvre TOUS les types d’erreur. L’erreur la plus courante serait 429, qui indique « trop de requêtes ». Mais si le service subit une interruption et renvoie une erreur de type 5xx, la requête sera également relancée. Le code reste très léger tout en rendant les requêtes Web extrêmement résilientes. C’est toute la puissance de WebFlux.

Une fois la temporisation et les nouvelles tentatives mises en place, il était temps de configurer le processus d’envoi des notifications par e-mail de longue durée. Sachant qu’il faudrait attendre une minute après chaque série de 10 notifications et que nous avions environ 4 000 inscriptions, j’ai estimé qu’il faudrait plus de 6,5 heures pour envoyer toutes les notifications. En pratique, en tenant compte du temps supplémentaire consacré aux appels d’API pour la pagination, la mise à jour des mots de passe et l’envoi des e-mails, le processus entier a pris plus de 9 heures.

L’étape suivante consistait à mettre en œuvre le traitement asynchrone. Je voulais que mon contrôleur renvoie une réponse immédiatement tout en lançant le processus de longue durée. Je voulais aussi maintenir la connexion de la requête HTTP ouverte et envoyer régulièrement des mises à jour sur l’état du processus. C’est là qu’interviennent les Server Sent Events (SSE). Vous pouvez voir les SSE comme un canal ouvert dans lequel nous pouvons continuer à envoyer des informations. Un abonné les recevra.

Spring Boot intègre la prise en charge des SSE et la requête HTTP s’y abonne automatiquement. Voici le code du contrôleur qui lance le processus d’envoi des notifications par e-mail de longue durée :

@PostMapping("/api/v1/update-and-email/{affiliation}")
public SseEmitter updateAndEmailUsers(@PathVariable String affiliation) {
    SseEmitter emitter = new SseEmitter(1000*60*60*24L);
    ctfdApiService.updateAndEmail(emitter, affiliation);
    return emitter;
}

À la première ligne de la méthode, un objet SseEmitter est créé avec un délai d’expiration de 24 heures. La méthode asynchrone ctfdApiService.updateAndEmail est appelée avec l’émetteur qui vient d’être créé. Enfin, l’émetteur est renvoyé par la méthode du contrôleur. Cette méthode de contrôleur en trois lignes active le gestionnaire SSE asynchrone. La méthode updateAndEmail envoie périodiquement des événements à l’émetteur, qui les transmet automatiquement via la requête HTTP ouverte.

Avant d’examiner le code du service, configurons notre application Spring Boot pour gérer les appels asynchrones. Dans l’application Spring Boot principale, vous activez le traitement asynchrone au moyen de l’annotation `EnableAsync` :

@SpringBootApplication
@EnableAsync
public class CtfdAccountHookApplication {

    public static void main(String[] args) {
        SpringApplication.run(CtfdAccountHookApplication.class, args);
    }
}

Ensuite, une méthode de service peut être rendue asynchrone automatiquement en lui ajoutant l’annotation @Async. Voici la définition de la méthode updateAndEmail dans la classe CtfdApiServiceImpl :

    @Async
    @Override
    public void updateAndEmail(SseEmitter emitter, String affiliation) {
        Integer page = 1;
        int processed = 0;

        do {
            try {
                CtfdUserPaginatedResponse ctfdUserResponse =
                    getUsersByAffiliation(affiliation, page);
                for (CtfdUser ctfdUser : ctfdUserResponse.getData()) {
                    SseEmitter.SseEventBuilder  event = SseEmitter.event()
                        .data("Processing - " + ctfdUser.getId() + " - " + LocalTime.now().toString())
                        .id(String.valueOf(ctfdUser.getId()))
                        .name(ctfdUser.getId() + " - " + ctfdUser.getName());
                    emitter.send(event);
…
                    ctfdUser = updatePassword(ctfdUser);
                    emailUser(ctfdUser);
                }
                page = ctfdUserResponse.getMeta().getPagination().getNext();
                processed += ctfdUserResponse.getData().length;
…
            } catch (Exception e) {
                log.error("Failure while update/email operation: {}", e.getMessage());
                emitter.completeWithError(e);
                return;
            }
        } while (page != null);
…
        emitter.complete();
}

Spring Boot exécute cette méthode dans son propre thread. Pour chaque page (c’est-à-dire chaque appel à l’API CTFd), la méthode parcourt la liste des utilisateurs inscrits sur cette page. Pour chaque utilisateur, elle met à jour le mot de passe (un appel à l’API CTFd), puis envoie une notification par e-mail (un autre appel à l’API CTFd). Tout au long du processus, elle utilise l’émetteur SSE pour envoyer des messages dans le canal. La requête et sa sortie ressemblent à ceci (avec le client HTTPie) :

http POST \
https://<ctfd account hook url>/api/v1/update-and-email/fetch2023 \
 x-api-key:"<api token>"

HTTP/1.1 200
data:Processing - 1 - 17:19:00.595414016
id:1
event:1 - raw-blue-armant

data:Finished Processing - 1 - 17:19:03.958247179
id:1
event:1 - raw-blue-armant

En consultant les journaux du serveur, j’ai vu quelque chose comme ceci :

Processing user id: 1, name: raw-blue-armant
Password updated for user id: 1
Email sent for user id: 1
…
Processing user id: 10, name: conscious-harlequin-cursinu
Password updated for user id: 10
Waiting 10 seconds. Retry #1 of 10 after exception: 429 Too Many Requests from POST https://snyk.ctf.games/api/v1/users/10/email
Waiting 10 seconds. Retry #2 of 10 after exception: 429 Too Many Requests from POST https://snyk.ctf.games/api/v1/users/10/email
Waiting 10 seconds. Retry #3 of 10 after exception: 429 Too Many Requests from POST https://snyk.ctf.games/api/v1/users/10/email
Email sent for user id: 10

On voit ici le mécanisme de temporisation et de nouvelle tentative de notre client HTTP WebFlux en action.

Le grain de sable

Après avoir tout testé en local, j’ai effectué un test complet. Le processus a duré plus de 9 heures et s’est terminé sans problème ; j’avais également l’intégralité du journal de sortie SSE. Il était temps de déployer sur Heroku et de lancer le processus en conditions réelles.

Sachant qu’un environnement de production peut se comporter différemment de ma machine locale, j’ai lancé depuis Heroku un test avec une centaine de comptes fictifs. À ma grande surprise, des erreurs sont apparues et la requête a échoué après environ une minute. Heroku semblait interrompre ma requête HTTP parce qu’elle était restée inactive trop longtemps.

Heroku dispose d’un proxy périphérique qui rend automatiquement les applications déployées accessibles sur Internet via une adresse HTTPS. Tout est configuré automatiquement, si bien que toutes les applications déployées sont protégées par SSL par défaut. Pour garantir une bonne qualité de service à toutes les applications exécutées sur Heroku, le proxy ferme de manière agressive les connexions inactives. Dans mon application, le problème survenait lorsque la logique de temporisation et de nouvelle tentative était déclenchée : aucun événement n’était envoyé par l’émetteur SSE pendant parfois une minute. Or, Heroku ferme les connexions inactives après environ 10 secondes. Pour éviter cela, il me fallait une autre méthode asynchrone qui envoie régulièrement un message SSE de « battement de cœur », quoi qu’il arrive.

Voici la méthode du contrôleur de service mise à jour :

@PostMapping("/api/v1/update-and-email/{affiliation}")
public SseEmitter updateAndEmailUsers(@PathVariable String affiliation) {
    // TODO - should probs be another env var setting
    SseEmitter emitter = new SseEmitter(1000*60*60*24L);
    ctfdApiService.emitterHeartBeat(emitter);
    ctfdApiService.updateAndEmail(emitter, affiliation);
    return emitter;
}

Comme emitterHeartBeat et updateAndEmail sont tous deux des méthodes asynchrones, tout fonctionne toujours comme prévu. Voici la méthode emitterHeartBeat :

@Async
@Override
public void emitterHeartBeat(SseEmitter emitter) {
    try {
        do {
            emitter.send("beat");
            Thread.sleep(5000);
        } while (true);
    } catch (Exception e) {
        log.debug("exception during emitter: {}", e.getMessage());
    }
}

Cela garantit qu’un message beat est envoyé toutes les 5 secondes dans la requête HTTP ouverte via l’émetteur SSE. La connexion n’est ainsi jamais considérée comme inactive, et Heroku ne la fermera pas. Après avoir déployé cette modification, j’ai lancé mon processus de notification de plus de 9 heures, et tout s’est déroulé à merveille.

À vous de jouer


Je suis fier du projet ctfd-account-hook et je serais ravi d’y accueillir vos contributions ! Je participe à Hacktoberfest : vous pouvez donc obtenir des badges en faisant accepter vos pull requests. 

J’ai compris que ce processus de longue durée gagnerait à s’exécuter entièrement en arrière-plan. Je pourrais alors créer un point de terminaison pour suivre sa progression. Aussi intéressante que soit la technologie Server-Sent Events, l’approche actuelle reste fragile. Si la requête HTTP ouverte est interrompue, tout le processus peut échouer. Comme indiqué dans ce ticket sur GitHub, je pourrais conserver mes processus de service asynchrones pratiquement tels quels. Une fois le processus de longue durée lancé, le contrôleur pourrait immédiatement renvoyer un job id. Un point de terminaison de suivi renverrait l’état du travail, y compris une indication signalant qu’il est terminé. Cette approche ajoute la complexité d’utiliser une base de données et des enregistrements de table pour suivre la progression, mais elle élimine la fragilité d’une connexion HTTP ouverte.

Nous serions ravis de vous retrouver à notre événement Fetch the Flag le 27 octobre 2023. Après votre inscription, vous recevrez un e-mail contenant vos identifiants pour la plateforme CTFd. 30 défis vous attendent et vous aurez 24 heures pour les relever. Vous pouvez créer une équipe ou en rejoindre une, et participer à la conversation sur notre serveur Discord.

Publié dans:

Lire la suite

Blog

Stadium Summer : la tournée Fan Zone de Snyk Connect

La tournée Fan Zone de Snyk a proposé des ateliers sur la sécurité de l’IA, des occasions de réseauter et une compétition amicale dans 8 villes et lors de 3 sessions virtuelles. Les participants ont développé leurs compétences, échangé des idées et progressé ensemble.

Blog

Snyk VulnBench JS 1.0 : les LLM peuvent-ils détecter deux fois les mêmes bugs ?

Snyk VulnBench JS 1.0 : 300 analyses répétées montrent que les résultats de sécurité des LLM varient d’une exécution à l’autre, tandis que SAST et modèles détectent différentes vulnérabilités.

feature insights context
Blog

Construire la sécurité de l’IA avec nos clients : 5 leçons du programme de partenaires de conception d’Evo

Découvrez 5 leçons clés tirées du programme de partenaires de conception d’Evo de Snyk. Découvrez comment la découverte de l’IA, le renseignement sur les risques et l’automatisation des politiques aident les équipes à sécuriser l’IA générative et à maîtriser la prolifération de l’IA à grande échelle.