Коротко: сам по себе pingback-эндпоинт CaptchaAI ничего не аутентифицирует — это обычный HTTP-запрос на ваш сервер, и если его URL кто-то узнает или подберёт, он сможет прислать поддельное «решение» вместо настоящего. Защита строится не на одном приёме, а на комбинации из нескольких: проверке ID задачи, подписи HMAC, белого списка IP и запрета повторного использования callback. Ниже — рабочие реализации каждого способа на Python и JavaScript, плюс чек-лист, который можно сразу приложить к код-ревью.
Проблема актуальна для любого, кто принимает результаты через pingback, а не опрашивает res.php вручную: вы выигрываете во времени отклика, но открываете HTTP-эндпоинт, который нужно защищать так же, как любой публичный webhook.
Как устроен обратный вызов CaptchaAI
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
Обратите внимание на шаг 3: это неаутентифицированный GET-запрос. Ваш сервер обязан сам убедиться, что он действительно пришёл от CaptchaAI, а не от произвольного клиента, угадавшего URL.
Способ 1: проверка ID задачи
Самый быстрый в реализации вариант — принимать результат только по тем идентификаторам задач, которые вы сами отправили в in.php. Всё остальное отбрасывается как неизвестное.
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
JavaScript (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);
Способ 2: подпись HMAC
Проверка ID задачи не спасёт, если атакующий каким-то образом узнал реальный task_id (например, из логов прокси). Более надёжный вариант — подписать сам URL обратного вызова секретом, который никогда не покидает ваш сервер: подобрать HMAC-SHA256 без знания секрета вычислительно нереально.
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);
});
Передавайте этот URL в параметре pingback при отправке задачи: pingback=https://your-server.com/captcha/callback?token=HMAC_SIGNATURE. Секрет храните в переменных окружения или секрет-хранилище — никогда в коде и не в логах.
Способ 3: белый список IP
Третий уровень — ограничить сам эндпоинт диапазоном IP-адресов, с которых CaptchaAI шлёт обратные вызовы. Полезен как дополнение к HMAC, а не замена ему: список IP может устареть, а подпись — нет.
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
JavaScript (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();
});
Важно. Актуальный список IP-адресов источников callback уточняйте в поддержке CaptchaAI — он может меняться. Если сервер стоит за обратным прокси (nginx, Cloudflare), проверьте, что заголовок
X-Forwarded-Forдоходит без искажений, иначеclient_ipбудет адресом самого прокси, а не CaptchaAI.
Для команд, которые разворачивают эндпоинт в европейском регионе или в Казахстане/Центральной Азии — а среди русскоязычных инженеров это частый выбор хостинга — белый список IP работает так же: он проверяет источник запроса, а не сеть получателя.
Защита от повторной отправки (replay)
Даже подлинный, правильно подписанный callback можно повторить: перехватить и отправить снова, или CaptchaAI сам повторит доставку при сетевом сбое на вашей стороне. Добавьте проверку возраста запроса по временной метке и запрет повторной обработки одного и того же task_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
Сводный чек-лист защиты
| Слой | От чего защищает | Как реализовать |
|---|---|---|
| Проверка ID задачи | Случайные или неизвестные task_id |
Храните набор ожидаемых ID, отклоняйте всё остальное |
| Подпись HMAC | Подбор URL, поддельные callback | Подписывайте URL обратного вызова секретом на сервере |
| Белый список IP | Запросы не от серверов CaptchaAI | Ограничьте эндпоинт актуальным диапазоном IP CaptchaAI |
| Защита от replay | Повторная отправка подлинного callback | Одноразовое использование ID + проверка временной метки |
| HTTPS | Перехват трафика, атака «человек посередине» | TLS на всём пути до эндпоинта обратного вызова |
Для продакшена достаточно проверки ID задачи как минимума и HMAC-подписи как основного слоя. Белый список IP и replay-защиту добавляйте, если эндпоинт публично доступен или обрабатывает платёжные/чувствительные сценарии.
Типичные проблемы и их причины
- Все callback отклоняются. В белом списке нет актуальных IP CaptchaAI — уточните текущие IP в поддержке и проверьте заголовки обратного прокси.
- HMAC-подпись не совпадает.
task_idпри подписи и в самом callback различаются — используйте ровно тот ID, который вернулin.php, без преобразований и лишних пробелов. - Один callback обработан дважды. Классическая гонка при параллельных запросах — используйте атомарные операции с множеством или уникальный constraint в БД.
- Callback обрывается по таймауту. Эндпоинт слишком долго отвечает — возвращайте
200 OKсразу, а тяжёлую обработку выносите в фоновую задачу или очередь. age < 0при проверкеts. Часы сервера и клиента задачи рассинхронизированы — синхронизируйте время по NTP и добавьте допуск в 1–2 секунды.
Частые вопросы
Обязательно ли использовать все три способа проверки сразу?
Нет. Проверка ID задачи — обязательный минимум для любого продакшен-эндпоинта. HMAC-подпись добавляйте, если эндпоинт доступен из интернета без дополнительной защиты. Белый список IP — по возможности, но не как единственный слой: список может отставать от реальной инфраструктуры CaptchaAI.
Что делать, если сервер обратного вызова временно недоступен?
Решение не теряется — оно остаётся доступным через res.php. Держите фоновую задачу, которая опрашивает res.php по задачам, для которых callback не пришёл в течение разумного таймаута (например, 60–90 секунд), и используйте её как страховку на случай простоя.
Как отличить настоящий callback от подделки, если список IP CaptchaAI под рукой нет?
В этом случае HMAC-подпись — единственная надёжная проверка: белый список IP полезен, но необязателен, а подпись с секретом на вашей стороне не подделать без утечки самого секрета. Если ни подпись, ни IP не настроены, полагайтесь только на проверку task_id и считайте эндпоинт временно небезопасным.
Нужно ли логировать входящие callback-запросы?
Да, но осторожно с составом полей: логируйте task_id, IP-источник, временную метку и результат проверки — этого достаточно для расследования инцидентов. Не пишите в логи сам решённый токен CAPTCHA дольше, чем нужно для обработки, и учитывайте требования к обработке персональных данных (для аудитории РФ — 152-ФЗ, для международных команд — аналогичная GDPR-дисциплина): собирайте и храните только то, что реально нужно для работы эндпоинта.
Какой TTL выбрать для проверки временной метки в продакшене?
5 минут (300 секунд), как в примере выше, — рабочее значение по умолчанию: с запасом покрывает сетевые задержки и ретраи, но не даёт использовать перехваченный callback спустя долгое время. Если инфраструктура работает через нестабильные мобильные каналы, можно увеличить окно до 10 минут — вместе с одноразовым использованием ID это не ослабляет защиту.