跳转至

会话累积成本速率限制器

概述

速率限制器通过在时间窗口内跟踪每个会话的累积请求成本,保护您的 RAG 聊天机器人免受低频持续攻击

主要特性

不是传统的速率限制

传统方式: "每分钟 10 个请求"
本系统: "每小时 20 个请求,然后 15 分钟冷却"

多维度识别

该系统使用以下方式追踪请求:

  1. 会话 ID(来自 x-session-id 标头或 sessionId 查询参数)
  2. IP 地址(来自 Cloudflare 的 cf-connecting-ip 标头)
  3. 浏览器指纹(来自 x-fingerprint 标头)

至少需要一个标识符。多个标识符可提供更强的保护。

累积成本模型

if (session.requestsInLastHour > 20) {
  return "太多请求。请在 15 分钟后重试。";
}

对于普通用户: 几乎透明(默认:20 请求/小时)
对于攻击脚本: 完全无效

架构

┌─────────────────┐
│   客户端请求    │
└────────┬────────┘
         │
         v
┌─────────────────────────────────┐
│  提取会话标识符                 │
│  - 会话 ID                      │
│  - IP 地址                      │
│  - 指纹                         │
└────────┬────────────────────────┘
         │
         v
┌─────────────────────────────────┐
│  检查 KV 中的现有记录           │
│  键:ratelimit:sid:xxx:ip:xxx   │
└────────┬────────────────────────┘
         │
         v
    ┌────┴────┐
    │ 找到?  │
    └────┬────┘
         │
    ┌────┴─────────────────────┐
    │                          │
    v                          v
┌───────────┐          ┌──────────────┐
│ 无记录    │          │ 检查窗口     │
│           │          │ 和计数       │
└─────┬─────┘          └──────┬───────┘
      │                       │
      v                  ┌────┴────┐
┌──────────┐             │ 有效?  │
│ 计数 = 1 │             └────┬────┘
│ 允许     │                  │
└──────────┘      ┌───────────┴─────────────┐
                  │                         │
                  v                         v
          ┌────────────┐           ┌────────────────┐
          │ < 最大请求?│           │ 冷却中?        │
          └─────┬──────┘           └────┬───────────┘
                │                       │
            ┌───┴────┐              ┌───┴────┐
            v        v              v        v
        ┌──────┐  ┌──────┐      ┌──────┐ ┌──────┐
        │允许  │  │阻止  │      │阻止  │ │重置  │
        │计数 + │  │冷却  │      │429   │ │允许  │
        └──────┘  └──────┘      └──────┘ └──────┘

实现细节

  • 速率限制中间件实现位于 tools/rag-worker/src/middleware/rate-limiter.ts,Worker 核心逻辑在 tools/rag-worker/src/worker.ts 中接入此中间件。
  • KV 绑定名(示例):RATE_LIMIT_KV,请在 wrangler.toml 中将 [[kv_namespaces]]binding 与实际创建的 namespace ID 对应起来。
  • 单元测试位于 tests/unit/rate-limiter.test.ts,可运行 npm test tests/unit/rate-limiter.test.ts 来验证行为。

配置

默认设置

{
  enabled: true,
  timeWindowMs: 60 * 60 * 1000,      // 1 小时
  maxRequests: 20,                    // 20 个请求
  cooldownMs: 15 * 60 * 1000          // 15 分钟
}

环境变量

wrangler.toml 中配置:

[vars]
RATE_LIMIT_ENABLED = "true"            # 启用/禁用
RATE_LIMIT_WINDOW_MS = "3600000"       # 时间窗口(毫秒)
RATE_LIMIT_MAX_REQUESTS = "20"         # 窗口内最大请求数
RATE_LIMIT_COOLDOWN_MS = "900000"      # 冷却持续时间(毫秒)

使用

设置 KV 命名空间

# 创建 KV 命名空间
wrangler kv:namespace create "RATE_LIMIT_KV"

# 添加到 wrangler.toml
[[kv_namespaces]]
binding = "RATE_LIMIT_KV"
id = "your-namespace-id"

客户端集成

基本使用(会话 ID)

