diff --git a/server/api/auth/login.post.ts b/server/api/auth/login.post.ts index ada022d..378a11e 100644 --- a/server/api/auth/login.post.ts +++ b/server/api/auth/login.post.ts @@ -3,6 +3,13 @@ import { LoginSchema } from '#shared/api-types'; import type { ApiErrorCodes } from '~~/shared/errors'; import type { ApiAuthLogin } from '#shared/api-types'; +const bucket = createRatelimitBucket({ + id: 'login', + points: 30, + durationSec: 5 * 60, // 5 minutes + blockDurationSec: 1 * 60 * 60 // 1 hour +}); + const errors: Record = { 'INVALID_ARGUMENT: User not found': 'INVALID_USERNAME', 'INVALID_ARGUMENT: Password is incorrect': 'INVALID_PASSWORD', @@ -10,6 +17,7 @@ const errors: Record = { }; export default defineEventHandler(async (event): Promise => { + await enforceRatelimit(event, bucket); const body = await readZodBody(event, LoginSchema); const grpc = useApiGrpc(event); diff --git a/server/api/auth/refresh.post.ts b/server/api/auth/refresh.post.ts index 9508964..03380c4 100644 --- a/server/api/auth/refresh.post.ts +++ b/server/api/auth/refresh.post.ts @@ -2,7 +2,15 @@ import { ClientError } from 'nice-grpc'; import { RefreshSchema } from '#shared/api-types'; import type { ApiAuthLogin } from '#shared/api-types'; +const bucket = createRatelimitBucket({ + id: 'refresh', + points: 10, + durationSec: 1 * 60, // 1 minute + blockDurationSec: 1 * 60 * 60 // 1 hour +}); + export default defineEventHandler(async (event): Promise => { + await enforceRatelimit(event, bucket); const body = await readZodBody(event, RefreshSchema); const grpc = useApiGrpc(event); diff --git a/server/api/auth/register.post.ts b/server/api/auth/register.post.ts index a6b2d6a..b62b6a6 100644 --- a/server/api/auth/register.post.ts +++ b/server/api/auth/register.post.ts @@ -3,6 +3,13 @@ import { RegisterSchema } from '#shared/api-types'; import type { ApiErrorCodes } from '~~/shared/errors'; import type { ApiAuthLogin } from '#shared/api-types'; +const bucket = createRatelimitBucket({ + id: 'register', + points: 15, + durationSec: 5 * 60, // 5 minutes + blockDurationSec: 1 * 60 * 60 // 1 hour +}); + const errors: Record = { 'INVALID_ARGUMENT: Captcha verification failed': 'INVALID_CAPTCHA', 'INVALID_ARGUMENT: Invalid email address': 'INVALID_EMAIL', @@ -39,6 +46,7 @@ function assertAge(birthDate: string | undefined) { } export default defineEventHandler(async (event): Promise => { + await enforceRatelimit(event, bucket); const body = await readZodBody(event, RegisterSchema); const grpc = useApiGrpc(event); assertAge(body.birthday); diff --git a/server/utils/cache.ts b/server/utils/cache.ts index 6b64731..1991ae5 100644 --- a/server/utils/cache.ts +++ b/server/utils/cache.ts @@ -70,7 +70,7 @@ function createCacher(event: H3Event): Cacher { }; } -export function useCacher(event: H3Event): Cacher | null { +export function useCacher(event: H3Event): Cacher { if (!cacher) { cacher = createCacher(event); } diff --git a/server/utils/ratelimits.ts b/server/utils/ratelimits.ts new file mode 100644 index 0000000..c744741 --- /dev/null +++ b/server/utils/ratelimits.ts @@ -0,0 +1,52 @@ +import { RateLimiterMemory, RateLimiterRedis } from 'rate-limiter-flexible'; +import Redis from 'ioredis'; +import type { RateLimiterAbstract, IRateLimiterOptions } from 'rate-limiter-flexible'; +import type { H3Event } from 'h3'; + +let redisRatelimit: Redis | null = null; + +export type RatelimitBucketOptions = { + id: string; + points: number; + durationSec: number; + blockDurationSec?: number; +}; + +export function createRatelimitBucket(ops: RatelimitBucketOptions) { + const config = useRuntimeConfig(); + const prefix = `pn:website:ratelimit:${ops.id}`; + const ratelimitOps: IRateLimiterOptions = { + keyPrefix: prefix, + duration: ops.durationSec, + blockDuration: ops.blockDurationSec, + points: ops.points + }; + + if (config.redisUrl) { + if (!redisRatelimit) { + redisRatelimit = new Redis(config.redisUrl, { + enableOfflineQueue: false + }); + } + return new RateLimiterRedis({ + ...ratelimitOps, + storeClient: redisRatelimit + }); + } + + return new RateLimiterMemory(ratelimitOps); +} + +export async function enforceRatelimit(event: H3Event, bucket: RateLimiterAbstract): Promise { + const config = useRuntimeConfig(); + const ip = getRequestIP(event, { xForwardedFor: !!config.trustProxy }); + if (!ip) { + throw new Error('Could not get IP for request'); + } + + try { + await bucket.consume(ip, 1); + } catch { + throw createApiError('RATELIMITED'); + } +} diff --git a/shared/errors.ts b/shared/errors.ts index 3923d6d..fb45ede 100644 --- a/shared/errors.ts +++ b/shared/errors.ts @@ -1,5 +1,6 @@ const apiErrorCodes = { UNPARSABLE_ERROR: 'Fatal exception!', + RATELIMITED: 'Too many requests!', UNHANDLED_ERROR: 'Something went wrong', INVALID_INPUT: 'Invalid input', INTEGRATION_DISABLED: 'Integration with this service is disabled', @@ -24,6 +25,7 @@ export type ApiErrorCodes = keyof typeof apiErrorCodes; export const apiErrorCodeStatus: Record = { UNPARSABLE_ERROR: 500, + RATELIMITED: 429, UNHANDLED_ERROR: 500, INVALID_INPUT: 400, INTEGRATION_DISABLED: 500,