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)
当请求不是简单请求(例如使用了 PUT、DELETE 方法,或自定义了 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 的流程:
- 前端在表单提交时调用
grecaptcha.execute()获取一个 token。 - 将 token 连同表单数据发给后端。
- 后端将 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 或后端之间的调用),可以通过请求签名来验证请求的合法性和完整性,防止参数篡改和重放攻击。
常见的签名方案:
- 客户端使用预设的
appKey和appSecret。 - 对请求参数(或请求体)加上时间戳和随机数,然后使用 HMAC-SHA256 生成签名。
- 将签名和
appKey、timestamp、nonce一并提交。 - 后端验证:
- 检查时间戳是否在允许的时间偏差内(如 5 分钟内),防止重放过期请求。
- 检查
nonce(随机数)是否已经使用过(可存入 Redis 并设置过期时间)。 - 使用相同的
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 检查
作为补充手段,可以检查请求的 Referer 或 User-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 项目通常会将以上方案组合起来:
- 全局 CORS:使用
cors中间件配置好允许的源和方法。 - 通用限流:对外网暴露的所有接口启用基于 IP 的每分钟 100 次限流(Redis 模式)。
- 特殊接口强化:登录接口设置每分钟 5 次限制,短信接口同一手机号每天 5 次。
- 验证码触发:当同一 IP 或账号连续错误超过 3 次后,要求输入图形验证码或滑块验证。
- 签名校验:对 APP 客户端提供签名认证,拒绝未签名请求。
- 监控告警:记录 429 过多或可疑签名错误,触发告警通知。
这些组件都可以用中间件的方式叠加,不会耦合业务代码。例如:
app.use(cors(corsOptions));
app.use(rateLimiterMiddleware); // 全局限流
app.use('/api/auth', authLimiter); // 登录接口更严格的限流
app.use('/api/private', signatureCheck); // 需要签名的接口
如果使用 NestJS,可以封装为装饰器或 Guard,同样能够清晰地分层。
防刷防爬是一个不断升级的对抗过程,没有一劳永逸的方案。重要的是根据业务被攻击的成本,动态调整防护措施,并用日志和监控持续观察效果。