Files
speckle-server/packages/server/modules/core/services/ratelimiter.ts
T

424 lines
13 KiB
TypeScript

import express from 'express'
import {
getRedisUrl,
getIntFromEnv,
getBooleanFromEnv
} from '@/modules/shared/helpers/envHelper'
import {
BurstyRateLimiter,
RateLimiterAbstract,
RateLimiterMemory,
RateLimiterRedis,
RateLimiterRes
} from 'rate-limiter-flexible'
import { TIME } from '@speckle/shared'
import { getIpFromRequest } from '@/modules/shared/utils/ip'
import { RateLimitError } from '@/modules/core/errors/ratelimit'
import { rateLimiterLogger } from '@/logging/logging'
import { createRedisClient } from '@/modules/shared/redis/redis'
import { getRequestPath } from '@/modules/core/helpers/server'
import { getTokenFromRequest } from '@/modules/shared/middleware'
export interface RateLimitResult {
isWithinLimits: boolean
action: RateLimitAction
}
export interface RateLimitSuccess extends RateLimitResult {
isWithinLimits: true
remainingPoints: number
}
export interface RateLimitBreached extends RateLimitResult {
isWithinLimits: false
msBeforeNext: number
}
type BurstyRateLimiterOptions = {
regularOptions: RateLimits
burstOptions: RateLimits
}
export type RateLimits = {
limitCount: number
duration: number
}
type RateLimiterOptions = {
[key: string]: BurstyRateLimiterOptions
}
export type RateLimiterMapping = {
[key in RateLimitAction]: (
source: string
) => Promise<RateLimitSuccess | RateLimitBreached>
}
export type RateLimitAction = keyof typeof LIMITS
export const isRateLimiterEnabled = (): boolean => {
return getBooleanFromEnv('RATELIMITER_ENABLED', true)
}
export const LIMITS = <const>{
ALL_REQUESTS: {
regularOptions: {
limitCount: getIntFromEnv('RATELIMIT_ALL_REQUESTS', '500'),
duration: 1 * TIME.second
},
burstOptions: {
limitCount: getIntFromEnv('RATELIMIT_BURST_ALL_REQUESTS', '2000'),
duration: 1 * TIME.minute
}
},
USER_CREATE: {
regularOptions: {
limitCount: getIntFromEnv('RATELIMIT_USER_CREATE', '6'),
duration: 1 * TIME.hour
},
burstOptions: {
limitCount: getIntFromEnv('RATELIMIT_BURST_USER_CREATE', '1000'),
duration: 1 * TIME.week
}
},
STREAM_CREATE: {
regularOptions: {
limitCount: getIntFromEnv('RATELIMIT_STREAM_CREATE', '1'),
duration: 1 * TIME.second
},
burstOptions: {
limitCount: getIntFromEnv('RATELIMIT_BURST_STREAM_CREATE', '100'),
duration: 1 * TIME.minute
}
},
COMMIT_CREATE: {
regularOptions: {
limitCount: getIntFromEnv('RATELIMIT_COMMIT_CREATE', '1'),
duration: 1 * TIME.second
},
burstOptions: {
limitCount: getIntFromEnv('RATELIMIT_BURST_COMMIT_CREATE', '100'),
duration: 1 * TIME.minute
}
},
GENDO_AI_RENDER_REQUEST: {
regularOptions: {
limitCount: getIntFromEnv('RATELIMIT_GENDO_AI_RENDER_REQUEST', '1'),
duration:
getIntFromEnv('RATELIMIT_GENDO_AI_RENDER_REQUEST_PERIOD_SECONDS', '20') *
TIME.second
},
burstOptions: {
limitCount: getIntFromEnv('RATELIMIT_BURST_GENDO_AI_RENDER_REQUEST', '3'),
duration:
getIntFromEnv('RATELIMIT_BURST_GENDO_AI_RENDER_REQUEST_PERIOD_SECONDS', '60') *
TIME.second
}
},
'POST /api/getobjects/:streamId': {
regularOptions: {
limitCount: getIntFromEnv('RATELIMIT_POST_GETOBJECTS_STREAMID', '3'),
duration: 1 * TIME.second
},
burstOptions: {
limitCount: getIntFromEnv('RATELIMIT_BURST_POST_GETOBJECTS_STREAMID', '200'),
duration: 1 * TIME.minute
}
},
'POST /api/diff/:streamId': {
regularOptions: {
limitCount: getIntFromEnv('RATELIMIT_POST_DIFF_STREAMID', '10'),
duration: 1 * TIME.second
},
burstOptions: {
limitCount: getIntFromEnv('RATELIMIT_BURST_POST_DIFF_STREAMID', '1000'),
duration: 1 * TIME.minute
}
},
'POST /objects/:streamId': {
regularOptions: {
limitCount: getIntFromEnv('RATELIMIT_POST_OBJECTS_STREAMID', '6'),
duration: 1 * TIME.second
},
burstOptions: {
limitCount: getIntFromEnv('RATELIMIT_BURST_POST_OBJECTS_STREAMID', '400'),
duration: 1 * TIME.minute
}
},
'GET /objects/:streamId/:objectId': {
regularOptions: {
limitCount: getIntFromEnv('RATELIMIT_GET_OBJECTS_STREAMID_OBJECTID', '3'),
duration: 1 * TIME.second
},
burstOptions: {
limitCount: getIntFromEnv('RATELIMIT_BURST_GET_OBJECTS_STREAMID_OBJECTID', '200'),
duration: 1 * TIME.minute
}
},
'GET /objects/:streamId/:objectId/single': {
regularOptions: {
limitCount: getIntFromEnv('RATELIMIT_GET_OBJECTS_STREAMID_OBJECTID_SINGLE', '3'),
duration: 1 * TIME.second
},
burstOptions: {
limitCount: getIntFromEnv(
'RATELIMIT_BURST_GET_OBJECTS_STREAMID_OBJECTID_SINGLE',
'200'
),
duration: 1 * TIME.minute
}
},
'POST /graphql': {
regularOptions: {
limitCount: getIntFromEnv('RATELIMIT_POST_GRAPHQL', '50'),
duration: 1 * TIME.second
},
burstOptions: {
limitCount: getIntFromEnv('RATELIMIT_BURST_POST_GRAPHQL', '200'),
duration: 1 * TIME.minute
}
},
'/auth/local/login': {
regularOptions: {
limitCount: getIntFromEnv('RATELIMIT_GET_AUTH', '4'),
duration: 10 * TIME.minute
},
burstOptions: {
limitCount: getIntFromEnv('RATELIMIT_BURST_GET_AUTH', '10'),
duration: 30 * TIME.minute
}
},
'/auth/azure': {
regularOptions: {
limitCount: getIntFromEnv('RATELIMIT_GET_AUTH', '4'),
duration: 10 * TIME.minute
},
burstOptions: {
limitCount: getIntFromEnv('RATELIMIT_BURST_GET_AUTH', '10'),
duration: 30 * TIME.minute
}
},
'/auth/gh': {
regularOptions: {
limitCount: getIntFromEnv('RATELIMIT_GET_AUTH', '4'),
duration: 10 * TIME.minute
},
burstOptions: {
limitCount: getIntFromEnv('RATELIMIT_BURST_GET_AUTH', '10'),
duration: 30 * TIME.minute
}
},
'/auth/goog': {
regularOptions: {
limitCount: getIntFromEnv('RATELIMIT_GET_AUTH', '4'),
duration: 10 * TIME.minute
},
burstOptions: {
limitCount: getIntFromEnv('RATELIMIT_BURST_GET_AUTH', '10'),
duration: 30 * TIME.minute
}
},
'/auth/oidc': {
regularOptions: {
limitCount: getIntFromEnv('RATELIMIT_GET_AUTH', '4'),
duration: 10 * TIME.minute
},
burstOptions: {
limitCount: getIntFromEnv('RATELIMIT_BURST_GET_AUTH', '10'),
duration: 30 * TIME.minute
}
},
'/auth/azure/callback': {
regularOptions: {
limitCount: getIntFromEnv('RATELIMIT_GET_AUTH', '4'),
duration: 10 * TIME.minute
},
burstOptions: {
limitCount: getIntFromEnv('RATELIMIT_BURST_GET_AUTH', '10'),
duration: 30 * TIME.minute
}
},
'/auth/gh/callback': {
regularOptions: {
limitCount: getIntFromEnv('RATELIMIT_GET_AUTH', '4'),
duration: 10 * TIME.minute
},
burstOptions: {
limitCount: getIntFromEnv('RATELIMIT_BURST_GET_AUTH', '10'),
duration: 30 * TIME.minute
}
},
'/auth/goog/callback': {
regularOptions: {
limitCount: getIntFromEnv('RATELIMIT_GET_AUTH', '4'),
duration: 10 * TIME.minute
},
burstOptions: {
limitCount: getIntFromEnv('RATELIMIT_BURST_GET_AUTH', '10'),
duration: 30 * TIME.minute
}
},
'/auth/oidc/callback': {
regularOptions: {
limitCount: getIntFromEnv('RATELIMIT_GET_AUTH', '4'),
duration: 10 * TIME.minute
},
burstOptions: {
limitCount: getIntFromEnv('RATELIMIT_BURST_GET_AUTH', '10'),
duration: 30 * TIME.minute
}
}
}
export const allActions = Object.keys(LIMITS) as RateLimitAction[]
export const sendRateLimitResponse = (
res: express.Response,
rateLimitBreached: RateLimitBreached
): express.Response => {
if (res.headersSent) return res
res.setHeader('Retry-After', rateLimitBreached.msBeforeNext / 1000)
res.removeHeader('X-RateLimit-Remaining')
res.setHeader(
'X-RateLimit-Reset',
new Date(Date.now() + rateLimitBreached.msBeforeNext).toISOString()
)
res.setHeader('X-Speckle-Meditation', 'https://http.cat/429')
return res.status(429).send({
err: 'You are sending too many requests. You have been rate limited. Please try again later.'
})
}
export const getActionForPath = (path: string, verb: string): RateLimitAction => {
const maybeAction = `${verb} ${path}` as RateLimitAction
const maybeActionNoVerb = path as RateLimitAction
if (LIMITS[maybeAction]) return maybeAction
if (LIMITS[maybeActionNoVerb]) return maybeActionNoVerb
return 'ALL_REQUESTS'
}
export const getSourceFromRequest = (req: express.Request): string => {
let source: string | null =
req?.context?.userId ||
getTokenFromRequest(req)?.substring(10) || // token ID
getIpFromRequest(req)
if (!source) source = 'unknown'
return source
}
export const createRateLimiterMiddleware = (
rateLimiterMapping: RateLimiterMapping = RATE_LIMITERS
) => {
return async (
req: express.Request,
res: express.Response,
next: express.NextFunction
) => {
if (!isRateLimiterEnabled()) return next()
const path = getRequestPath(req) || ''
const action = getActionForPath(path, req.method)
const source = getSourceFromRequest(req)
const rateLimitResult = await getRateLimitResult(action, source, rateLimiterMapping)
if (isRateLimitBreached(rateLimitResult)) {
return sendRateLimitResponse(res, rateLimitResult)
} else {
try {
if (res.headersSent) return res
res.setHeader('X-RateLimit-Remaining', rateLimitResult.remainingPoints)
return next()
} catch (err) {
if (!(err instanceof RateLimitError)) throw err
return sendRateLimitResponse(res, err.rateLimitBreached)
}
}
}
}
// we need to take the `BurstyRateLimiter` specific type because
// its not considered as an RateLimiterAbstract in the rate-limiter-flexible package
// This is just a rant comment, but why define the Abstract then if not
// all RateLimiters are implementing it?
export const createConsumer =
(action: RateLimitAction, rateLimiter: RateLimiterAbstract | BurstyRateLimiter) =>
async (source: string): Promise<RateLimitSuccess | RateLimitBreached> => {
try {
const rateLimitRes = await rateLimiter.consume(source)
return {
action,
isWithinLimits: true,
remainingPoints: rateLimitRes.remainingPoints
}
} catch (err) {
if (err instanceof RateLimiterRes)
return { action, isWithinLimits: false, msBeforeNext: err.msBeforeNext }
rateLimiterLogger.error(err, 'Error while consuming rate limiter')
throw err
}
}
const initializeRedisRateLimiters = (
options: RateLimiterOptions = LIMITS
): RateLimiterMapping => {
const redisClient = createRedisClient(getRedisUrl(), {
enableReadyCheck: false,
maxRetriesPerRequest: null
})
const mapping = Object.fromEntries(
allActions.map((action) => {
const limits = options[action]
const burstyLimiter = new BurstyRateLimiter(
new RateLimiterRedis({
storeClient: redisClient,
keyPrefix: action,
points: limits.regularOptions.limitCount,
duration: limits.regularOptions.duration,
inMemoryBlockOnConsumed: limits.regularOptions.limitCount, // stops additional requests going to Redis once the limit is reached
inMemoryBlockDuration: limits.regularOptions.duration,
insuranceLimiter: new RateLimiterMemory({
keyPrefix: action,
points: limits.regularOptions.limitCount,
duration: limits.regularOptions.duration
})
}),
new RateLimiterRedis({
storeClient: redisClient,
keyPrefix: `BURST_${action}`,
points: limits.burstOptions.limitCount,
duration: limits.burstOptions.duration,
inMemoryBlockOnConsumed: limits.burstOptions.limitCount,
inMemoryBlockDuration: limits.burstOptions.duration,
insuranceLimiter: new RateLimiterMemory({
keyPrefix: `BURST_${action}`,
points: limits.burstOptions.limitCount,
duration: limits.burstOptions.duration
})
})
)
return [action, createConsumer(action, burstyLimiter)]
})
)
// i know that all the values are in there, but TS doesn't...
return mapping as RateLimiterMapping
}
export const RATE_LIMITERS = initializeRedisRateLimiters()
export const isRateLimitBreached = (
rateLimitResult: RateLimitResult
): rateLimitResult is RateLimitBreached => !rateLimitResult.isWithinLimits
export async function getRateLimitResult(
action: RateLimitAction,
source: string,
rateLimiterMapping: RateLimiterMapping = RATE_LIMITERS
): Promise<RateLimitSuccess | RateLimitBreached> {
const consumerFunc = rateLimiterMapping[action]
return await consumerFunc(source)
}