Hướng Dẫn Thực Hành

Bảo mật webhook CaptchaAI: xác thực chữ ký callback

Nếu ai đó đoán được URL callback (pingback) của bạn, họ gửi thẳng được một token giả tới endpoint đó — code phía sau không có cách nào phân biệt với kết quả thật từ CaptchaAI. Bốn lớp xác thực dưới đây, xếp chồng lên nhau, đóng lỗ hổng này.

Một team automation ở công ty outsourcing tại TP.HCM từng gặp đúng lỗi này: pipeline theo dõi giá trên sàn thương mại điện tử dùng CaptchaAI giải CAPTCHA và nhận kết quả qua webhook, nhưng endpoint callback bị dò quét vì không kiểm tra nguồn request. Thêm xác thực ID task và chữ ký HMAC là đủ để log spam biến mất.

Luồng callback CaptchaAI hoạt động thế nào


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

Bước 3 chính là điểm hở: một request GET không hề có xác thực đi kèm. Ai biết URL endpoint cũng gửi được request y hệt, tự chọn idcode.

  • Không xác thực: server chấp nhận mọi callback gửi đến, kể cả token giả.
  • Có xác thực: server chỉ tin callback đã được chứng minh xuất phát từ CaptchaAI.

Cách 1: Xác minh ID task

Lớp bảo vệ rẻ nhất, nên có ở mọi endpoint: chỉ chấp nhận callback cho ID task đã tự gửi lên trước đó, từ chối mọi ID lạ.

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

Cách 2: Ký callback bằng HMAC

Xác minh ID task chặn được request ngẫu nhiên, nhưng không chặn được kẻ đoán đúng ID hợp lệ (ví dụ dò ID tuần tự). Ký URL callback bằng HMAC để chỉ ai biết CALLBACK_SECRET mới tạo được token hợp lệ.

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

Dùng URL đã ký ngay khi gửi task: pingback=https://your-server.com/captcha/callback?token=HMAC_SIGNATURE.

Cách 3: Giới hạn IP nguồn gửi callback

Lớp phòng thủ ở tầng mạng: chỉ chấp nhận request từ dải IP máy chủ CaptchaAI, nguồn khác bị chặn trước khi chạm logic xử lý.

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

Lưu ý: Liên hệ hỗ trợ CaptchaAI để lấy danh sách IP nguồn callback mới nhất — IP có thể thay đổi. Nếu bạn chạy sau reverse proxy (Nginx, Cloudflare), kiểm tra header X-Forwarded-For được cấu hình đúng, nếu không request.remote_addr sẽ luôn trả về IP của proxy chứ không phải IP thật.

Chống tấn công replay

Ba lớp trên xác nhận callback đến từ đúng nguồn, nhưng một callback hợp lệ vẫn có thể bị chặn lại và gửi lại sau đó (replay). Kiểm tra dấu thời gian và bắt buộc mỗi task chỉ xử lý một lần:

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

Checklist bảo mật kết hợp

  • Endpoint nội bộ, ít rủi ro: xác minh ID task là đủ để bắt đầu.
  • Endpoint public hoặc xử lý workflow nhạy cảm: thêm HMAC, IP allowlist và chặn replay.
Lớp Chặn được gì Cách triển khai
Xác minh ID task Task ID ngẫu nhiên hoặc không xác định Lưu ID đang chờ xử lý, từ chối ID lạ
Chữ ký HMAC Dò URL, callback giả mạo Ký URL callback bằng secret riêng
Giới hạn IP nguồn Request từ server không được phép Whitelist dải IP của CaptchaAI
Chặn replay Gửi lại callback hợp lệ đã xử lý Xử lý một lần + kiểm tra dấu thời gian
HTTPS Nghe lén, tấn công xen giữa (MITM) Bắt buộc TLS trên endpoint callback

Xử lý sự cố thường gặp

Vấn đề Nguyên nhân Cách xử lý
Toàn bộ callback bị từ chối Whitelist IP chưa cập nhật IP mới của CaptchaAI Xác minh IP hiện tại với support; kiểm tra header proxy ngược
Xác minh HMAC luôn thất bại ID task ở bước ký khác với ID task trong callback Dùng đúng task_idin.php trả về, không tự sinh ID khác
Một task bị xử lý trùng nhiều lần Race condition khi hai callback đến gần như đồng thời Dùng thao tác set nguyên tử hoặc ràng buộc unique ở database
Callback timeout Endpoint xử lý logic nặng trước khi trả response Trả 200 OK ngay lập tức, đẩy xử lý thật vào hàng đợi nền

Kiểm tra nhanh trước khi deploy

  • Endpoint callback trả 200 OK trong dưới 1 giây, xử lý nặng chạy nền.
  • Secret CALLBACK_SECRET không nằm trong code, chỉ trong biến môi trường.
  • Có job dự phòng poll res.php cho task không nhận được callback.

Câu hỏi thường gặp

Callback CaptchaAI có bắt buộc chạy trên HTTPS không?

Không bắt buộc ở giao thức (CaptchaAI gửi GET tới URL bạn khai báo, kể cả HTTP), nhưng nên dùng HTTPS luôn. Không có TLS, token giải CAPTCHA đi qua endpoint của bạn ở dạng plaintext và có thể bị đọc trộm trên đường truyền.

Nên chọn callback hay polling cho pipeline QA nội bộ?

Phụ thuộc vào hạ tầng bạn có:

  • Callback: cần kết quả gần như ngay lập tức, endpoint mở ra public được.
  • Polling (res.php): triển khai đơn giản hơn, không cần mở cổng ra ngoài.

Nhiều team dùng cả hai, polling làm phương án dự phòng.

Endpoint callback bị down thì có mất kết quả giải CAPTCHA không?

Không. Giải pháp vẫn nằm ở endpoint polling (res.php) chờ bạn lấy về — chạy một job nền poll lại task chưa nhận được callback sau timeout là đủ.

Có thể dùng mTLS thay vì HMAC để xác thực callback không?

Về lý thuyết có thể, nhưng thực tế thì không:

  • Hệ thống callback của CaptchaAI dùng HTTPS GET tiêu chuẩn, không hỗ trợ chứng chỉ client.
  • Chữ ký HMAC đạt mức xác thực tương đương mà không cần quản lý certificate ở cả hai phía.

Bài viết liên quan

Bước tiếp theo

Lấy API key CaptchaAI và thêm xác thực chữ ký vào endpoint callback trước khi đưa lên production.

Hướng dẫn liên quan:

Os comentários estão desativados para este artigo.