🏳️‍🌈♿️👩‍🎨🌱 En février, je remets 3% de mes ventes à des organismes qui favorisent la diversité, les arts et l'environnement 🏳️‍🌈♿️👩‍🎨🌱

Potion Bottle Icon

Manuel d'alchimie du code

Potion Bottle Icon

Changement de hCaptcha pour CloudFlare sur ListMonk - Une aventure !

Récemment, un visiteur de mon site web m'a contacté pour me dire que c'était rendu presque impossible de s'inscrire à mon infolettre. Les défis proposés par le logiciel anti-robots hCaptcha sont rendus très difficiles. Il existe une version automatisée qui ne demande aucun défi visuel, mais elle est très dispendieuse.

J'utilise le logiciel libre d'infolettre ListMonk. Par défaut, dans ce logiciel, il n'y a que hCaptcha qui est disponible comme outil de prévention d'exploitation contre les robots spammeurs.

Je souhaitais utiliser une solution libre, mais comme la plupart nécessitent aussi de mettre en place un serveur de validation, j'ai regardé les équivalents gratuits sur le marché. J'ai découvert que CloudFlare offre le service Turnstile gratuitement, et qu'il est beaucoup simple pour les visiteurs. Il suffit de cocher une case lorsque le script ne parvient pas à déterminer automatiquement que le visiteur est humain.

J'ai donc entrepris de modifier le logiciel ListMonk, dont le code est disponible sur GitHub, pour y inclure la fonctionnalité de CloudFlare Turnstile. J'ai fait face à plusieurs défis. ListMonk est développé en Go, un langage que je ne connais pas vraiment. De plus, je suis actuellement sur un ordinateur Mac Studio, avec un processeur Apple M2. Go est un langage compilé, et sur ces processeurs, ce n'est pas possible de compiler du Go avec l'architecture amd64.

J'ai donc fait le développement en compilant localement avec l'architecture de mon processeur, qui est arm64. Cependant, je ne pouvais pas copier mon binaire directement sur mon serveur. De plus, ce n'était pas possible de tester les fonctionnalités de CloudFlare, parce que ça ne fonctionne pas en mode local, il faut être sur un site avec le nom de domaine que nous avons enregistré pour obtenir les clés API.

J'ai donc configuré un GitHub Actions pour compiler le logiciel à distance, sur la plateforme GitHub, et produire le binaire dans les releases. De cet endroit, j'ai pu le télécharger directement sur mon serveur et faire les tests.

Je vais te détailler tout ça !

Enregistrement de CloudFlare Turnstile

