Use Cases

Résolution de CAPTCHA pour les tests de points de terminaison d'API dans les formulaires Web

Un endpoint protégé par un CAPTCHA reste un endpoint que votre équipe QA doit valider : statut HTTP, message d'erreur, limitation de débit, rejet des jetons invalides. Le défi CAPTCHA ne devrait pas vous empêcher d'écrire ces tests. En résolvant le CAPTCHA via l'API CaptchaAI, puis en soumettant le token directement au backend, vous testez la logique serveur sans piloter de navigateur ni cliquer sur la moindre case.


Pourquoi tester une API protégée par CAPTCHA sans navigateur

Piloter un navigateur headless pour chaque test est lent, fragile et coûteux en ressources. Dès que la seule barrière qui vous sépare de l'endpoint est le CAPTCHA, une requête HTTP directe suffit. Le tableau suivant résume ce que cette approche vous permet de valider :

Objectif du test Ce que la requête directe prouve
Validation côté serveur Le backend vérifie réellement le token auprès de Google ou de Cloudflare, pas seulement sa présence dans le payload
Tests de charge L'endpoint tient le volume sans la surcharge d'un navigateur à chaque itération
Intégration CI/CD La soumission de formulaire passe dans GitHub Actions ou GitLab CI avec un vrai token
Réponses d'erreur Un token invalide ou expiré déclenche bien un code 4xx et le message attendu

Données de test : utilisez des adresses e-mail et des noms fictifs, et minimisez ce que vous conservez dans vos rapports. C'est la bonne hygiène RGPD, et cela évite de traîner de vraies coordonnées dans les logs d'un runner CI hébergé chez OVHcloud, Scaleway ou dans une région AWS eu-west-3 (Paris).


Le principe : résoudre, construire, soumettre, valider

Le flux tient en quatre étapes. Vous obtenez un token via l'API, vous l'insérez dans le payload du formulaire, vous envoyez la requête au backend, puis vous vérifiez la réponse.

┌──────────┐     ┌────────────┐     ┌──────────────┐     ┌──────────────┐
│ Solve    │────▶│ Build      │────▶│ POST to      │────▶│ Validate     │
│ CAPTCHA  │     │ Request    │     │ Endpoint     │     │ Response     │
│ (API)    │     │ Payload    │     │              │     │              │
└──────────┘     └────────────┘     └──────────────┘     └──────────────┘

Aucun navigateur n'est nécessaire pour la plupart des tests de points de terminaison.


Mise en œuvre

Deux classes suffisent : l'une résout le CAPTCHA et renvoie un token, l'autre pilote le test et compare la réponse à ce que vous attendez. Vous les réutilisez pour tous vos endpoints.

Le fournisseur de tokens

Cette première classe isole toute la logique d'appel à l'API : envoi de la tâche à in.php, puis interrogation régulière de res.php jusqu'à obtenir le token. Elle couvre reCAPTCHA v2, reCAPTCHA v3 et Cloudflare Turnstile, qui sont les types que vous croiserez le plus souvent sur des formulaires publics.

import time
import requests

class TokenProvider:
    BASE = "https://ocr.captchaai.com"

    def __init__(self, api_key):
        self.api_key = api_key

    def get_recaptcha_token(self, sitekey, pageurl, version="v2"):
        params = {
            "method": "userrecaptcha",
            "googlekey": sitekey,
            "pageurl": pageurl,
        }
        if version == "v3":
            params["version"] = "v3"
            params["action"] = "submit"
        return self._solve(params, initial_wait=15 if version == "v3" else 10)

    def get_turnstile_token(self, sitekey, pageurl):
        return self._solve({
            "method": "turnstile",
            "sitekey": sitekey,
            "pageurl": pageurl,
        })

    def _solve(self, params, initial_wait=10):
        params["key"] = self.api_key
        params["json"] = 1
        resp = requests.post(f"{self.BASE}/in.php", data=params).json()
        if resp["status"] != 1:
            raise Exception(resp["request"])
        task_id = resp["request"]
        time.sleep(initial_wait)
        for _ in range(60):
            result = requests.get(
                f"{self.BASE}/res.php",
                params={"key": self.api_key, "action": "get", "id": task_id, "json": 1},
            ).json()
            if result["request"] == "CAPCHA_NOT_READY":
                time.sleep(5)
                continue
            if result["status"] == 1:
                return result["request"]
            raise Exception(result["request"])
        raise TimeoutError("Timed out")

Le testeur d'endpoint

La seconde classe orchestre le test lui-même. Elle demande un token au fournisseur, construit le payload avec le bon nom de champ, envoie la requête et compare la réponse à ce que vous attendez. Le nom du champ dépend du type de CAPTCHA :

Type de CAPTCHA Paramètre method Champ du token
reCAPTCHA v2 userrecaptcha g-recaptcha-response
reCAPTCHA v3 userrecaptcha (version=v3) g-recaptcha-response
Cloudflare Turnstile turnstile cf-turnstile-response

