Tutoriales

Seguridad del webhook CaptchaAI: validación de firmas de callback

Un endpoint de callback sin validar es una puerta abierta: cualquiera que descubra la URL puede enviarte resoluciones CAPTCHA falsas. Le pasa a cualquier equipo en producción —una agencia en Madrid o Ciudad de México que automatiza el QA de sus formularios— en cuanto expone un endpoint. La defensa son cuatro capas que se refuerzan:

  • ID de tarea — aceptas solo tareas que tú enviaste.
  • Firma HMAC — añades un token que nadie puede adivinar.
  • Lista blanca de IPs — restringes el origen a CaptchaAI.
  • Prevención de replay — bloqueas el reenvío de callbacks legítimos.

Cómo funciona el flujo de callback

Con pingback activado dejas de sondear res.php y recibes el resultado por push. El intercambio ocurre en tres pasos:


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
  1. Envías la tarea a in.php con pingback apuntando a tu endpoint.
  2. CaptchaAI resuelve el CAPTCHA.
  3. Hace un GET a tu URL con el id y el code, sin autenticar: ese es el hueco que cierran las capas siguientes.

Capa 1: verifica el ID de la tarea

La comprobación más barata y la primera que deberías montar: guarda cada ID al enviar la tarea y rechaza los callbacks cuyo ID no figure en la lista. En Python (Flask) y JavaScript (Express):

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
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);

Capa 2: firma la URL con un token HMAC

Un ID legítimo observado todavía podría reutilizarse, así que añade un secreto que solo conoce tu servidor. Firmas el ID con HMAC-SHA256 y verificas la firma en cada solicitud:

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
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);
});

Compara en tiempo constante con hmac.compare_digest (Python) o crypto.timingSafeEqual (Node.js), nunca con ==. Al enviar la tarea usa la URL firmada: pingback=https://your-server.com/captcha/callback?token=HMAC_SIGNATURE.

Capa 3: restringe el origen con una lista blanca de IPs

Limita el endpoint a las IPs desde las que responde CaptchaAI. Es una defensa perimetral: útil como refuerzo, pero frágil por sí sola —las IPs cambian—, así que combínala con las anteriores:

# CaptchaAI callback source IPs (verify current IPs con 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
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();
});

Nota: Pide al soporte de CaptchaAI la lista actualizada de IPs de origen. Tras un proxy inverso, revisa el encabezado X-Forwarded-For; si no, verás la IP del proxy en lugar de la de CaptchaAI y rechazarás todo.

Capa 4: bloquea los ataques de repetición (replay)

Un callback legítimo también puede capturarse y reenviarse para procesarlo dos veces. Cierra esa puerta con una marca de tiempo que caduque (CALLBACK_TTL = 300, 5 minutos) y un registro de uso único que rechace cualquier ID ya visto. Respáldalo en Redis o en la base de datos para varios workers:

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

Checklist de seguridad combinada

Cada capa cubre un riesgo distinto:

Capa Protege contra Implementación
ID de tarea IDs desconocidos o inventados Guardar IDs pendientes, rechazar el resto
Firma HMAC URL adivinada, callbacks falsificados Firmar la URL con un secreto
Lista blanca de IPs Servidores no autorizados Permitir solo las IPs de CaptchaAI
Prevención de replay Callbacks válidos reenviados Uso único + marca de tiempo
HTTPS Interceptación, intermediario TLS en el endpoint

Solución de problemas

Los fallos más habituales:

Problema Causa probable Solución
Se rechazan todos los callbacks La lista blanca no incluye las IPs reales Verifica las IPs con soporte y revisa el proxy inverso
La verificación HMAC falla siempre El ID no coincide entre envío y callback Usa el ID exacto que devuelve in.php
Callbacks duplicados procesados Condición de carrera entre callbacks simultáneos Operaciones atómicas o restricción UNIQUE
Los callbacks expiran El endpoint tarda en responder Responde 200 al instante y procesa en segundo plano

Preguntas frecuentes

¿Necesito HTTPS si ya firmo las URL con HMAC?

Sí. La firma HMAC autentica el origen, pero no cifra el tráfico: sin TLS, el token viaja en claro. Sirve pingback siempre sobre HTTPS.

¿Cómo evito procesar el mismo callback dos veces con varios workers?

Un conjunto en memoria solo protege un proceso. Con varios workers, centraliza el registro: una clave con TTL en Redis (SETNX) o una restricción UNIQUE sobre el ID de tarea. Así el segundo intento falla limpio.

¿Qué hago si CaptchaAI cambia sus IPs de origen?

No dependas solo de la lista blanca: trátala como refuerzo y usa la firma HMAC como control principal. Guárdalas en configuración (variable de entorno o tabla), no en el código, para cambiarlas sin volver a desplegar.

¿Estas validaciones sirven para reCAPTCHA, Turnstile y GeeTest v3 por igual?

Sí. El callback es agnóstico al tipo: recibes un id y un code sin importar qué resolviste. CaptchaAI resuelve reCAPTCHA v2/v3, Cloudflare Turnstile y Challenge, GeeTest v3 e imagen/OCR, y todas llegan por el mismo pingback.

Artículos relacionados


Blinda tus endpoints de callback

Protege tus endpoints de callback de extremo a extremo: obtén tu clave API, firma cada URL y despliega con las cuatro capas activas.

Guías relacionadas:

Los comentarios están deshabilitados para este artículo.