J'avais déjà un compte sur CloudFlare, alors l'enregistrement s'est fait en quelques clics. Grosso modo, tu dois avoir un nom de domaine enregistré chez CloudFlare. Tu n'as pas à utiliser leur service de DNS (même s'il est pratique). Tu peux faire un bon bout de chemin avec leurs services gratuits. Donc aucune inquiétude à avoir à moins que tu aies un site web avec un trafic très important.

Voici les caractéristiques de l'offre gratuite de Turnstile :

  1. Entre le nom de ton choix
  2. Ajoute un hôte (nom de domaine)
    • Si tu as déjà des domaines chez CloudFlare, tu peux les utiliser ici.
    • Sinon, tu peux ajouter d'autres domaines.
  3. Conserve le choix Managed
    Créer un module avec Turnstile
  4. N'active pas le Pre-clearance, sauf si ton domaine utilise la fonctionnalité proxy de CloudFlare.
  5. Clique sur Create
    cloudflare-turnstile-non-preclearance.PNG

Tu vas ensuite obtenir une clé de site (qui est publique et visible dans le code de ta page) et une clé secrète, qui est privée et doit être configurée dans le logiciel serveur qui va valider la connexion.

Le site indique maintenant deux liens :

L'intégration client

L'intégration client consiste à intégrer du HTML dans le code de la page avec la classe cf-turnstile, ainsi qu'un code javascript qui va activer le module.

Voici le code HTML à intégrer. Remplace yourSitekey par la clé de site que tu as obtenue précédemment.

<div
  class="cf-turnstile"
  data-sitekey="yourSitekey"
  data-callback="javascriptCallback">
</div>

Le module va vérifier qu'il est sur le bon domaine avant de s'activer. Ce qui fait que ce n'est pas possible de tester le module localement avant de l'avoir mis sur son site.
Si tu es dans un <form>, tu n'as pas besoin de mettre la variable data-callback. C'est notre cas ici, nous sommes bien dans un formulaire généré par ListMonk.

<div
  class="cf-turnstile"
  data-sitekey="yourSitekey">
</div>

Tu dois aussi inclure le script Javascript suivant :

<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>

L'intégration serveur

Comme nous utilisons ListMonk comme logiciel serveur, et que celui-ci est déjà configuré pour utiliser hCaptcha, nous allons devoir modifier le logiciel. Celui-ci est programmé dans le langage Go.

Modifications au logiciel ListMonk

Les modifications apportées au logiciel visent à généraliser l'utilisation d'un module de CAPTCHA au lieu de reposer seulement sur l'utilisation de hCaptcha.

Pour ce faire, j'ai identifié quatre variables dans le code qui permettent d'utiliser à la fois hCaptcha et Cloudflare Turnstile.

Je propose donc ici un début d'approche de généralisation qui pourra être bonifiée en ajoutant de nouveaux fournisseurs.

Les variables introduites

J'ai ajouté une section security dans le fichier config.toml qui contient les variables suivantes :

Cette approche permet d'introduire de la flexibilité et d'éviter les valeurs codées en dur dans le code.

Commençons par modifier le modèle de code client généré par ListMonk. Cette information se trouve dans des modèles Vue situés dans /frontend/src/views/Forms.vue.

Voici la section originale :

      if (this.settings['security.enable_captcha']) {
        h += '\n'
          + `    <div class="h-captcha" data-sitekey="${this.settings['security.captcha_key']}"></div>\n`
          + `    <${'script'} src="https://js.hcaptcha.com/1/api.js" async defer></${'script'}>\n`;
      }

Elle devient maintenant :

      if (this.settings['security.enable_captcha']) {
        h += '\n'
          + `    <div class="${this.settings['security.captcha_class']}" data-sitekey="${this.settings['security.captcha_key']}"></div>\n`
          + `    <${'script'} src="${this.settings['security.captcha_url']}" async defer></${'script'}>\n`;
      }

Tu remarqueras qu'il s'agit d'un modèele générique du code que nous avons modifié précédemment sur le site web.

Nous allons maintenant modifier les fichiers de code Go et ajoutant les variables là oùon trouve déjà des variables de CAPTCHA.

Dans le fichier captcha.go, nous allons remplacer

type Opt struct {
	CaptchaSecret string `json:"captcha_secret"`
}

par

type Opt struct {
	CaptchaSecret        string `json:"captcha_secret"`
	CaptchaResponseField string `json:"captcha_response_field"`
	VerifyURL            string `json:"captcha_verify_url"`
}

Nous ajoutons ainsi dans la structure de données Opt, le nom du champ de validation dans la réponse du CAPTCHA, ainsi que l'URL de vérification. Le secret représente la clé secrète que nous avons reçue en créant le module. C'est une variable d'environnement qui est déjà définie dans ListMonk.

Le constructeur de structure Captcha, qui inclus la structure Opt, n'a aucune façon de gérer la valeur par défaut de l'URL de vérification VerifyURL. Nous ajoutons donc celle de CloudFlare Turnstile.

On ajoute les lignes suivantes :

	verifyURL := o.VerifyURL
	if verifyURL == "" {
		verifyURL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
	}

Le constructeur devient alors

func New(o Opt) *Captcha {
	timeout := time.Second * 5
	verifyURL := o.VerifyURL
	if verifyURL == "" {
		verifyURL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
	}

	return &Captcha{
		o: o,
		client: &http.Client{
			Timeout: timeout,
			Transport: &http.Transport{
				MaxIdleConnsPerHost:   10,
				MaxConnsPerHost:       100,
				ResponseHeaderTimeout: timeout,
				IdleConnTimeout:       timeout,
			},
		}}
}

Dans la fonction de vérification, au lieu d'utiliser l'URL codée en dur, que nous supprimons tout simplement

const (
	rootURL = "https://hcaptcha.com/siteverify"
)

Nous allons utiliser la variable VerifyURL que nous avons ajoutée à la structure Opt. Dans la fonction, cette structure est référée en tant que o, dans la structure c de type Captcha, qui a été définie par le constructeur ci-haut.

func (c *Captcha) Verify(token string) (error, bool) {
	resp, err := c.client.PostForm(c.o.VerifyURL, url.Values{
		"secret":   {c.o.CaptchaSecret},
		"response": {token},
	})
	if err != nil {
		return err, false
	}

	defer func(Body io.ReadCloser) {
		err := Body.Close()
		if err != nil {
			fmt.Printf("Error closing response body: %v\n", err)
		}
	}(resp.Body)

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		fmt.Printf("Error reading response body: %v\n", err)
		return err, false
	}

	var r captchaResp
	if err := json.Unmarshal(body, &r); err != nil {
		fmt.Printf("Error unmarshalling response JSON: %v\n", err)
		return err, true
	}

	if !r.Success {
		fmt.Printf("CAPTCHA verification failed with error codes: %v\n", r.ErrorCodes)
		return fmt.Errorf("captcha failed: %s", strings.Join(r.ErrorCodes, ",")), false
	}

	return nil, true
}

