ITADN
fastify/fastify-rate-limit
fastify/fastify-rate-limit · 文件 下载 ZIP
文件最后提交记录最后更新时间
README.md
以下内容由 AI 翻译,如有问题请点此提交 issue 反馈

@fastify/rate-limit

CI NPM version neostandard javascript style

一个低开销的路由限流器。

安装

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 中所示自定义 connectTimeoutmaxRetriesPerRequest 参数。
  • nameSpace: 选择在 redis 中使用的哪个前缀,默认为 'fastify-rate-limit-'
  • continueExceeding: 当用户仍受限制时向服务器发送请求,则更新用户限制。这将优先于 exponentialBackoff
  • store: 用于跟踪请求和速率的自定义存储,允许您使用自己的存储机制(使用 RDBMS、MongoDB 等),并进一步自定义用于计算速率限制的逻辑。下面提供了一个简单示例,使用 Knex.js 的更详细示例可在 example/ 文件夹中找到
  • skipOnError: 如果 true,它将跳过由存储生成的错误(例如 redis 不可达)。
  • keyGenerator: 一个同步或异步函数,用于为每个传入请求生成唯一标识符。默认为 (request) => normalizeIP(request.ip, ipv6Subnet),IP 由 fastify 使用 request.connection.remoteAddressrequest.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:路由的配置,包括 methodurlpath 以及完整的路由 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() 创建自定义限制器函数,这在需要与 GraphQLtRPC 等技术集成时非常有用。该函数使用在插件注册期间设置的全球 options,但您可以覆盖诸如 storeskipOnErrormaxtimeWindowallowListkeyGeneratoripv6Subnetban 等选项。

用法示例:

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 是一个对象,包含针对传递给 checkRateLimitrequest 的以下属性。

  • isAllowed: 如果为 true,则请求根据配置的 allowList 被排除在速率限制之外。
  • key: 由 keyGenerator 函数返回的生成的键。

如果 isAllowedfalse,该对象还包含以下附加属性:

  • max: 作为数字表示的配置的 max 选项。如果作为全局选项或传递给 fastify.createRateLimit() 提供了 max 函数,此属性将对应于给定 request 的函数返回类型。
  • timeWindow: 以毫秒为单位的配置的 timeWindow 选项。如果向 timeWindow 提供了函数,类似于上述的 max 属性,此属性将等于函数的返回类型。
  • remaining: 在超出限制之前剩余的请求数量。
  • ttl: 限制重置前的剩余时间,以毫秒为单位。
  • ttlInSeconds: 以秒为单位的 ttl
  • isExceeded: 如果超出限制,则为 true
  • isBanned: 如果根据 ban 选项请求被禁止,则为 true

限制器函数接受一个可选的第二个参数 { increment?: boolean }increment 标志默认为 true,因此省略该参数会保持原始行为(请求被消耗)。当 incrementfalse 时,当前速率限制状态将在不消耗请求的情况下返回。这在仅应在某些结果(例如登录失败尝试)上执行限制,同时仍需在处理前检查状态时非常有用。

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 草案规范头部

如果 enableDraftSpectrue,响应将包含以下头部:

头部描述
ratelimit-limit客户端可以发起的请求数量
ratelimit-remaining客户端在时间窗口内剩余可发起的请求数量
ratelimit-reset速率限制重置前必须经过的秒数
retry-after包含与 ratelimit-reset 相同的时间值

贡献

要在本地运行测试,你需要一个 Redis 实例,可以使用以下命令启动:

npm run redis

许可证

根据 MIT 授权。