@fastify/rate-limit
一个低开销的路由限流器。
安装
npm i @fastify/rate-limit
兼容性
| 插件版本 | Fastify 版本 |
|---|---|
>=10.x | ^5.x |
>=7.x <10.x | ^4.x |
>=3.x <7.x | ^3.x |
>=2.x <7.x | ^2.x |
^1.x | ^1.x |
请注意,如果某个 Fastify 版本已停止支持,则上表中对应的插件版本也将停止支持。 详见 Fastify 的 LTS 政策。
用法
注册该插件,并在需要时传入一些自定义选项。
该插件将添加一个 onRequest 钩子,用于检查客户端(基于其 IP 地址)在给定的 timeWindow 内是否发出了过多的请求。
import Fastify from 'fastify'
const fastify = Fastify()
await fastify.register(import('@fastify/rate-limit'), {
max: 100,
timeWindow: '1 minute'
})
fastify.get('/', (request, reply) => {
reply.send({ hello: 'world' })
})
fastify.listen({ port: 3000 }, err => {
if (err) throw err
console.log('Server listening at http://localhost:3000')
})
如果客户端达到了允许的最大请求数,将向用户发送一个状态码设置为 429 的错误:
{
statusCode: 429,
error: 'Too Many Requests',
message: 'Rate limit exceeded, retry in 1 minute'
}
你可以通过为 errorResponseBuilder 提供回调函数或设置 自定义错误处理器 来更改响应:
fastify.setErrorHandler(function (error, request, reply) {
if (error.statusCode === 429) {
reply.code(429)
error.message = 'You hit the rate limit! Slow down please!'
}
reply.send(error)
})
响应将包含一些额外的请求头:
| 请求头 | 描述 |
|---|---|
x-ratelimit-limit | 客户端可以发起多少请求 |
x-ratelimit-remaining | 在时间窗口内客户端剩余可发起的请求数 |
x-ratelimit-reset | 速率限制重置前必须经过的秒数 |
retry-after | 如果已达到最大值,客户端在可以发起新请求前必须等待的秒数 |
通过 404 防止 URL 猜测
如果您的 404 错误处理未进行速率限制,攻击者可能会搜索有效的 URL。 要对 404 响应进行速率限制,您可以使用自定义处理器:
const fastify = Fastify()
await fastify.register(rateLimit, { global: true, max: 2, timeWindow: 1000 })
fastify.setNotFoundHandler({
preHandler: fastify.rateLimit()
}, function (request, reply) {
reply.code(404).send({ hello: 'world' })
})
请注意,您可以像针对特定路由那样自定义 preHandler 的行为:
const fastify = Fastify()
await fastify.register(rateLimit, { global: true, max: 2, timeWindow: 1000 })
fastify.setNotFoundHandler({
preHandler: fastify.rateLimit({
max: 4,
timeWindow: 500
})
}, function (request, reply) {
reply.code(404).send({ hello: 'world' })
})
选项
在插件注册期间,您可以传递以下选项:
await fastify.register(import('@fastify/rate-limit'), {
global : false, // default true
max: 3, // default 1000
ban: 2, // default -1
timeWindow: 5000, // default 1000 * 60
hook: 'preHandler', // default 'onRequest'
cache: 10000, // default 5000
allowList: ['127.0.0.1'], // default []
redis: new Redis({ host: '127.0.0.1' }), // default null
nameSpace: 'teste-ratelimit-', // default is 'fastify-rate-limit-'
continueExceeding: true, // default false
skipOnError: true, // default false
keyGenerator: function (request) { /* ... */ }, // default (request) => normalizeIP(request.ip, ipv6Subnet)
ipv6Subnet: 64, // default 64
errorResponseBuilder: function (request, context) { /* ... */},
enableDraftSpec: true, // default false. Uses IEFT draft header standard
addHeadersOnExceeding: { // default show all the response headers when rate limit is not reached
'x-ratelimit-limit': true,
'x-ratelimit-remaining': true,
'x-ratelimit-reset': true
},
addHeaders: { // default show all the response headers when rate limit is reached
'x-ratelimit-limit': true,
'x-ratelimit-remaining': true,
'x-ratelimit-reset': true,
'retry-after': true
}
})
global: 指示插件是否应对封装范围内的所有路由应用速率限制。max: 单个客户端在 timeWindow 内可执行的最大请求数。它可以是一个具有签名async (request, key) => {}的异步函数,其中request是 Fastify 请求对象,key是由keyGenerator生成的值。该函数必须返回一个数字。ban: 在返回 403 响应之前,向客户端返回 429 响应的最大次数。当超过封禁限制时,传递给errorResponseBuilder的 context 参数其ban属性将被设置为true。注意:0也可以直接传递,以便在客户端超过max限制时直接返回 403 响应。timeWindow:时间窗口的持续时间。它可以表示为毫秒,字符串(采用ms格式),或具有签名async (request, key) => {}的异步函数,其中request是 Fastify 请求对象,key是由keyGenerator生成的值。该函数必须返回一个数字。cache: 此插件内部使用 LRU 缓存来处理客户端,您可以使用此选项更改缓存的大小allowList: 用于从速率限制中排除的 IP 地址字符串数组。它可以是一个具有签名(request, key) => {}的同步或异步函数,其中request是 Fastify 请求对象,key是由keyGenerator生成的值。如果函数返回一个真值,该请求将被排除在速率限制之外。redis: 默认情况下,此插件使用内存存储,但如果应用程序运行在多台服务器上,则需要外部存储。此插件要求使用ioredis.
注意: ioredis 实例的 默认设置 对于速率限制并非最佳。我们建议按照example中所示自定义connectTimeout和maxRetriesPerRequest参数。nameSpace: 选择在 redis 中使用的哪个前缀,默认为 'fastify-rate-limit-'continueExceeding: 当用户仍受限制时向服务器发送请求,则更新用户限制。这将优先于exponentialBackoffstore: 用于跟踪请求和速率的自定义存储,允许您使用自己的存储机制(使用 RDBMS、MongoDB 等),并进一步自定义用于计算速率限制的逻辑。下面提供了一个简单示例,使用 Knex.js 的更详细示例可在example/文件夹中找到skipOnError: 如果true,它将跳过由存储生成的错误(例如 redis 不可达)。keyGenerator: 一个同步或异步函数,用于为每个传入请求生成唯一标识符。默认为(request) => normalizeIP(request.ip, ipv6Subnet),IP 由 fastify 使用request.connection.remoteAddress或request.headers['x-forwarded-for']解析,如果启用了 trustProxy 选项。默认情况下会规范化 IP 字符串,将 IPv4 映射的 IPv6 地址映射为 IPv4,并将 IPv6 地址掩码为配置的ipv6Subnet。如果您想覆盖此行为,请使用它ipv6Subnet: 默认keyGenerator使用的 IPv6 前缀长度。默认为64。当您的客户端通常接收到不同的 IPv6 分配大小时,将其设置为其他前缀长度。normalizeIP(ip, ipv6Subnet)辅助函数也已导出,供自定义密钥生成器使用。groupId: 用于将多个路由分组在一起并引入独立的每组速率限制的字符串。这将叠加在keyGenerator的结果之上。errorResponseBuilder: 用于生成自定义响应对象的函数。默认为(request, context) => ({statusCode: 429, error: 'Too Many Requests', message: ``Rate limit exceeded, retry in ${context.after}``})addHeadersOnExceeding: 定义在未达到限制时应添加到响应中的哪些标头。默认情况下,所有标头都会显示addHeaders: 定义在达到限制时应添加到响应中的哪些标头。默认情况下,所有标头都会显示enableDraftSpec: 如果true,它将更改 HTTP 速率限制标头以符合 IEFT 草案文档。更多信息请参阅 draft-ietf-httpapi-ratelimit-headers.md。onExceeding: 在请求限制达到之前执行的回调。onExceeded: 在请求限制达到之后执行的回调。onBanReach: 在达到封禁限制时执行的回调。exponentialBackoff: 当用户在仍受限制时向服务器发送请求时,指数级更新用户限制。
keyGenerator 示例用法:
await fastify.register(import('@fastify/rate-limit'), {
/* ... */
keyGenerator: function (request) {
return request.headers['x-real-ip'] // nginx
|| request.headers['x-client-ip'] // apache
|| request.headers['x-forwarded-for'] // use this only if you trust the header
|| request.session.username // you can limit based on any session value
|| request.ip // fallback to default
}
})
变量 max 示例用法:
// In the same timeWindow, the max value can change based on request and/or key like this
fastify.register(rateLimit, {
/* ... */
keyGenerator (request) { return request.headers['service-key'] },
max: async (request, key) => { return key === 'pro' ? 3 : 2 },
timeWindow: 1000
})
errorResponseBuilder 示例用法:
await fastify.register(import('@fastify/rate-limit'), {
/* ... */
errorResponseBuilder: function (request, context) {
return {
statusCode: 429,
error: 'Too Many Requests',
message: `I only allow ${context.max} requests per ${context.after} to this Website. Try again soon.`,
date: Date.now(),
expiresIn: context.ttl // milliseconds
}
}
})
动态 allowList 示例用法:
await fastify.register(import('@fastify/rate-limit'), {
/* ... */
allowList: function (request, key) {
return request.headers['x-app-client-id'] === 'internal-usage'
}
})
自定义 hook 示例用法(认证后):
await fastify.register(import('@fastify/rate-limit'), {
hook: 'preHandler',
keyGenerator: function (request) {
return request.userId || request.ip
}
})
fastify.decorateRequest('userId', '')
fastify.addHook('preHandler', async function (request) {
const { userId } = request.query
if (userId) {
request.userId = userId
}
})
自定义 store 示例用法:
注意:timeWindow 始终会以毫秒为单位的数值形式传递给 store 的构造函数。
function CustomStore (options) {
this.options = options
this.current = 0
}
CustomStore.prototype.incr = function (key, cb, timeWindow, max) {
this.current++
cb(null, { current: this.current, ttl: timeWindow - (this.current * 1000) })
}
CustomStore.prototype.child = function (routeOptions) {
// We create a merged copy of the current parent parameters with the specific
// route parameters and pass them into the child store.
const childParams = Object.assign(this.options, routeOptions)
const store = new CustomStore(childParams)
// Here is where you may want to do some custom calls on the store with the information
// in routeOptions first...
// store.setSubKey(routeOptions.method + routeOptions.url)
return store
}
await fastify.register(import('@fastify/rate-limit'), {
/* ... */
store: CustomStore
})
传递给 store 的 child 方法的 routeOptions 对象将包含上述插件注册中详述的相同选项,以及路由上提供的任何特定覆盖。此外,提供了以下参数:
routeInfo:路由的配置,包括method、url、path以及完整的路由config
自定义 onExceeding 用法示例:
await fastify.register(import('@fastify/rate-limit'), {
/* */
onExceeding: function (req, key) {
console.log('callback on exceeding ... executed before response to client')
}
})
自定义 onExceeded 用法示例:
await fastify.register(import('@fastify/rate-limit'), {
/* */
onExceeded: function (req, key) {
console.log('callback on exceeded ... executed before response to client')
}
})
自定义 onBanReach 用法示例:
await fastify.register(import('@fastify/rate-limit'), {
/* */
ban: 10,
onBanReach: function (req, key) {
console.log('callback on exceeded ban limit')
}
})
端点本身的选项
速率限制也可以在路由级别进行配置,独立地应用配置。
例如,如果配置了 allowList:
- 在插件注册时,将影响封装范围内的所有端点
- 在路由声明时,仅影响目标端点
全局允许列表在与 fastify.register(...) 注册时进行配置。
端点允许列表通过 { config : { rateLimit : { allowList : [] } } } 对象直接在端点上设置。
ACL 检查基于 keyGenerator 中键的值执行。
在此示例中,我们检查的是 IP 地址,但也可以是特定用户标识符(如 JWT 或令牌)的允许列表:
import Fastify from 'fastify'
const fastify = Fastify()
await fastify.register(import('@fastify/rate-limit'),
{
global : false, // don't apply these settings to all the routes of the context
max: 3000, // default global max rate limit
allowList: ['192.168.0.10'], // global allowlist access.
redis: redis, // custom connection to redis
})
// add a limited route with this configuration plus the global one
fastify.get('/', {
config: {
rateLimit: {
max: 3,
timeWindow: '1 minute'
}
}
}, (request, reply) => {
reply.send({ hello: 'from ... root' })
})
// add a limited route with this configuration plus the global one
fastify.get('/private', {
config: {
rateLimit: {
max: 3,
timeWindow: '1 minute'
}
}
}, (request, reply) => {
reply.send({ hello: 'from ... private' })
})
// this route doesn't have any rate limit
fastify.get('/public', (request, reply) => {
reply.send({ hello: 'from ... public' })
})
// add a limited route with this configuration plus the global one
fastify.get('/public/sub-rated-1', {
config: {
rateLimit: {
timeWindow: '1 minute',
allowList: ['127.0.0.1'],
onExceeding: function (request, key) {
console.log('callback on exceeding ... executed before response to client')
},
onExceeded: function (request, key) {
console.log('callback on exceeded ... to black ip in security group for example, request is give as argument')
}
}
}
}, (request, reply) => {
reply.send({ hello: 'from sub-rated-1 ... using default max value ... ' })
})
// group routes and add a rate limit
fastify.get('/otp/send', {
config: {
rateLimit: {
max: 3,
timeWindow: '1 minute',
groupId:"OTP"
}
}
}, (request, reply) => {
reply.send({ hello: 'from ... grouped rate limit' })
})
fastify.get('/otp/resend', {
config: {
rateLimit: {
max: 3,
timeWindow: '1 minute',
groupId:"OTP"
}
}
}, (request, reply) => {
reply.send({ hello: 'from ... grouped rate limit' })
})
在创建路由时,你可以覆盖插件注册的相同设置,以及以下附加选项:
onExceeding: 每次对受速率限制的路由发起请求时执行的回调onExceeded: 当用户达到最大尝试次数时执行的回调。可用于将客户端加入黑名单
你可能还希望设置一个全局速率限制器,然后在某些路由上禁用它:
import Fastify from 'fastify'
const fastify = Fastify()
await fastify.register(import('@fastify/rate-limit'), {
max: 100,
timeWindow: '1 minute'
})
// add a limited route with global config
fastify.get('/', (request, reply) => {
reply.send({ hello: 'from ... rate limited root' })
})
// this route doesn't have any rate limit
fastify.get('/public', {
config: {
rateLimit: false
}
}, (request, reply) => {
reply.send({ hello: 'from ... public' })
})
// add a limited route with global config and different max
fastify.get('/private', {
config: {
rateLimit: {
max: 9
}
}
}, (request, reply) => {
reply.send({ hello: 'from ... private and more limited' })
})
手动速率限制
可以使用 fastify.createRateLimit() 创建自定义限制器函数,这在需要与 GraphQL 或 tRPC 等技术集成时非常有用。该函数使用在插件注册期间设置的全球 options,但您可以覆盖诸如 store、skipOnError、max、timeWindow、
allowList、keyGenerator、ipv6Subnet 和 ban 等选项。
用法示例:
import Fastify from 'fastify'
const fastify = Fastify()
// register with global options
await fastify.register(import('@fastify/rate-limit'), {
global : false,
max: 100,
timeWindow: '1 minute'
})
// checkRateLimit will use the global options provided above when called
const checkRateLimit = fastify.createRateLimit();
fastify.get("/", async (request, reply) => {
// manually check the rate limit (using global options)
const limit = await checkRateLimit(request);
if(!limit.isAllowed && limit.isExceeded) {
return reply.code(429).send("Limit exceeded");
}
return reply.send("Hello world");
});
// override global max option
const checkCustomRateLimit = fastify.createRateLimit({ max: 100 });
fastify.get("/custom", async (request, reply) => {
// manually check the rate limit (using global options and overridden max option)
const limit = await checkCustomRateLimit(request);
// manually handle limit exceedance
if(!limit.isAllowed && limit.isExceeded) {
return reply.code(429).send("Limit exceeded");
}
return reply.send("Hello world");
});
使用 fastify.createRateLimit() 创建的自定义限制器函数仅需要一个 FastifyRequest 作为第一个参数:
const checkRateLimit = fastify.createRateLimit();
const limit = await checkRateLimit(request);
返回的 limit 是一个对象,包含针对传递给 checkRateLimit 的 request 的以下属性。
isAllowed: 如果为true,则请求根据配置的allowList被排除在速率限制之外。key: 由keyGenerator函数返回的生成的键。
如果 isAllowed 为 false,该对象还包含以下附加属性:
max: 作为数字表示的配置的max选项。如果作为全局选项或传递给fastify.createRateLimit()提供了max函数,此属性将对应于给定request的函数返回类型。timeWindow: 以毫秒为单位的配置的timeWindow选项。如果向timeWindow提供了函数,类似于上述的max属性,此属性将等于函数的返回类型。remaining: 在超出限制之前剩余的请求数量。ttl: 限制重置前的剩余时间,以毫秒为单位。ttlInSeconds: 以秒为单位的ttl。isExceeded: 如果超出限制,则为true。isBanned: 如果根据ban选项请求被禁止,则为true。
限制器函数接受一个可选的第二个参数 { increment?: boolean }。increment 标志默认为 true,因此省略该参数会保持原始行为(请求被消耗)。当 increment 为 false 时,当前速率限制状态将在不消耗请求的情况下返回。这在仅应在某些结果(例如登录失败尝试)上执行限制,同时仍需在处理前检查状态时非常有用。
const checkRateLimit = fastify.createRateLimit({ max: 5, timeWindow: '1 minute' });
fastify.post('/login', async (request, reply) => {
// Peek at the current status without consuming a request
const status = await checkRateLimit(request, { increment: false });
if (status.isExceeded) {
return reply.code(429).send({ error: 'Too many attempts' });
}
const success = await tryLogin(request.body);
if (!success) {
// Only consume a request when the login fails
await checkRateLimit(request);
return reply.code(401).send({ error: 'Invalid credentials' });
}
return { ok: true };
});
使用 { increment: false } 时需要注意以下几点:
- 它是一个非变更快照。 一次 peek 操作永远不会递增计数器、重置窗口,或推进真实请求所触发的
ban/continueExceeding/exponentialBackoff副作用。因此,返回的isExceeded/isBanned反映的是当前计数器,但仅凭一次 peek 操作本身,不会升级封禁或延长退避窗口。 ttl反映存储层的当前窗口。 对于 Redis 存储,ttl是原始的服务器PTTL(与incr报告的值相同),因此当continueExceeding/exponentialBackoff将其延长时,它可能超过配置的timeWindow。- 自定义存储必须实现
read。 该标志依赖于一个非变更的read(ip, cb, timeWindow, max)方法,其签名与incr一致。内置的本地和 Redis 存储提供了该方法;未提供该方法的自定义存储在以{ increment: false }调用时会抛出明确的错误。
自定义存储示例
这些示例展示了 store 功能的大致情况,您可以从中获取灵感并根据需要进行调整:
IETF 草案规范头部
如果 enableDraftSpec 为 true,响应将包含以下头部:
| 头部 | 描述 |
|---|---|
ratelimit-limit | 客户端可以发起的请求数量 |
ratelimit-remaining | 客户端在时间窗口内剩余可发起的请求数量 |
ratelimit-reset | 速率限制重置前必须经过的秒数 |
retry-after | 包含与 ratelimit-reset 相同的时间值 |
贡献
要在本地运行测试,你需要一个 Redis 实例,可以使用以下命令启动:
npm run redis
许可证
根据 MIT 授权。