diff --git a/components/server/src/api/rate-limited.ts b/components/server/src/api/rate-limited.ts new file mode 100644 index 00000000000000..c240fc477bcbde --- /dev/null +++ b/components/server/src/api/rate-limited.ts @@ -0,0 +1,23 @@ +/** + * Copyright (c) 2023 Gitpod GmbH. All rights reserved. + * Licensed under the GNU Affero General Public License (AGPL). + * See License.AGPL.txt in the project root for license information. + */ + +import { IRateLimiterOptions } from "rate-limiter-flexible"; + +const RATE_LIMIT_METADATA_KEY = Symbol("RateLimited"); + +export function RateLimited(options: IRateLimiterOptions) { + return Reflect.metadata(RATE_LIMIT_METADATA_KEY, options); +} + +export namespace RateLimited { + const defaultOptions: IRateLimiterOptions = { + points: 200, + duration: 60, + }; + export function getOptions(target: Object, properyKey: string | symbol): IRateLimiterOptions { + return Reflect.getMetadata(RATE_LIMIT_METADATA_KEY, target, properyKey) || defaultOptions; + } +} diff --git a/components/server/src/api/server.ts b/components/server/src/api/server.ts index 5eff0abd077dac..d7477e13b3a8a0 100644 --- a/components/server/src/api/server.ts +++ b/components/server/src/api/server.ts @@ -30,6 +30,13 @@ import { APIStatsService as StatsServiceAPI } from "./stats"; import { APITeamsService as TeamsServiceAPI } from "./teams"; import { APIUserService as UserServiceAPI } from "./user"; import { WorkspaceServiceAPI } from "./workspace-service-api"; +import { IRateLimiterOptions, RateLimiterMemory, RateLimiterRedis, RateLimiterRes } from "rate-limiter-flexible"; +import { Redis } from "ioredis"; +import { RateLimited } from "./rate-limited"; +import { Config } from "../config"; +import { ApplicationError, ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error"; +import { UserService } from "../user/user-service"; +import { User } from "@gitpod/gitpod-protocol"; decorate(injectable(), PublicAPIConverter); @@ -46,6 +53,9 @@ export class API { @inject(HelloServiceAPI) private readonly helloServiceApi: HelloServiceAPI; @inject(SessionHandler) private readonly sessionHandler: SessionHandler; @inject(PublicAPIConverter) private readonly apiConverter: PublicAPIConverter; + @inject(Redis) private readonly redis: Redis; + @inject(Config) private readonly config: Config; + @inject(UserService) private readonly userService: UserService; listenPrivate(): http.Server { const app = express(); @@ -174,9 +184,30 @@ export class API { const context = args[1] as HandlerContext; + const rateLimit = async (subjectId: string) => { + const key = `${grpc_service}/${grpc_method}`; + const options = self.config.rateLimits?.[key] || RateLimited.getOptions(target, prop); + try { + await self.getRateLimitter(options).consume(`${subjectId}_${key}`); + } catch (e) { + if (e instanceof RateLimiterRes) { + throw new ConnectError("rate limit exceeded", Code.ResourceExhausted, { + // http compatibility, can be respected by gRPC clients as well + // instead of doing an ad-hoc retry, the client can wait for the given amount of seconds + "Retry-After": e.msBeforeNext / 1000, + "X-RateLimit-Limit": options.points, + "X-RateLimit-Remaining": e.remainingPoints, + "X-RateLimit-Reset": new Date(Date.now() + e.msBeforeNext), + }); + } + throw e; + } + }; + const apply = async (): Promise => { - const user = await self.verify(context); - context.user = user; + const subjectId = await self.verify(context); + await rateLimit(subjectId); + context.user = await self.ensureFgaMigration(subjectId); return Reflect.apply(target[prop as any], target, args); }; @@ -211,17 +242,50 @@ export class API { }; } - private async verify(context: HandlerContext) { - const cookieHeader: string = context.requestHeader.get("cookie") || ""; - const user = await this.sessionHandler.verify(cookieHeader); - if (!user) { + private async verify(context: HandlerContext): Promise { + const cookieHeader = (context.requestHeader.get("cookie") || "") as string; + const claims = await this.sessionHandler.verifyJWTCookie(cookieHeader); + const subjectId = claims?.sub; + if (!subjectId) { throw new ConnectError("unauthenticated", Code.Unauthenticated); } - const fgaChecksEnabled = await isFgaChecksEnabled(user.id); + return subjectId; + } + + private async ensureFgaMigration(userId: string): Promise { + const fgaChecksEnabled = await isFgaChecksEnabled(userId); if (!fgaChecksEnabled) { throw new ConnectError("unauthorized", Code.PermissionDenied); } - return user; + try { + return await this.userService.findUserById(userId, userId); + } catch (e) { + if (e instanceof ApplicationError && e.code === ErrorCodes.NOT_FOUND) { + throw new ConnectError("unauthorized", Code.PermissionDenied); + } + throw e; + } + } + + private readonly rateLimiters = new Map(); + private getRateLimitter(options: IRateLimiterOptions): RateLimiterRedis { + const sortedKeys = Object.keys(options).sort(); + const sortedObject: { [key: string]: any } = {}; + for (const key of sortedKeys) { + sortedObject[key] = options[key as keyof IRateLimiterOptions]; + } + const key = JSON.stringify(sortedObject); + + let rateLimiter = this.rateLimiters.get(key); + if (!rateLimiter) { + rateLimiter = new RateLimiterRedis({ + storeClient: this.redis, + ...options, + insuranceLimiter: new RateLimiterMemory(options), + }); + this.rateLimiters.set(key, rateLimiter); + } + return rateLimiter; } static bindAPI(bind: interfaces.Bind): void { diff --git a/components/server/src/api/teams.spec.db.ts b/components/server/src/api/teams.spec.db.ts index 710317697e0e66..a452d5be728610 100644 --- a/components/server/src/api/teams.spec.db.ts +++ b/components/server/src/api/teams.spec.db.ts @@ -21,6 +21,9 @@ import { UserAuthentication } from "../user/user-authentication"; import { WorkspaceService } from "../workspace/workspace-service"; import { API } from "./server"; import { SessionHandler } from "../session-handler"; +import { Redis } from "ioredis"; +import { UserService } from "../user/user-service"; +import { Config } from "../config"; const expect = chai.expect; @@ -39,6 +42,9 @@ export class APITeamsServiceSpec { this.container.bind(WorkspaceService).toConstantValue({} as WorkspaceService); this.container.bind(UserAuthentication).toConstantValue({} as UserAuthentication); this.container.bind(SessionHandler).toConstantValue({} as SessionHandler); + this.container.bind(Config).toConstantValue({} as Config); + this.container.bind(Redis).toConstantValue({} as Redis); + this.container.bind(UserService).toConstantValue({} as UserService); // Clean-up database const typeorm = testContainer.get(TypeORM); diff --git a/components/server/src/config.ts b/components/server/src/config.ts index 7183cc1f738cbd..33d4cf5651be47 100644 --- a/components/server/src/config.ts +++ b/components/server/src/config.ts @@ -17,6 +17,7 @@ import { log } from "@gitpod/gitpod-protocol/lib/util/logging"; import { filePathTelepresenceAware } from "@gitpod/gitpod-protocol/lib/env"; import { WorkspaceClassesConfig } from "./workspace/workspace-classes"; import { PrebuildRateLimiters } from "./workspace/prebuild-rate-limiter"; +import { IRateLimiterOptions } from "rate-limiter-flexible"; export const Config = Symbol("Config"); export type Config = Omit< @@ -174,9 +175,20 @@ export interface ConfigSerialized { /** * The configuration for the rate limiter we (mainly) use for the websocket API + * @deprecated used for JSON-RPC API, for gRPC use rateLimits */ rateLimiter: RateLimiterConfig; + /** + * The configuration for the rate limiter we use for the gRPC API. + * As a primary means use RateLimited decorator. + * Only use this if you need to adjst in production, make sure to apply changes to the decorator as well. + * Key is of the form `/` + */ + rateLimits?: { + [key: string]: IRateLimiterOptions; + }; + /** * The address content service clients connect to * Example: content-service:8080