Tutorials

CaptchaAI Webhook-Sicherheit: Callback-Signaturen validieren

Ein CaptchaAI-Callback-Endpunkt ist eine öffentlich erreichbare URL – und genau das macht ihn angreifbar. Sobald Sie die pingback-Funktion nutzen, schickt CaptchaAI die fertige CAPTCHA-Lösung per HTTP an Ihren Server. Nimmt Ihr Endpunkt jede eingehende Anfrage ungeprüft an, kann jeder, der die URL errät, gefälschte Lösungen einspielen. Die Absicherung besteht aus vier Prüfschichten: Task-ID-Abgleich, HMAC-Signatur, IP-Allowlist und Replay-Schutz. Dieses Tutorial zeigt jede Schicht mit lauffähigem Python- und Node.js-Code.

Warum ein Callback-Endpunkt ein lohnendes Angriffsziel ist

Beim Polling-Modell fragt Ihr Code aktiv den Status ab (res.php) – die Antwort kommt nur auf Ihre eigene, authentifizierte Anfrage. Der Callback dreht diese Richtung um: Ein fremder Server ruft Ihren Endpunkt auf. Damit verschiebt sich die Verantwortung. Nicht CaptchaAI muss sich bei Ihnen ausweisen, sondern Sie müssen entscheiden, ob eine eingehende Anfrage vertrauenswürdig ist.

Das ist heikel, weil die Callback-URL an vielen Stellen sichtbar wird:

  • in Server- und Proxy-Logs,
  • in Fehlermeldungen und Stack-Traces,
  • in der Netzwerküberwachung,
  • in einem versehentlich geleakten Konfigurationsfile.

Wer die URL kennt, kann eine GET-Anfrage mit beliebigen id- und code-Parametern absetzen. Ohne Prüfung landet dieser gefälschte Token in Ihrer Verarbeitung – und Ihre Automatisierung sendet ihn womöglich an das Zielformular weiter. Die folgenden Schichten schließen genau diese Lücke, von der minimalen Pflichtprüfung bis zum Schutz vor wiederholten Anfragen.

So läuft der CaptchaAI-Callback ab


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

Der Knackpunkt ist Schritt 3: Diese GET-Anfrage trägt keinerlei Authentifizierung. Ihr Endpunkt muss selbst feststellen, ob sie wirklich von CaptchaAI stammt. Vier Schichten sichern ihn ab:

  • Task-ID-Abgleich – nur selbst übermittelte IDs akzeptieren.
  • HMAC-Signatur – die Callback-URL kryptografisch signieren.
  • IP-Allowlist – nur bekannte Absender-IPs zulassen.
  • Replay-Schutz – jeden Callback genau einmal verarbeiten.

Die Schichten bauen aufeinander auf – kombinieren Sie sie, statt sich auf eine einzelne zu verlassen.

Schicht 1: Task-ID abgleichen

Die Pflichtprüfung mit dem geringsten Aufwand: Akzeptieren Sie Callback-Ergebnisse nur für Task-IDs, die Sie tatsächlich selbst übermittelt haben. Jede unbekannte ID wird sofort mit 403 abgewiesen. Speichern Sie die offenen IDs in einer Thread-sicheren Struktur, damit parallele Worker sich nicht in die Quere kommen.

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

Diese Prüfung allein hält bereits jede Anfrage ab, deren id nicht aus Ihrer eigenen Übermittlung stammt. Zwei Punkte in der Umsetzung sind wichtig:

  • Speichern Sie offene IDs Thread-sicher (pending_lock), sonst kollidieren parallele Worker beim Zugriff.
  • Betreiben Sie mehrere Instanzen, halten Sie die IDs zentral in Redis vor – so wird ein Callback auch dann erkannt, wenn ihn eine andere Instanz entgegennimmt.

Schicht 2: HMAC-Signatur im Callback-Token

Task-IDs sind mitunter kurz und vorhersehbar. Ergänzen Sie deshalb ein Geheimnis, das ein Angreifer nicht raten kann: Sie berechnen aus der Task-ID und einem CALLBACK_SECRET eine HMAC-SHA256-Signatur und hängen sie als token-Parameter an die Callback-URL. Im Endpunkt berechnen Sie die Signatur erneut und vergleichen sie zeitkonstant.

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

Übergeben Sie beim Absenden die signierte URL: pingback=https://your-server.com/captcha/callback?token=HMAC_SIGNATURE. Zwei Details entscheiden hier über die Sicherheit:

  • Nutzen Sie den zeitkonstanten Vergleich hmac.compare_digest beziehungsweise crypto.timingSafeEqual; ein naiver ==-Vergleich verrät über Laufzeitunterschiede Stück für Stück die korrekte Signatur.
  • Halten Sie CALLBACK_SECRET ausschließlich in einer Umgebungsvariablen – nie im Quellcode und nie in der URL-Historie.

Schicht 3: IP-Allowlist für CaptchaAI-Server

Als zusätzliche Hürde beschränken Sie den Endpunkt auf die bekannten Absender-IPs von CaptchaAI. Wichtig zur Einordnung:

  • Die IP-Allowlist ersetzt die Signaturprüfung nicht – IPs lassen sich fälschen, Signaturen nicht.
  • Sie filtert aber den Großteil des unerwünschten Traffics ab, bevor er überhaupt Rechenzeit kostet.