// 生成或检索会话 ID
function getSessionId() {
  let sessionId = localStorage.getItem('chat-session-id');
  if (!sessionId) {
    sessionId = `session-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
    localStorage.setItem('chat-session-id', sessionId);
  }
  return sessionId;
}

// 发送带有会话 ID 的请求
async function sendMessage(message) {
  const response = await fetch('/chat', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'x-session-id': getSessionId()
    },
    body: JSON.stringify({ message })
  });

  if (response.status === 429) {
    const error = await response.json();
    alert(`太多请求。请在 ${error.retryAfter} 秒后重试。`);
    return;
  }

  return response.json();
}

增强保护(指纹识别)

import FingerprintJS from '@fingerprintjs/fingerprintjs';

async function getFingerprint() {
  const fp = await FingerprintJS.load();
  const result = await fp.get();
  return result.visitorId;
}

async function sendMessage(message) {
  const fingerprint = await getFingerprint();

  const response = await fetch('/chat', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'x-session-id': getSessionId(),
      'x-fingerprint': fingerprint
    },
    body: JSON.stringify({ message })
  });

  // 处理响应...
}

响应格式

成功 (200)

{
  "answer": "响应文本",
  "sources": [...]
}

速率限制 (429)

{
  "error": "太多请求",
  "message": "您在短时间内发送了太多请求。",
  "cooldown": "冷却:900 秒",
  "retryAfter": 900,
  "tip": "正常使用不会触发此限制。",
  "details": {
    "requestCount": 21,
    "windowStart": "2025-12-16T10:00:00.000Z",
    "lastRequest": "2025-12-16T10:10:00.000Z",
    "coolingUntil": "2025-12-16T10:15:00.000Z"
  }
}

响应标头:

  • Retry-After: 允许重试前的秒数
  • X-RateLimit-Limit: 触发限制的请求计数
  • X-RateLimit-Remaining: 0(超过限制)
  • X-RateLimit-Reset: 限制重置的 Unix 时间戳

监控

Cloudflare 日志

搜索:

operation: rate_limit_exceeded
operation: rate_limit_passed

日志条目示例:

{
  "operation": "rate_limit_exceeded",
  "metadata": {
    "identifier": "session:test-session-123",
    "requestCount": 21,
    "coolingUntil": "2025-12-16T10:25:00.000Z",
    "ttl": 900
  }
}

管理端点(可选)

添加到 worker.ts 用于调试:

if (req.method === 'GET' && url.pathname === '/admin/rate-limit-status') {
  const auth = req.headers.get('authorization');
  if (auth !== `Bearer ${env.ADMIN_TOKEN}`) {
    return new Response('未授权', { status: 401 });
  }

  const sessionId = url.searchParams.get('sessionId');
  if (!sessionId) {
    return new Response('需要 sessionId', { status: 400 });
  }

  const key = `ratelimit:sid:${sessionId}`;
  const record = await env.RATE_LIMIT_KV.get(key, 'json');

  return new Response(JSON.stringify(record, null, 2), {
    headers: { 'Content-Type': 'application/json' }
  });
}

场景和调优

场景 1:个人博客(默认)

低流量,宽松限制:

RATE_LIMIT_WINDOW_MS = "3600000"      # 1 小时
RATE_LIMIT_MAX_REQUESTS = "20"        # 20 个请求
RATE_LIMIT_COOLDOWN_MS = "900000"     # 15 分钟

场景 2:高流量文档

对重度用户更宽松:

RATE_LIMIT_WINDOW_MS = "3600000"      # 1 小时
RATE_LIMIT_MAX_REQUESTS = "50"        # 50 个请求
RATE_LIMIT_COOLDOWN_MS = "600000"     # 10 分钟

场景 3:严格保护

激进的安全限制:

RATE_LIMIT_WINDOW_MS = "1800000"      # 30 分钟
RATE_LIMIT_MAX_REQUESTS = "10"        # 10 个请求
RATE_LIMIT_COOLDOWN_MS = "1800000"    # 30 分钟

场景 4:临时禁用

紧急覆盖:

RATE_LIMIT_ENABLED = "false"

故障排除

问题:速率限制未强制执行

检查清单:

  1. 验证 RATE_LIMIT_ENABLED 不是 "false"
  2. 确认 KV 命名空间绑定正确
  3. 检查客户端是否发送会话 ID 或具有有效 IP
  4. 查看 Worker 日志中的 rate_limit_* 事件

问题:误报(合法用户被阻止)

解决方案:

  1. 增加 RATE_LIMIT_MAX_REQUESTS(例如 20 → 50)
  2. 扩展 RATE_LIMIT_WINDOW_MS(例如 1h → 2h)
  3. 减少 RATE_LIMIT_COOLDOWN_MS(例如 15min → 5min)
  4. 将特定 IP 或会话 ID 加入白名单(需要自定义逻辑)

问题:攻击者绕过限制

可能原因:

  1. 攻击者使用代理池(旋转 IP)
  2. 没有会话 ID 强制

增强功能:

  1. 在客户端代码中强制要求会话 ID
  2. 添加浏览器指纹识别(x-fingerprint
  3. 对新/未识别的会话收紧限制
  4. 考虑 Cloudflare Bot Management(付费)

成本分析

KV 操作

  • 免费层: 每天 100,000 次读取,1,000 次写入
  • 付费: \(0.50/百万次读取,\)5.00/百万次写入

示例(每天 10,000 次聊天):

  • 每个请求 2 个 KV 操作(1 次读取 + 1 次写入)
  • 20,000 操作/天 = 600,000 操作/月
  • 成本: $0(在免费层内)

Worker CPU

速率限制开销:每个请求约 1-2 毫秒(可忽略不计)

测试

运行包含的测试套件:

npm test tests/unit/rate-limiter.test.ts

手动测试脚本:

#!/bin/bash
SESSION_ID="test-$(date +%s)"
for i in {1..25}; do
  STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
    -X POST https://your-worker.dev/chat \
    -H "Content-Type: application/json" \
    -H "x-session-id: $SESSION_ID" \
    -d '{"message":"test"}')

  echo "请求 $i: $STATUS"
  [ "$STATUS" = "429" ] && echo "✅ 速率限制已触发" && break
  sleep 0.5
done

最佳实践

  1. 始终从客户端发送会话 ID 以准确追踪
  2. 定期监控日志 以根据实际使用情况调优限制
  3. 从保守开始(默认配置),如果需要可以放松
  4. 添加指纹识别 以增强对复杂攻击的保护
  5. 在客户端实现重试逻辑 使用指数退避
  6. 清楚地传达限制 在用户面向的错误消息中

总结

有效防御: 阻止低频持续攻击 ✅ 用户友好: 对正常使用透明 ✅ 灵活配置: 针对您的流量模式进行调整 ✅ 具有成本效益: 在 Cloudflare 免费层内运行 ✅ 可观测性: 全面的日志记录和监控

从默认设置开始,根据您的具体需求和攻击模式进行调整。