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
- Envías la tarea a
in.phpconpingbackapuntando a tu endpoint. - CaptchaAI resuelve el CAPTCHA.
- Hace un GET a tu URL con el
idy elcode, 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
- Patrones de manejo de errores en callbacks de CaptchaAI
- Guía de URL de callback y webhook de CaptchaAI
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: