Tutorials

Sécuriser les webhooks CaptchaAI et valider les callbacks

Un endpoint de callback (pingback) CaptchaAI qui ne vérifie pas l'origine de ses requêtes accepte de fausses solutions envoyées par quiconque a deviné l'URL. La parade ne tient pas à un seul mécanisme, mais à quatre contrôles qui se cumulent :

  • vérifier que l'ID de tâche correspond à une soumission réelle ;
  • signer l'URL de callback avec un HMAC ;
  • restreindre les IP sources autorisées ;
  • bloquer les rejeux par horodatage et usage unique.

Avant de commencer

  • une clé API CaptchaAI active et un endpoint en HTTPS ;
  • des tâches envoyées via in.php avec pingback ;
  • Flask ou Express déjà en place.

Comment CaptchaAI vous renvoie une solution


1. You submit task:
   POST https://ocr.captchaai.com/in.php
     ?key=YOUR_API_KEY
     &method=userrecaptcha
     &googlekey=SITE_KEY
     &pageurl=https://example.com
     &pingback=https://your-server.com/captcha/callback

2. CaptchaAI solves the CAPTCHA

3. CaptchaAI sends result to your endpoint:
   GET https://your-server.com/captcha/callback?id=TASK_ID&code=SOLUTION_TOKEN
  • l'étape 3 n'est pas authentifiée : rien ne prouve son origine ;
  • un tiers qui connaît l'URL peut y poster de fausses solutions.

Contrôle 1 : n'accepter que les ID de tâche connus

  • à l'envoi, enregistrez l'ID de tâche renvoyé par in.php ;
  • au callback, rejetez tout ID absent de cette liste.

Python (Flask)

import os
import threading
import requests
from flask import Flask, request, jsonify

app = Flask(__name__)

# Thread-safe set of pending task IDs
pending_tasks = set()
pending_lock = threading.Lock()
results = {}

API_KEY = os.environ["CAPTCHAAI_API_KEY"]


def submit_captcha(sitekey, pageurl):
    """Submit CAPTCHA and register the task ID."""
    resp = requests.post("https://ocr.captchaai.com/in.php", data={
        "key": API_KEY,
        "method": "userrecaptcha",
        "googlekey": sitekey,
        "pageurl": pageurl,
        "pingback": "https://your-server.com/captcha/callback",
        "json": 1
    })
    data = resp.json()

    if data.get("status") == 1:
        task_id = data["request"]
        with pending_lock:
            pending_tasks.add(task_id)
        return task_id
    return None


@app.route("/captcha/callback")
def captcha_callback():
    task_id = request.args.get("id")
    solution = request.args.get("code")

    # Validate: only accept known task IDs
    with pending_lock:
        if task_id not in pending_tasks:
            return jsonify({"error": "unknown task"}), 403
        pending_tasks.discard(task_id)

    results[task_id] = solution
    return "OK", 200

Node.js (Express)

const express = require("express");
const axios = require("axios");

const app = express();
const API_KEY = process.env.CAPTCHAAI_API_KEY;

const pendingTasks = new Set();
const results = new Map();

async function submitCaptcha(sitekey, pageurl) {
  const resp = await axios.post("https://ocr.captchaai.com/in.php", null, {
    params: {
      key: API_KEY,
      method: "userrecaptcha",
      googlekey: sitekey,
      pageurl: pageurl,
      pingback: "https://your-server.com/captcha/callback",
      json: 1,
    },
  });

  if (resp.data.status === 1) {
    const taskId = resp.data.request;
    pendingTasks.add(taskId);
    return taskId;
  }
  return null;
}

app.get("/captcha/callback", (req, res) => {
  const taskId = req.query.id;
  const solution = req.query.code;

  // Validate: only accept known task IDs
  if (!pendingTasks.has(taskId)) {
    return res.status(403).json({ error: "unknown task" });
  }

  pendingTasks.delete(taskId);
  results.set(taskId, solution);
  res.sendStatus(200);
});

app.listen(3000);

Contrôle 2 : signer l'URL de callback avec un HMAC

  • signez l'ID de tâche avec un secret côté serveur ;
  • placez la signature dans l'URL de callback ;
  • au callback, recalculez-la et comparez-la en temps constant.

Python

import hashlib
import hmac
import os

CALLBACK_SECRET = os.environ["CALLBACK_SECRET"]  # Random 32+ character string


def generate_callback_url(task_id):
    """Generate callback URL with HMAC signature."""
    signature = hmac.new(
        CALLBACK_SECRET.encode(),
        task_id.encode(),
        hashlib.sha256
    ).hexdigest()

    return f"https://your-server.com/captcha/callback?token={signature}"


@app.route("/captcha/callback")
def captcha_callback():
    task_id = request.args.get("id")
    token = request.args.get("token")
    solution = request.args.get("code")

    # Verify HMAC signature
    expected = hmac.new(
        CALLBACK_SECRET.encode(),
        task_id.encode(),
        hashlib.sha256
    ).hexdigest()

    if not hmac.compare_digest(token, expected):
        return jsonify({"error": "invalid signature"}), 403

    results[task_id] = solution
    return "OK", 200

Javascript

const crypto = require("crypto");

const CALLBACK_SECRET = process.env.CALLBACK_SECRET;

function generateCallbackUrl(taskId) {
  const signature = crypto
    .createHmac("sha256", CALLBACK_SECRET)
    .update(taskId)
    .digest("hex");

  return `https://your-server.com/captcha/callback?token=${signature}`;
}

app.get("/captcha/callback", (req, res) => {
  const taskId = req.query.id;
  const token = req.query.token;
  const solution = req.query.code;

  // Verify HMAC signature
  const expected = crypto
    .createHmac("sha256", CALLBACK_SECRET)
    .update(taskId)
    .digest("hex");

  if (!crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expected))) {
    return res.status(403).json({ error: "invalid signature" });
  }

  results.set(taskId, solution);
  res.sendStatus(200);
});