Nous allons maintenant configurer le chemin entre le fichier de configuration config.toml et l'usage des variables.

Premièrement, dans le fichier init.go, nous allons ajouter les quatre variables à la structure Security

La structure originale

	Security struct {
		OIDC struct {
			Enabled      bool   `koanf:"enabled"`
			Provider     string `koanf:"provider_url"`
			ClientID     string `koanf:"client_id"`
			ClientSecret string `koanf:"client_secret"`
		} `koanf:"oidc"`

		EnableCaptcha bool   `koanf:"enable_captcha"`
		CaptchaKey    string `koanf:"captcha_key"`
		CaptchaSecret string `koanf:"captcha_secret"`
	} `koanf:"security"`

devient

	Security struct {
		OIDC struct {
			Enabled      bool   `koanf:"enabled"`
			Provider     string `koanf:"provider_url"`
			ClientID     string `koanf:"client_id"`
			ClientSecret string `koanf:"client_secret"`
		} `koanf:"oidc"`

		EnableCaptcha        bool   `koanf:"enable_captcha"`
		CaptchaKey           string `koanf:"captcha_key"`
		CaptchaURL           string `koanf:"captcha_url"`
		VerifyURL            string `koanf:"captcha_verify_url"`
		CaptchaSecret        string `koanf:"captcha_secret"`
		CaptchaClass         string `koanf:"captcha_class"`
		CaptchaResponseField string `koanf:"captcha_response_field"`
	} `koanf:"security"`

Plus bas, dans la fonction initCaptcha, nous allons ajouter les deux variables que nous avons ajoutées à la structure Opt précédemment :

func initCaptcha() *captcha.Captcha {
	return captcha.New(captcha.Opt{
		CaptchaSecret:        ko.String("security.captcha_secret"),
		VerifyURL:            ko.String("security.captcha_verify_url"),
		CaptchaResponseField: ko.String("security.captcha_response_field"),
	})
}

Maintenant, dans le programme admin.go, nous allons ajouter les quatre variables à la structure de données serverConfig:

type serverConfig struct {
	RootURL              string          `json:"root_url"`
	FromEmail            string          `json:"from_email"`
	Messengers           []string        `json:"messengers"`
	Langs                []i18nLang      `json:"langs"`
	Lang                 string          `json:"lang"`
	Permissions          json.RawMessage `json:"permissions"`
	Update               *AppUpdate      `json:"update"`
	NeedsRestart         bool            `json:"needs_restart"`
	HasLegacyUser        bool            `json:"has_legacy_user"`
	Version              string          `json:"version"`
	CaptchaURL           string          `json:"captcha_url"`
	VerifyURL            string          `json:"captcha_verify_url"`
	CaptchaResponseField string          `json:"captcha_response_field"`
	CaptchaClass         string          `json:"captcha_class"`
}

Un peu plus bas, dans la fonction handleGetServerConfig, nous allons aussi ajouter les variables dans l'initialisation de serverConfig :

	out := serverConfig{
		RootURL:              app.constants.RootURL,
		FromEmail:            app.constants.FromEmail,
		Lang:                 app.constants.Lang,
		Permissions:          app.constants.PermissionsRaw,
		HasLegacyUser:        app.constants.HasLegacyUser,
		CaptchaURL:           app.constants.Security.CaptchaURL,
		VerifyURL:            app.constants.Security.VerifyURL,
		CaptchaClass:         app.constants.Security.CaptchaClass,
		CaptchaResponseField: app.constants.Security.CaptchaResponseField,
	}

Nous sommes maintenant rendus au dernier fichier modifié. C'est le fichier qui contient la logique du formulaire l'abonnement.

On commence par ajouter les variables dans la structure subFormTpl:

type subFormTpl struct {
	publicTpl
	Lists        []models.List
	CaptchaKey   string
	CaptchaClass string
	CaptchaURL   string
}

On définit maintenant les deux variables CaptchaClass et CaptchaURL depuis la structure Security que nous avons vue plus haut

	if app.constants.Security.EnableCaptcha {
		out.CaptchaKey = app.constants.Security.CaptchaKey
		out.CaptchaClass = app.constants.Security.CaptchaClass
		out.CaptchaURL = app.constants.Security.CaptchaURL
	}

