diff --git a/packages/authx/src/Config.ts b/packages/authx/src/Config.ts index fe7d2ddc..4ac52565 100644 --- a/packages/authx/src/Config.ts +++ b/packages/authx/src/Config.ts @@ -28,6 +28,7 @@ export interface Config { readonly from?: string; }) => Promise; readonly processSchema?: (schema: GraphQLSchema) => GraphQLSchema; + readonly maxRequestsPerMinute: number | null; } export function assertConfig(config: Config): void { diff --git a/packages/authx/src/Context.ts b/packages/authx/src/Context.ts index 6e4044ac..bc34862d 100644 --- a/packages/authx/src/Context.ts +++ b/packages/authx/src/Context.ts @@ -2,6 +2,7 @@ import { Pool } from "pg"; import { Authorization } from "./model"; import { Explanation } from "./util/explanations"; import { ReadonlyDataLoaderExecutor } from "./loader"; +import { RateLimiter } from "./util/ratelimiter"; export interface Context { readonly realm: string; @@ -10,6 +11,7 @@ export interface Context { readonly publicKeys: ReadonlyArray; readonly codeValidityDuration: number; readonly jwtValidityDuration: number; + readonly rateLimiter: RateLimiter; readonly sendMail: (options: { readonly to: string; readonly subject: string; diff --git a/packages/authx/src/errors.ts b/packages/authx/src/errors.ts index 5714d9aa..b8161756 100644 --- a/packages/authx/src/errors.ts +++ b/packages/authx/src/errors.ts @@ -171,3 +171,25 @@ export class NotImplementedError extends Error { this.status = this.statusCode = 501; } } + +export class TooManyRequests extends Error { + public fileName?: string; + public lineNumber?: number; + public status: 429 = 429; + public statusCode: 429 = 429; + public expose: boolean; + + public constructor( + message?: string, + expose: boolean = true, + fileName?: string, + lineNumber?: number + ) { + super(message); + if (typeof fileName !== undefined) this.fileName = fileName; + if (typeof lineNumber !== undefined) this.lineNumber = lineNumber; + this.message = message || "Too many requests."; + this.expose = expose; + this.status = this.statusCode = 429; + } +} diff --git a/packages/authx/src/graphql/GraphQLAuthorization.ts b/packages/authx/src/graphql/GraphQLAuthorization.ts index 35b72306..8a2cf100 100644 --- a/packages/authx/src/graphql/GraphQLAuthorization.ts +++ b/packages/authx/src/graphql/GraphQLAuthorization.ts @@ -148,6 +148,7 @@ export const GraphQLAuthorization: GraphQLObjectType< privateKey, authorization: a, executor, + rateLimiter, }: Context ): Promise { if (!a) return null; @@ -174,6 +175,8 @@ export const GraphQLAuthorization: GraphQLObjectType< } if (args.format === "bearer") { + rateLimiter.limit(authorization.id); + const tokenId = v4(); const grant = await authorization.grant(executor); await authorization.invoke(executor, { diff --git a/packages/authx/src/index.ts b/packages/authx/src/index.ts index 7a96e09b..0967eae9 100644 --- a/packages/authx/src/index.ts +++ b/packages/authx/src/index.ts @@ -17,6 +17,11 @@ import { StrategyCollection } from "./StrategyCollection"; import { UnsupportedMediaTypeError } from "./errors"; import { createAuthXExplanations } from "./explanations"; import { DataLoaderExecutor } from "./loader"; +import { + LocalMemoryRateLimiter, + NoOpRateLimiter, + RateLimiter, +} from "./util/ratelimiter"; export * from "./x"; export * from "./errors"; @@ -33,6 +38,7 @@ type AuthXMiddleware = Middleware; export class AuthX extends Router { public readonly pool: Pool; + public readonly rateLimiter: RateLimiter; public constructor(config: Config & IRouterOptions) { assertConfig(config); super(config); @@ -46,6 +52,10 @@ export class AuthX extends Router { // create a database pool this.pool = new Pool(config.pg); + this.rateLimiter = + typeof config.maxRequestsPerMinute === "number" + ? new LocalMemoryRateLimiter(config.maxRequestsPerMinute) + : new NoOpRateLimiter(); // define the context middleware const contextMiddleware = async ( @@ -69,6 +79,8 @@ export class AuthX extends Router { if (basic) { authorization = await fromBasic(tx, basic); + this.rateLimiter.limit(authorization.id); + // Invoke the authorization. Because the resource validates basic // tokens by making a GraphQL request here, each request can be // considered an invocation. @@ -105,6 +117,7 @@ export class AuthX extends Router { authorization, explanations: explanations, executor: new DataLoaderExecutor(this.pool, strategies), + rateLimiter: this.rateLimiter, }; ctx[x] = context; diff --git a/packages/authx/src/oauth2.ts b/packages/authx/src/oauth2.ts index 44a433a6..d7900179 100644 --- a/packages/authx/src/oauth2.ts +++ b/packages/authx/src/oauth2.ts @@ -362,6 +362,8 @@ async function oAuth2Middleware( ); } + ctx[x].rateLimiter.limit(paramsClientId); + // Authenticate the client with its secret. let client; try { @@ -659,6 +661,8 @@ async function oAuth2Middleware( ); } + ctx[x].rateLimiter.limit(paramsClientId); + const requestedScopeTemplates = paramsScope ? paramsScope.split(" ") : []; diff --git a/packages/authx/src/util/ratelimiter.ts b/packages/authx/src/util/ratelimiter.ts new file mode 100644 index 00000000..e7771231 --- /dev/null +++ b/packages/authx/src/util/ratelimiter.ts @@ -0,0 +1,64 @@ +import { performance } from "perf_hooks"; +import { TooManyRequests } from "../errors"; + +/** + * Applies a simple in-memory rate limiting scheme. This system is designed to prevent a single + * malfunctioning client from bringing down the service. This system is only designed to prevent + * unintentional abuse by malfunctioning clients. + */ +export interface RateLimiter { + /** + * Applies the configured rate limit to the given key. If the key is being excessively used, throws a TooManyRequests exception. + * @param key A string representing the key to use for rate limiting. This is generally the id of an authorization, + * client, or credential. + */ + limit(key: string): void; +} + +export class LocalMemoryRateLimiter implements RateLimiter { + private readonly map: { [key: string]: number[] } = { + __proto__: null as any, + }; + + constructor( + private readonly limitPerWindow = 100, + private readonly window = 60 * 1_000, + private readonly timeSource: () => number = performance.now + ) {} + + limit(key: string): void { + const currentTime = this.timeSource(); + + for (const existingKey of Object.keys(this.map)) { + for (let i = 0; i < this.map[existingKey].length; ++i) { + if (currentTime - this.map[existingKey][i] > this.window) { + this.map[existingKey].splice(i, 1); + --i; + } + } + + if (this.map[existingKey].length == 0) { + delete this.map[existingKey]; + } + } + + if ( + typeof this.map[key] !== "undefined" && + this.map[key].length >= this.limitPerWindow + ) { + throw new TooManyRequests(); + } + + if (typeof this.map[key] === "undefined") { + this.map[key] = []; + } + + this.map[key].push(currentTime); + } +} + +export class NoOpRateLimiter implements RateLimiter { + limit(): void { + // This rate limiter does nothing. It only exists so that clients can call some rate limiter. + } +} diff --git a/packages/authx/src/util/raterlimiter.test.ts b/packages/authx/src/util/raterlimiter.test.ts new file mode 100644 index 00000000..5a0a97c4 --- /dev/null +++ b/packages/authx/src/util/raterlimiter.test.ts @@ -0,0 +1,32 @@ +import test from "ava"; +import { LocalMemoryRateLimiter } from "./ratelimiter"; + +test("Rate limiter over rate", async (t) => { + let curTime = 0; + + const limiter = new LocalMemoryRateLimiter(3, 60 * 1_000, () => { + curTime += 14_000; + return curTime; + }); + + limiter.limit("A"); + limiter.limit("B"); + limiter.limit("C"); + limiter.limit("A"); + limiter.limit("B"); + limiter.limit("C"); + limiter.limit("A"); + limiter.limit("B"); + limiter.limit("C"); + + limiter.limit("A"); + limiter.limit("A"); + limiter.limit("A"); + + try { + limiter.limit("A"); + t.fail("4th call in the same minute should cause 429"); + } catch (ex) { + t.pass(); + } +}); diff --git a/packages/strategy-email/src/server/graphql/mutation/authenticateEmail.ts b/packages/strategy-email/src/server/graphql/mutation/authenticateEmail.ts index 59ea614d..c9845cc1 100644 --- a/packages/strategy-email/src/server/graphql/mutation/authenticateEmail.ts +++ b/packages/strategy-email/src/server/graphql/mutation/authenticateEmail.ts @@ -176,6 +176,8 @@ export const authenticateEmail: GraphQLFieldConfig< ); } + context.rateLimiter.limit(credential.id); + // Invoke the credential. await credential.invoke(executor, { id: v4(), diff --git a/packages/strategy-openid/src/server/graphql/mutation/authenticateOpenId.ts b/packages/strategy-openid/src/server/graphql/mutation/authenticateOpenId.ts index 540451bf..c464977d 100644 --- a/packages/strategy-openid/src/server/graphql/mutation/authenticateOpenId.ts +++ b/packages/strategy-openid/src/server/graphql/mutation/authenticateOpenId.ts @@ -290,6 +290,8 @@ export const authenticateOpenId: GraphQLFieldConfig< throw new AuthenticationError("No such credential exists."); } + context.rateLimiter.limit(credential.id); + // Invoke the credential. await credential.invoke(executor, { id: v4(), diff --git a/packages/strategy-password/src/server/graphql/mutation/authenticatePassword.ts b/packages/strategy-password/src/server/graphql/mutation/authenticatePassword.ts index c2583b2a..b66d6b2e 100644 --- a/packages/strategy-password/src/server/graphql/mutation/authenticatePassword.ts +++ b/packages/strategy-password/src/server/graphql/mutation/authenticatePassword.ts @@ -143,6 +143,8 @@ export const authenticatePassword: GraphQLFieldConfig< ); } + context.rateLimiter.limit(credential.id); + // Invoke the credential. await credential.invoke(executor, { id: v4(), diff --git a/packages/strategy-saml/src/server/graphql/mutation/authenticateSaml.ts b/packages/strategy-saml/src/server/graphql/mutation/authenticateSaml.ts index ec241c3c..8e4e3727 100644 --- a/packages/strategy-saml/src/server/graphql/mutation/authenticateSaml.ts +++ b/packages/strategy-saml/src/server/graphql/mutation/authenticateSaml.ts @@ -321,6 +321,8 @@ export const authenticateSaml: GraphQLFieldConfig< ); } + context.rateLimiter.limit(credential.id); + // Invoke the credential. await credential.invoke(executor, { id: v4(), diff --git a/src/server.ts b/src/server.ts index da2fcf74..224db564 100644 --- a/src/server.ts +++ b/src/server.ts @@ -80,6 +80,9 @@ Bac/x5qiUn5fh2xM+wIDAQAB ssl: process.env.PGSSL === "true" ? true : false, user: process.env.PGUSER ?? undefined, }, + maxRequestsPerMinute: process.env.MAX_REQUESTS_PER_KEY_PER_MINUTE + ? parseFloat(process.env.MAX_REQUESTS_PER_KEY_PER_MINUTE) + : null, }); // Apply the AuthX routes to the app. diff --git a/src/setup.ts b/src/setup.ts index 0a97b246..ce43e907 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -127,6 +127,7 @@ Bac/x5qiUn5fh2xM+wIDAQAB ssl: process.env.PGSSL === "true" ? true : false, user: process.env.PGUSER ?? undefined, }, + maxRequestsPerMinute: null, }); // Apply the AuthX routes to the app.