Elle expose aussi deux tests négatifs indispensables : le token invalide et le token absent.

import json
import time

class EndpointTester:
    def __init__(self, api_key):
        self.token_provider = TokenProvider(api_key)
        self.session = requests.Session()
        self.results = []

    def test_endpoint(self, config):
        """
        config: {
            "name": "test name",
            "url": "endpoint URL",
            "method": "POST",
            "captcha_type": "recaptcha_v2" | "recaptcha_v3" | "turnstile",
            "sitekey": "...",
            "pageurl": "...",
            "captcha_field": "g-recaptcha-response",
            "payload": { ... form data ... },
            "expected_status": 200,
            "expected_contains": "success",
        }
        """
        start = time.time()
        result = {"name": config["name"], "passed": False}

        try:
            # Get CAPTCHA token
            captcha_type = config.get("captcha_type", "recaptcha_v2")
            if captcha_type == "recaptcha_v2":
                token = self.token_provider.get_recaptcha_token(
                    config["sitekey"], config["pageurl"]
                )
            elif captcha_type == "recaptcha_v3":
                token = self.token_provider.get_recaptcha_token(
                    config["sitekey"], config["pageurl"], version="v3"
                )
            elif captcha_type == "turnstile":
                token = self.token_provider.get_turnstile_token(
                    config["sitekey"], config["pageurl"]
                )
            else:
                raise ValueError(f"Unknown captcha type: {captcha_type}")

            # Build payload
            payload = {**config.get("payload", {})}
            captcha_field = config.get("captcha_field", "g-recaptcha-response")
            payload[captcha_field] = token

            # Submit request
            method = config.get("method", "POST").upper()
            headers = config.get("headers", {})

            if config.get("json_body"):
                resp = self.session.request(
                    method, config["url"], json=payload, headers=headers
                )
            else:
                resp = self.session.request(
                    method, config["url"], data=payload, headers=headers
                )

            # Validate response
            result["status_code"] = resp.status_code
            result["response_length"] = len(resp.text)
            result["elapsed"] = round(time.time() - start, 2)

            # Check expected status
            expected_status = config.get("expected_status", 200)
            if resp.status_code != expected_status:
                result["error"] = f"Expected {expected_status}, got {resp.status_code}"
                self.results.append(result)
                return result

            # Check expected content
            expected = config.get("expected_contains")
            if expected and expected.lower() not in resp.text.lower():
                result["error"] = f"Response missing: '{expected}'"
                self.results.append(result)
                return result

            result["passed"] = True

        except Exception as e:
            result["error"] = str(e)
            result["elapsed"] = round(time.time() - start, 2)

        self.results.append(result)
        return result

    def test_invalid_token(self, config):
        """Test that endpoint rejects invalid CAPTCHA tokens."""
        invalid_config = {**config}
        invalid_config["name"] = f"{config['name']} (invalid token)"

        # Override with fake token
        payload = {**config.get("payload", {})}
        captcha_field = config.get("captcha_field", "g-recaptcha-response")
        payload[captcha_field] = "INVALID_TOKEN_12345"

        start = time.time()
        result = {"name": invalid_config["name"], "passed": False}

        try:
            resp = self.session.post(config["url"], data=payload)
            result["status_code"] = resp.status_code
            result["elapsed"] = round(time.time() - start, 2)

            # Should reject — 4xx or error message
            if resp.status_code >= 400 or "error" in resp.text.lower() or "invalid" in resp.text.lower():
                result["passed"] = True
            else:
                result["error"] = "Endpoint accepted invalid CAPTCHA token"

        except Exception as e:
            result["error"] = str(e)
            result["elapsed"] = round(time.time() - start, 2)

        self.results.append(result)
        return result

    def test_missing_token(self, config):
        """Test that endpoint rejects missing CAPTCHA token."""
        start = time.time()
        result = {"name": f"{config['name']} (missing token)", "passed": False}

        try:
            payload = config.get("payload", {})
            resp = self.session.post(config["url"], data=payload)
            result["status_code"] = resp.status_code
            result["elapsed"] = round(time.time() - start, 2)

            if resp.status_code >= 400 or "captcha" in resp.text.lower():
                result["passed"] = True
            else:
                result["error"] = "Endpoint accepted request without CAPTCHA"

        except Exception as e:
            result["error"] = str(e)
            result["elapsed"] = round(time.time() - start, 2)

        self.results.append(result)
        return result

    def run_suite(self, configs):
        """Run a full test suite against multiple endpoints."""
        for config in configs:
            self.test_endpoint(config)
            self.test_invalid_token(config)
            self.test_missing_token(config)
        return self.report()

    def report(self):
        passed = sum(1 for r in self.results if r["passed"])
        total = len(self.results)
        lines = [f"Endpoint Tests: {passed}/{total} passed", "=" * 50]
        for r in self.results:
            status = "PASS" if r["passed"] else "FAIL"
            elapsed = r.get("elapsed", "?")
            lines.append(f"  [{status}] {r['name']} ({elapsed}s)")
            if r.get("error"):
                lines.append(f"         Error: {r['error']}")
        return "\n".join(lines)

