Files
overleaf-cep/services/web/app/src/infrastructure/RateLimiter.mjs
Andrew Rumble 07c827e9fd Merge pull request #29928 from overleaf/ar-last-infrastructure-conversions
[web] last infrastructure conversions

GitOrigin-RevId: ad1aff9b7df0610ed0303157d9e2c8032f32c02b
2025-11-28 09:05:56 +00:00

145 lines
4.0 KiB
JavaScript

import Settings from '@overleaf/settings'
import Metrics from '@overleaf/metrics'
import logger from '@overleaf/logger'
import RedisWrapper from './RedisWrapper.mjs'
import RateLimiterFlexible from 'rate-limiter-flexible'
import OError from '@overleaf/o-error'
const rclient = RedisWrapper.client('ratelimiter')
/**
* Wrapper over the RateLimiterRedis class
*/
export class RateLimiter {
#opts
/**
* Create a rate limiter.
*
* @param name {string} The name that identifies this rate limiter. Different
* rate limiters must have different names.
* @param opts {object} Options to pass to RateLimiterRedis
*
* Some useful options:
*
* points - number of points that can be consumed over the given duration
* (default: 4)
* subnetPoints - number of points that can be consumed over the given
* duration accross a sub-network. This should only be used
* ip-based rate limits.
* duration - duration of the fixed window in seconds (default: 1)
* blockDuration - additional seconds to block after all points are consumed
* (default: 0)
*/
constructor(name, opts = {}) {
this.name = name
this.#opts = Object.assign({}, opts)
this._rateLimiter = new RateLimiterFlexible.RateLimiterRedis({
...opts,
keyPrefix: `rate-limit:${name}`,
storeClient: rclient,
})
if (opts.subnetPoints && !Settings.rateLimit?.subnetRateLimiterDisabled) {
this._subnetRateLimiter = new RateLimiterFlexible.RateLimiterRedis({
...opts,
points: opts.subnetPoints,
keyPrefix: `rate-limit:${name}`,
storeClient: rclient,
})
}
}
// Readonly access to the options, useful for aligning rate-limits.
getOptions() {
return Object.assign({}, this.#opts)
}
async consume(key, points = 1, options = { method: 'unknown' }) {
if (Settings.disableRateLimits) {
// Return a fake result in case it's used somewhere
return {
msBeforeNext: 0,
remainingPoints: 100,
consumedPoints: 0,
isFirstInDuration: false,
}
}
await this.consumeForRateLimiter(this._rateLimiter, key, options, points)
if (options.method === 'ip' && this._subnetRateLimiter) {
const subnetKey = this.getSubnetKeyFromIp(key)
await this.consumeForRateLimiter(
this._subnetRateLimiter,
subnetKey,
options,
points,
'ip-subnet'
)
}
}
async consumeForRateLimiter(rateLimiter, key, options, points, method) {
try {
const res = await rateLimiter.consume(key, points, options)
return res
} catch (err) {
if (err instanceof Error) {
throw err
} else {
// Only log the first time we exceed the rate limit for a given key and
// duration. This happens when the previous amount of consumed points
// was below the threshold.
if (err.consumedPoints - points <= rateLimiter.points) {
logger.warn({ path: this.name, key }, 'rate limit exceeded')
}
Metrics.inc('rate-limit-hit', 1, {
path: this.name,
method: method || options.method,
})
throw err
}
}
}
getSubnetKeyFromIp(ip) {
if (!/^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/.test(ip)) {
throw new OError(
'Cannot generate subnet key as the ip address is not of the expected format.',
{ ip }
)
}
return ip.split('.').slice(0, 3).join('.')
}
async delete(key) {
return await this._rateLimiter.delete(key)
}
}
/*
* Shared rate limiters
*/
export const openProjectRateLimiter = new RateLimiter('open-project', {
points: 15,
duration: 60,
})
// Keep in sync with the can-skip-captcha options.
export const overleafLoginRateLimiter = new RateLimiter(
'overleaf-login',
Settings.rateLimit?.login?.ip || {
points: 20,
subnetPoints: 200,
duration: 60,
}
)
export default {
RateLimiter,
openProjectRateLimiter,
overleafLoginRateLimiter,
}