Tutorials

Безопасность CaptchaAI Webhook: проверка подписей обратного вызова

Коротко: сам по себе 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 это не ослабляет защиту.

Дальше по теме

Комментарии для этой статьи отключены.