Les tests négatifs sont la partie qui a le plus de valeur. Un token valide accepté ne prouve pas grand-chose ; c'est le token invalide accepté qui révèle un backend qui ne vérifie rien, et donc une faille de sécurité à remonter immédiatement.


Exemple concret : formulaire de contact et inscription à la newsletter

Prenons un cas réaliste pour une petite application SaaS francophone : un formulaire de contact protégé par reCAPTCHA v2 et une inscription à la newsletter protégée par Turnstile. La suite ci-dessous enchaîne, pour chaque endpoint, le test positif puis les deux tests négatifs.

tester = EndpointTester("YOUR_API_KEY")

configs = [
    {
        "name": "Contact form submission",
        "url": "https://example.com/api/contact",
        "captcha_type": "recaptcha_v2",
        "sitekey": "6Le-wvkSAAAAAPBMRTvw0Q4Muexq9bi0DJwx_mJ-",
        "pageurl": "https://example.com/contact",
        "captcha_field": "g-recaptcha-response",
        "payload": {
            "name": "Test User",
            "email": "test@example.com",
            "message": "Automated test message",
        },
        "expected_status": 200,
        "expected_contains": "success",
    },
    {
        "name": "Newsletter signup",
        "url": "https://example.com/api/subscribe",
        "captcha_type": "turnstile",
        "sitekey": "0x4AAAA...",
        "pageurl": "https://example.com/newsletter",
        "captcha_field": "cf-turnstile-response",
        "payload": {
            "email": "test@example.com",
        },
        "expected_status": 200,
    },
]

report = tester.run_suite(configs)
print(report)

Sortie :

Endpoint Tests: 5/6 passed
==================================================
  [PASS] Contact form submission (18.5s)
  [PASS] Contact form submission (invalid token) (0.3s)
  [PASS] Contact form submission (missing token) (0.2s)
  [PASS] Newsletter signup (14.2s)
  [FAIL] Newsletter signup (invalid token) (0.3s)
         Error: Endpoint accepted invalid CAPTCHA token
  [PASS] Newsletter signup (missing token) (0.2s)

Le résultat est parlant : l'inscription à la newsletter accepte un token bidon. Le formulaire de contact, lui, valide correctement. Vous savez exactement quel endpoint corriger, sans avoir ouvert un seul onglet de navigateur.


Questions fréquentes

Faut-il un navigateur pour tester un endpoint protégé par CAPTCHA ?

Non. Tant que la seule barrière est le CAPTCHA, vous récupérez un token via l'API et vous l'envoyez dans le payload comme n'importe quel champ de formulaire. Le navigateur n'est utile que si l'endpoint dépend d'un état JavaScript côté client.

Quels types de CAPTCHA cette approche couvre-t-elle ?

reCAPTCHA v2, reCAPTCHA v3 et Cloudflare Turnstile, qui couvrent la grande majorité des formulaires publics. À noter : hCaptcha et FunCaptcha ne sont pas pris en charge par CaptchaAI, il faudra donc les gérer autrement dans vos suites de tests.

Combien coûte la résolution pendant une campagne de tests ?

La facturation est par thread simultané, pas par résolution. Le plan BASIC ($15/mois, 5 threads) suffit largement pour une suite de tests nocturne, puisque chaque thread traite un CAPTCHA à la fois avec des résolutions illimitées sur le mois.

Comment intégrer ces tests dans une pipeline CI/CD ?

Stockez votre clé API dans un secret du pipeline (GitHub Actions, GitLab CI), lancez la suite comme un job dédié et faites échouer le build si un test négatif passe. Prévoyez quelques secondes de marge par requête, le temps de la résolution.


Dépannage des tests d'endpoints

Quatre symptômes reviennent souvent quand on soumet des tokens en dehors d'un navigateur. Voici comment les diagnostiquer :

Problème Cause probable Correctif
Token valide rejeté Le token a expiré avant l'envoi de la requête Réduisez le délai entre la résolution et la soumission
Token invalide accepté Le backend ne vérifie pas le token CAPTCHA Ouvrez un ticket : c'est une faille de sécurité à corriger
403 sur toutes les requêtes Token CSRF ou cookies de session manquants Ajoutez les cookies de session ou l'en-tête CSRF attendu
L'endpoint JSON refuse les données de formulaire Type de contenu inadapté Passez json_body: True dans la configuration

Guides connexes


Validez chaque endpoint protégé par CAPTCHA de bout en bout — ouvrez un compte CaptchaAI.

Les commentaires sont désactivés pour cet article.