人人都会AI编程

14.5 跨域处理、接口限流、防刷防爬方案

更新时间:2026-07-10

Web 服务的接口一旦公开到互联网上,就必须面对两个基本现实:浏览器同源策略带来的跨域请求限制,以及恶意调用、爬虫抓取、暴力破解等行为带来的风险。这一节我们来梳理如何在 Node.js 应用中有效处理跨域访问,并对接口施加合理的频率限制和防刷防爬保护。

本节内容基于 Express/Koa/NestJS 等主流框架,但无论你使用哪一个,核心思路都是通用的,只是具体中间件的用法略有差异。


14.5.1 跨域处理:CORS 的正确配置方式

1. 什么是跨域以及为什么需要 CORS

浏览器为了安全,实施了同源策略:一个网页中的 JavaScript 只能请求与其同源(协议、域名、端口完全相同)的服务端接口。当你的前端页面部署在 https://example.com,而后端 API 在 https://api.example.com(不同子域名)时,浏览器就会拦截这个请求,除非服务端明确表示允许跨域访问。

解决跨域问题的标准方案就是 CORS(Cross-Origin Resource Sharing),它通过一组 HTTP 响应头告诉浏览器:这个服务器允许哪些外部源、哪些 HTTP 方法、哪些请求头。

对于 Node.js 开发者来说,CORS 的实现不需要自己手动拼凑响应头,已经有大量成熟且稳定的中间件可以直接使用。

2. 使用 cors 中间件(Express / Koa / Fastify)

最流行的跨域中间件是 cors,它在 Express、Koa(通过 @koa/cors)、Fastify(通过 @fastify/cors)中都有对应的封装。

Express 中的基本用法:

const express = require('express');
const cors = require('cors');

const app = express();

// 允许所有源访问(仅适用于开发阶段或公开 API)
app.use(cors());

app.get('/api/data', (req, res) => {
  res.json({ message: 'Hello from server' });
});

app.listen(3000);

生产环境中常见的精细配置:

const corsOptions = {
  origin: ['https://example.com', 'https://admin.example.com'],  // 白名单
  methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
  allowedHeaders: ['Content-Type', 'Authorization'],
  credentials: true,            // 允许携带 Cookie
  maxAge: 86400,                // 预检请求缓存时间(秒)
};

app.use(cors(corsOptions));

如果你的场景更复杂(例如需要根据数据库动态判断某个域名是否合法),可以将 origin 字段设为一个函数:

origin: (origin, callback) => {
  // origin 可能是 undefined(如非浏览器的请求)
  const allowedOrigins = ['https://myapp.com'];
  if (!origin || allowedOrigins.includes(origin)) {
    callback(null, true);
  } else {
    callback(new Error('Not allowed by CORS'));
  }
}

3. Koa 与 NestJS 的跨域配置

Koa 使用 @koa/cors

const Koa = require('koa');
const cors = require('@koa/cors');

const app = new Koa();
app.use(cors({
  origin: 'https://myapp.com',
  credentials: true,
}));

NestJS 内置了 CORS 支持,可以在 main.ts 中开启:

const app = await NestFactory.create(AppModule);
app.enableCors({
  origin: 'https://myapp.com',
  credentials: true,
});

或者在 Bootstrap 时通过 app.enableCors() 直接启用,也可以使用 @nestjs/platform-express 等适配器更精细地配置。

4. 理解 CORS 预检请求(Preflight Request)

当请求不是简单请求(例如使用了 PUTDELETE 方法,或自定义了 Authorization 头)时,浏览器会先发送一个 OPTIONS 请求到服务器,询问服务器是否允许这个真实请求。这个 OPTIONS 请求就是预检请求

大多数 CORS 中间件已经自动处理了 OPTIONS 请求,你不需要额外编写逻辑。但如果你手动编写跨域逻辑,一定要确保服务端正确响应 OPTIONS,并设置 Access-Control-Max-Age 头以缓存结果,减少额外请求。


14.5.2 接口限流:保护服务器资源与业务安全

限流(Rate Limiting)是防止接口被滥用、保护后端服务稳定性的第一道防线。无论是防止暴力破解登录接口,还是限制爬虫抓取数据,限流都可以有效地在请求量超出正常范围时进行阻断或降级。

1. 基础限流算法与方案选型