À la soumission, utilisez l'URL signée : pingback=https://your-server.com/captcha/callback?token=HMAC_SIGNATURE. Comparez avec compare_digest / timingSafeEqual, en temps constant.

Contrôle 3 : restreindre les IP sources autorisées

  • n'autorisez les callbacks que depuis les IP des serveurs CaptchaAI ;
  • efficace seulement si ces IP restent stables et si vous voyez la vraie IP source.

Python (Flask)

# CaptchaAI callback source IPs (verify current IPs with CaptchaAI support)
ALLOWED_IPS = {"138.201.XX.XX", "148.251.XX.XX"}  # Replace with actual IPs


@app.before_request
def check_ip():
    if request.path.startswith("/captcha/callback"):
        client_ip = request.remote_addr
        if client_ip not in ALLOWED_IPS:
            return jsonify({"error": "forbidden"}), 403

Node.js (Express)

const ALLOWED_IPS = new Set(["138.201.XX.XX", "148.251.XX.XX"]);

app.use("/captcha/callback", (req, res, next) => {
  const clientIp = req.ip || req.connection.remoteAddress;
  if (!ALLOWED_IPS.has(clientIp)) {
    return res.status(403).json({ error: "forbidden" });
  }
  next();
});

Remarque : demandez au support la liste à jour des IP sources. Derrière un reverse proxy, vérifiez que l'en-tête X-Forwarded-For est propagé, sinon vous filtrerez l'IP du proxy.

Bloquer les attaques par rejeu

Un callback légitime peut être rejoué tel quel. Deux garde-fous se complètent :

  • rejeter les callbacks trop anciens via un horodatage (ts) ;
  • n'autoriser qu'un seul traitement par ID.

Python

import time

CALLBACK_TTL = 300  # Reject callbacks older than 5 minutes
used_callbacks = set()


@app.route("/captcha/callback")
def captcha_callback():
    task_id = request.args.get("id")
    timestamp = request.args.get("ts")
    solution = request.args.get("code")

    # Check timestamp freshness
    if timestamp:
        age = time.time() - float(timestamp)
        if age > CALLBACK_TTL or age < 0:
            return jsonify({"error": "expired"}), 403

    # One-time use
    if task_id in used_callbacks:
        return jsonify({"error": "already processed"}), 409

    used_callbacks.add(task_id)
    results[task_id] = solution
    return "OK", 200

Déployer l'endpoint de callback en production

En production, l'endpoint vit derrière un reverse proxy (Nginx sur OVHcloud, Load Balancer Scaleway, région AWS eu-west-3 à Paris). Deux points de vigilance :

  • IP source réelle : l'application voit l'IP du proxy. L'allowlist du contrôle 3 doit s'appuyer sur X-Forwarded-For, jamais sur une valeur du client.
  • RGPD et logs : le callback transporte une solution CAPTCHA et votre token HMAC. Minimisez et masquez l'URL signée dans vos logs.

Dépannage

Problème Cause Correctif
Tous les callbacks rejetés (403) L'allowlist n'inclut pas les IP de CaptchaAI Vérifier les IP à jour ; contrôler les en-têtes du proxy
La vérification HMAC échoue ID différent entre soumission et callback Utiliser l'ID exact renvoyé par in.php
Callbacks traités en double Concurrence sur des callbacks simultanés Opérations atomiques ou contrainte d'unicité en base
Callbacks en timeout L'endpoint répond trop lentement Accuser réception aussitôt, traiter en arrière-plan
Signatures rejetées après un redémarrage L'état en mémoire est perdu au reboot Persister l'état en Redis plutôt qu'en mémoire

Récapitulatif : empiler les couches

  • Vérification de l'ID — contre les ID inconnus : rejeter tout ID non enregistré.
  • Signature HMAC — contre les URL devinées : signer l'URL avec un secret.
  • Allowlist d'IP — contre les serveurs non autorisés : filtrer sur les IP de CaptchaAI.
  • Anti-rejeu — contre les callbacks rejoués : usage unique et horodatage.
  • HTTPS — contre l'écoute réseau : TLS sur l'endpoint.

Questions fréquentes

Faut-il implémenter les quatre contrôles pour un simple scraper ?

Non. Le contrôle 1 suffit souvent en interne. Ajoutez le HMAC si l'endpoint est public, et l'anti-rejeu pour les flux sensibles.

Où stocker les ID de tâche avec plusieurs workers ?

L'ensemble en mémoire ne vaut que dans un processus. Déplacez pending_tasks et used_callbacks vers un store partagé comme Redis.

Le token HMAC exposé dans l'URL présente-t-il un risque ?

Il apparaît dans les logs et parfois le Referer. Signez l'ID de tâche, masquez l'URL et gardez HTTPS ; le secret ne quitte jamais le serveur.

Les IP de callback de CaptchaAI sont-elles fixes ?

Ne le supposez pas. Demandez la liste au support et gardez le HMAC comme protection principale, indépendante du réseau.

Le même code de validation vaut-il pour reCAPTCHA, Turnstile et GeeTest v3 ?

Oui. Le pingback fonctionne à l'identique quel que soit le type résolu ; seule la soumission change.

Peut-on utiliser le mTLS plutôt que le HMAC ?

En théorie oui, mais le callback repose sur de simples requêtes HTTPS GET. Le HMAC offre une authentification équivalente sans gestion de certificats.

Articles connexes

Prochaines étapes

Sécurisez vos endpoints de callback CaptchaAI : récupérez votre clé API et déployez la validation de signature dès votre prochaine intégration.

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