diff --git a/.docker/development/docker-compose.yml b/.docker/development/docker-compose.yml index c458aa9..e094d89 100644 --- a/.docker/development/docker-compose.yml +++ b/.docker/development/docker-compose.yml @@ -12,6 +12,8 @@ services: POSTGRES_PASSWORD: postgres volumes: - 'postgres_data:/var/lib/postgresql/data' + redis: + image: redis # custom services backend: diff --git a/src/config/fragments/docker.ts b/src/config/fragments/docker.ts index 3c57696..43ee4ae 100644 --- a/src/config/fragments/docker.ts +++ b/src/config/fragments/docker.ts @@ -4,4 +4,8 @@ export const dockerFragment: FragmentSchema = { postgres: { connection: 'postgres://postgres:postgres@postgres:5432/postgres', }, + ratelimits: { + enabled: true, + redisUrl: 'redis://redis:6379', + }, }; diff --git a/src/config/schema.ts b/src/config/schema.ts index e6ec194..b4ad626 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -16,6 +16,9 @@ export const configSchema = z.object({ // should it trust reverse proxy headers? (for ip gathering) trustProxy: z.coerce.boolean().default(false), + // should it trust cloudflare headers? (for ip gathering, cloudflare has priority) + trustCloudflare: z.coerce.boolean().default(false), + // prefix for where the instance is run on. for example set it to /backend if you're hosting it on example.com/backend // if this is set, do not apply url rewriting before proxing basePath: z.string().default('/'), diff --git a/src/modules/ratelimits/limiter.ts b/src/modules/ratelimits/limiter.ts index 628ce59..45ca519 100644 --- a/src/modules/ratelimits/limiter.ts +++ b/src/modules/ratelimits/limiter.ts @@ -2,6 +2,7 @@ import Redis from 'ioredis'; import RateLimiter from 'async-ratelimiter'; import ms from 'ms'; import { StatusError } from '@/services/error'; +import { IpReq, getIp } from '@/services/ip'; export interface LimiterOptions { redis: Redis; @@ -26,8 +27,8 @@ export class Limiter { this.redis = ops.redis; } - async bump(req: { ip: string }, ops: BucketOptions) { - const ip = req.ip; + async bump(req: IpReq, ops: BucketOptions) { + const ip = getIp(req); if (!this.buckets[ops.id]) { this.buckets[ops.id] = { limiter: new RateLimiter({ @@ -54,7 +55,7 @@ export class Limiter { }; } - async assertAndBump(req: { ip: string }, ops: BucketOptions) { + async assertAndBump(req: IpReq, ops: BucketOptions) { const { hasBeenLimited } = await this.bump(req, ops); if (hasBeenLimited) { throw new StatusError('Ratelimited', 429); diff --git a/src/services/ip.ts b/src/services/ip.ts new file mode 100644 index 0000000..adf32c6 --- /dev/null +++ b/src/services/ip.ts @@ -0,0 +1,27 @@ +import { conf } from '@/config'; +import { IncomingHttpHeaders } from 'http'; + +export type IpReq = { + ip: string; + headers: IncomingHttpHeaders; +}; + +const trustCloudflare = conf.server.trustCloudflare; + +function getSingleHeader( + headers: IncomingHttpHeaders, + key: string, +): string | undefined { + const header = headers[key]; + if (Array.isArray(header)) return header[0]; + return header; +} + +export function getIp(req: IpReq) { + const cfIp = getSingleHeader(req.headers, 'cf-connecting-ip'); + if (trustCloudflare && cfIp) { + return cfIp; + } + + return req.ip; +}