常用的限流算法包括:

  • 固定窗口:在一个固定时间窗口(如 1 分钟)内只允许 N 次请求。实现简单,但存在临界突发问题(窗口切换瞬间可能放过两倍的请求量)。
  • 滑动窗口:时间窗口随着当前时间移动,更平滑地控制速率。Redis 的 ZSET 可以很好地实现。
  • 令牌桶:以恒定速率生成令牌,请求必须拿到令牌才能被处理,允许一定程度的突发。
  • 漏桶:以恒定速率处理请求,强制平滑流量。

对于大部分 Web API 场景,基于 IP 的固定窗口限流已经足够实用,配合 Redis 可以轻松做到分布式限流。

2. 单机限流:express-rate-limit

在 Express 中,express-rate-limit 是最简单易用的限流中间件。

npm install express-rate-limit
const rateLimit = require('express-rate-limit');

const limiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15分钟窗口
  max: 100,                 // 每个 IP 最多 100 次请求
  message: 'Too many requests from this IP, please try again later.',
  standardHeaders: true,    // 返回 RateLimit 头
  legacyHeaders: false,
});

// 对整个应用启用
app.use(limiter);

// 或者只对特定路由启用
app.use('/api/login', limiter);

Koa 可以使用 koa2-ratelimit,Fastify 有 @fastify/rate-limit,它们的配置项大致类似。

3. 分布式限流:结合 Redis 实现

当应用运行在多台服务器上时,基于内存的限流计数器无法跨进程共享,需要使用外部存储(通常是 Redis)。rate-limiter-flexible 是一个非常强大的库,支持内存、Redis、MongoDB 等多种后端,并且可以轻松集成到 Express、Koa 等框架中。

npm install rate-limiter-flexible ioredis
const { RateLimiterRedis } = require('rate-limiter-flexible');
const Redis = require('ioredis');

const redisClient = new Redis();

const rateLimiter = new RateLimiterRedis({
  storeClient: redisClient,
  keyPrefix: 'middleware',
  points: 10,        // 最大请求数
  duration: 1,       // 每 1 秒
});

// Express 中间件
app.use((req, res, next) => {
  rateLimiter.consume(req.ip)
    .then(() => {
      next();
    })
    .catch(() => {
      res.status(429).send('Too Many Requests');
    });
});

使用 Redis 时,计数器的过期时间等于 duration,不会永久占用内存。你也可以使用 windowMs 风格的窗口参数。

4. 限流策略的适用场景与调优

  • 登录接口:严格限制尝试次数,例如同一 IP 每分钟最多尝试 5 次,超过后返回 429,并要求等待或输入验证码。
  • 短信/邮件发送接口:对同一手机号/邮箱在 24 小时内限制发送次数,防止被恶意消耗费用。
  • 读取接口:可以适当宽松,但也要做粗糙限制,防止单个 IP 暴力爬取整个数据库。
  • 自定义 Key:不要只依赖 IP,对已认证的用户可以使用用户 ID 作为 Key(req.user.id),这样就算 IP 共享或变化仍能精确控制。

限流配置需要结合监控数据持续调整,过严会影响正常用户,过松则起不到防护效果。


14.5.3 防刷防爬:超越限流的深度防护

仅仅依靠频率限制,有时候并不能完全阻止恶意的自动刷量行为,因为攻击者可以:

  • 使用大量代理 IP 分散请求。
  • 模仿真实用户的请求头和行为。
  • 针对不需要登录的公开接口进行爬取。

因此,一个稳固的防刷防爬体系需要多层防御,从网络层、应用层到业务层共同协作。

1. 验证码:有效区分人与机器

当请求频率超过正常阈值,或者检测到可疑行为时,可以触发验证码挑战。常见的验证码方案包括:

  • Google reCAPTCHA(v2 或 v3)
  • 国内:腾讯验证码、极验(GeeTest)
  • 自主简单验证码(不推荐,易被破解)

使用 reCAPTCHA v3 的流程:

  1. 前端在表单提交时调用 grecaptcha.execute() 获取一个 token。
  2. 将 token 连同表单数据发给后端。
  3. 后端将 token 和 secret 发往 Google 验证接口 https://www.google.com/recaptcha/api/siteverify,根据返回的 score 判断是否是人类操作。
// 后端验证 reCAPTCHA token 示例
const axios = require('axios');

async function verifyRecaptcha(token) {
  const secret = process.env.RECAPTCHA_SECRET;
  const { data } = await axios.post(
    `https://www.google.com/recaptcha/api/siteverify?secret=${secret}&response=${token}`
  );
  return data.success && data.score > 0.5; // 阈值可调整
}

对于关键操作(注册、下单、修改密码),强制要求通过验证码可以有效阻挡大多数脚本和低端爬虫。