On remplace le code de traitement du CAPTCHA suivant :

	// Process CAPTCHA.
	if app.constants.Security.EnableCaptcha {
		err, ok := app.captcha.Verify(c.FormValue("h-captcha-response"))
		if err != nil {
			app.log.Printf("Captcha request failed: %v", err)
		}

		if !ok {
			return c.Render(http.StatusBadRequest, tplMessage,
				makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.T("public.invalidCaptcha")))
		}
	}

On utilise maintenant nos variables, ce qui donne :

	// Process CAPTCHA.
	if app.constants.Security.EnableCaptcha {
		captchaResponse := c.FormValue(app.constants.Security.CaptchaResponseField)
		err, ok := app.captcha.Verify(captchaResponse)
		if err != nil {
			app.log.Printf("Captcha request failed: %v", err)
		}

		if !ok {
			return c.Render(http.StatusBadRequest, tplMessage,
				makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.T("public.invalidCaptcha")))
		}
	}

Configuration GitHub Actions, tags et Release

J'ai créé une nouvelle tâche pour GitHub Actions, l'outil d'intégration continue de GitHub, qui permet de générer un binaire et de le placer comme artifact dans la section Release. Cette tâche va être déclenchée pour toute étiquette de version qui débute par la lettre v.

L'application compilée portera simplement le nom listmonk, ce qui va permettre de la remplacer directement sur le serveur.

Ce fichier est enregistré dans le répertoire .github/workflows/ du projet, et se nomme github-release.yml. Il n'y a aucune variable d'environnement à configurer, car GITHUB_TOKEN est une variable globale au dépôt GitHub.

name: Create Release

on:
  push:
    tags:
      - 'v*' # Trigger on tags starting with 'v'

jobs:
  build:
    name: Build and Create Release
    runs-on: ubuntu-latest
    
    steps:
    - name: Check out code
      uses: actions/checkout@v3

    - name: Set up Go
      uses: actions/setup-go@v4
      with:
        go-version: '1.21' # Specify the Go version you're using

    - name: Install dependencies
      run: |
        sudo apt-get update
        sudo apt-get install -y make git gcc

    - name: Build project
      run: make dist

    - name: Create Release
      id: create_release
      uses: actions/create-release@v1
      env:
        GITHUB_TOKEN: $
      with:
        tag_name: $
        release_name: Release $
        draft: false
        prerelease: false

    - name: Upload Release Asset
      uses: actions/upload-release-asset@v1
      env:
        GITHUB_TOKEN: $
      with:
        upload_url: $
        asset_path: ./listmonk
        asset_name: listmonk
        asset_content_type: application/octet-stream

Remplacement du logiciel sur le serveur

Configurer les nouvelles variables d'environnement

Dans le fichier /var/www/listmonk/config.toml, j'ai ajouté les quatre nouvelles variables pour Cloudflare Turnstile.

[security]
captcha_url = "https://challenges.cloudflare.com/turnstile/v0/api.js"
captcha_verify_url = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
captcha_class = "cf-turnstile"
captcha_response_field = "cf-turnstile-response"

Puis, j'ai modifié la configuration de nginx pour autoriser cloudflare comme source externe de scripts. Il faut appeler le script depuis CloudFlare et non en héberger une copie localement, car il y a souvent des mises à jour

Maintenant, on effectue le remplacement du binaire en déplaçcant la version actuelle, en téléchargeant directement le release que l'on a créé et en ajustant ces permissions. Ça se peut que tes noms d'utilisateur et de groupes soient différent. L'important, c'est de prendre les mêmes que le fichier listmonk_old.

cp listmonk listmonk_old
rm listmonk
wget https://github.com/franc00018/listmonk/releases/download/v4.1.0-franc00018-2/listmonk
chmod a+x listmonk
chown listmonk:listmonk listmonk

On met maintenant la base de données à jour. C'est important de faire une sauvegarde au préalable, avec un outil comme Adminer, c'est beaucoup plus facile. Les informations pour se connecter à la base de données se trouvent aussi dans le fichier config.toml.

./listmonk --upgrade

Enfin, on redémarre les services du serveur

systemctl restart listmonk.service
systemctl restart nginx.service

Voilà ! L'application ListMonk a été remplacée et utilise maintenant CloudFlare Turnstile au lieu de hCaptcha pour prévenir les abus par des robots.

Abonne-toi au flux RSS pour ne rien manquer.

Étiquettes