Python (Flask)

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

Hinweis: Die aktuelle Liste der Callback-Quell-IPs erhalten Sie beim CaptchaAI-Support. Betreiben Sie den Endpunkt hinter einem Reverse-Proxy – etwa auf einem Server bei Hetzner oder IONOS mit vorgelagertem nginx –, müssen Sie den Header X-Forwarded-For korrekt auswerten, sonst sehen Sie nur die IP des Proxys statt der echten Absender-IP.

Replay-Angriffe verhindern

Selbst ein gültiger Callback lässt sich mitschneiden und erneut abspielen. Ergänzen Sie deshalb eine Zeitstempelprüfung und erzwingen Sie die Einmalverwendung: Callbacks, die älter als das TTL-Fenster sind, werden verworfen, und jede Task-ID wird nur einmal verarbeitet.

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

Der Endpunkt kombiniert dafür zwei Mechanismen:

  • Zeitstempelprüfung – Callbacks außerhalb des TTL-Fensters (CALLBACK_TTL) gelten als abgelaufen.
  • Einmalverwendung – jede bereits gesehene Task-ID wird mit 409 abgewiesen.

In der Praxis wächst die Menge used_callbacks unbegrenzt – legen Sie sie in einem Store mit Ablaufzeit ab, etwa als Redis-Schlüssel mit einer TTL, die dem Callback-Fenster entspricht. So bleibt der Replay-Schutz auch über mehrere Prozesse hinweg konsistent.

Sicherheits-Checkliste im Überblick

Schicht Schützt vor Umsetzung
Task-ID-Abgleich Injektion unbekannter Task-IDs Offene IDs speichern, unbekannte ablehnen
HMAC-Signatur Erraten der URL, gefälschte Callbacks Callback-URL mit Secret signieren
IP-Allowlist Anfragen von nicht autorisierten Servern Bekannte CaptchaAI-IPs freigeben
Replay-Schutz Erneut eingespielte gültige Callbacks Einmalverwendung + Zeitstempelprüfung
HTTPS Mitlesen, Man-in-the-Middle TLS am Callback-Endpunkt erzwingen

Behandeln Sie die ersten beiden Schichten als Pflicht, die dritte als sinnvolle Ergänzung und den Replay-Schutz als Muss, sobald am Ende der Kette eine Zahlung, ein Login oder ein anderer sensibler Vorgang steht.

Häufige Fragen

Wie lang muss das Callback-Secret für HMAC sein?

Mindestens 32 zufällige Zeichen aus einem kryptografisch sicheren Generator (secrets.token_urlsafe in Python, crypto.randomBytes in Node.js). Kürzere oder aus Wörtern zusammengesetzte Secrets lassen sich per Brute Force angreifen. Legen Sie das Secret nur als Umgebungsvariable ab, niemals im Code.

Was passiert, wenn mein Endpunkt beim Callback nicht erreichbar ist?

Die Lösung bleibt über das Polling verfügbar: Fragen Sie res.php mit der Task-ID als Fallback ab. Bauen Sie einen Watchdog, der Aufgaben ohne Callback nach einem Zeitfenster aktiv nachlädt, statt sich allein auf den eingehenden Ping zu verlassen.

Warum reicht HTTPS am Endpunkt nicht als alleinige Absicherung?

HTTPS schützt den Transportweg gegen Mitlesen und Manipulation, sagt aber nichts über die Herkunft der Anfrage aus. Ein Angreifer kann jederzeit selbst eine HTTPS-GET-Anfrage an Ihre URL senden. Erst Task-ID-Abgleich und HMAC-Signatur belegen, dass der Callback wirklich zu einer Ihrer Übermittlungen gehört.

Zählen die gefilterten Client-IPs als personenbezogene Daten nach DSGVO?

Ja – IP-Adressen gelten nach DSGVO in der Regel als personenbezogene Daten. Für die Allowlist heißt das:

  • Zweck und Aufbewahrungsdauer der geloggten IPs dokumentieren.
  • Rechtsgrundlage der Verarbeitung prüfen.

Das betrifft Ihre eigene Infrastruktur, nicht die CAPTCHA-Lösung selbst.

Fehlerbehebung

Diese Fälle treten beim Einrichten der Callback-Validierung am häufigsten auf – jeweils mit Ursache und konkreter Lösung:

Problem Ursache Lösung
Alle Callbacks werden abgelehnt IP-Allowlist enthält die CaptchaAI-IPs nicht Aktuelle IPs beim Support erfragen; X-Forwarded-For hinter dem Proxy prüfen
HMAC-Prüfung schlägt fehl Task-ID bei Übermittlung und Callback weichen ab Exakt die von in.php zurückgegebene Task-ID signieren
Doppelte Callbacks in der Verarbeitung Race Condition bei parallelen Callbacks Atomare Set-Operationen oder Unique-Constraint in der Datenbank
Callbacks laufen ins Timeout Endpunkt antwortet zu langsam Sofort mit 200 quittieren, Verarbeitung asynchron im Hintergrund

Verwandte Leitfäden

Kommentare sind für diesen Artikel deaktiviert.