2. 请求签名与防重放

如果你们的 API 是暴露给可信客户端(如自己公司的 App 或后端之间的调用),可以通过请求签名来验证请求的合法性和完整性,防止参数篡改和重放攻击。

常见的签名方案:

  • 客户端使用预设的 appKeyappSecret
  • 对请求参数(或请求体)加上时间戳和随机数,然后使用 HMAC-SHA256 生成签名。
  • 将签名和 appKeytimestampnonce 一并提交。
  • 后端验证:
  1. 检查时间戳是否在允许的时间偏差内(如 5 分钟内),防止重放过期请求。
  2. 检查 nonce(随机数)是否已经使用过(可存入 Redis 并设置过期时间)。
  3. 使用相同的 appSecret 和参数重新计算签名,比对是否一致。
const crypto = require('crypto');

function generateSignature(params, secret) {
  const sortedKeys = Object.keys(params).sort();
  const strToSign = sortedKeys.map(k => `${k}=${params[k]}`).join('&');
  return crypto.createHmac('sha256', secret).update(strToSign).digest('hex');
}

// 后端校验中间件示例
async function verifySignature(req, res, next) {
  const { appKey, timestamp, nonce, sign } = req.body;
  const secret = await getAppSecret(appKey); // 从数据库获取 secret

  if (Math.abs(Date.now() - timestamp) > 5 * 60 * 1000) {
    return res.status(403).send('Timestamp expired');
  }
  if (await isNonceUsed(nonce)) { // 检查 Redis
    return res.status(403).send('Nonce already used');
  }

  // 用相同的参数重新计算签名,剔除 sign 字段
  const params = { ...req.body };
  delete params.sign;
  const expected = generateSignature(params, secret);
  if (sign !== expected) {
    return res.status(403).send('Invalid signature');
  }

  await saveNonce(nonce, timestamp); // 存入 Redis 并设置过期
  next();
}

这种方法对纯前端 web 页面不适用(因为无法安全存储 appSecret),但对移动 APP、内部服务调用、开放平台 API 是标准的防护手段。

3. 业务层面的防刷策略

有些攻击可以直接通过业务逻辑进行防御:

  • 注册接口:必须通过邮箱/短信验证码激活账号,且同一手机号 24 小时内只能发送有限次数验证码。
  • 敏感操作:如支付、修改密码,必须使用二次验证(短信验证、TOTP)。
  • 设备指纹:收集客户端的屏幕分辨率、浏览器特征、安装字体等信息生成指纹,识别可疑的批量注册账号。
  • 风控引擎:对于大型系统,可以接入专业的风控系统(如阿里云风险识别、美团风控等),但大多数中小项目只需用好验证码和限流即可。

4. 简单的 Referer 和 User-Agent 检查

作为补充手段,可以检查请求的 RefererUser-Agent 头,过滤无头浏览器或简单爬虫:

app.use((req, res, next) => {
  const ua = req.get('User-Agent');
  if (!ua || /headless/i.test(ua)) {
    return res.status(403).send('Forbidden');
  }
  next();
});

这种检查很容易被伪造,因此只能作为辅助手段,不能作为主要防线。


14.5.4 组合策略:实际落地

一个实际的 Node.js 项目通常会将以上方案组合起来:

  1. 全局 CORS:使用 cors 中间件配置好允许的源和方法。
  2. 通用限流:对外网暴露的所有接口启用基于 IP 的每分钟 100 次限流(Redis 模式)。
  3. 特殊接口强化:登录接口设置每分钟 5 次限制,短信接口同一手机号每天 5 次。
  4. 验证码触发:当同一 IP 或账号连续错误超过 3 次后,要求输入图形验证码或滑块验证。
  5. 签名校验:对 APP 客户端提供签名认证,拒绝未签名请求。
  6. 监控告警:记录 429 过多或可疑签名错误,触发告警通知。

这些组件都可以用中间件的方式叠加,不会耦合业务代码。例如:

app.use(cors(corsOptions));
app.use(rateLimiterMiddleware);            // 全局限流
app.use('/api/auth', authLimiter);         // 登录接口更严格的限流
app.use('/api/private', signatureCheck);   // 需要签名的接口

如果使用 NestJS,可以封装为装饰器或 Guard,同样能够清晰地分层。

防刷防爬是一个不断升级的对抗过程,没有一劳永逸的方案。重要的是根据业务被攻击的成本,动态调整防护措施,并用日志和监控持续观察效果。