Skip to content

Commit 01f100b

Browse files
authored
[public-api] add rate limiting in server (#18953)
1 parent 004aa50 commit 01f100b

File tree

5 files changed

+136
-18
lines changed

5 files changed

+136
-18
lines changed
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/**
2+
* Copyright (c) 2023 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License.AGPL.txt in the project root for license information.
5+
*/
6+
7+
import { IRateLimiterOptions } from "rate-limiter-flexible";
8+
9+
const RATE_LIMIT_METADATA_KEY = Symbol("RateLimited");
10+
11+
export function RateLimited(options: IRateLimiterOptions) {
12+
return Reflect.metadata(RATE_LIMIT_METADATA_KEY, options);
13+
}
14+
15+
export namespace RateLimited {
16+
const defaultOptions: IRateLimiterOptions = {
17+
points: 200,
18+
duration: 60,
19+
};
20+
export function getOptions(target: Object, properyKey: string | symbol): IRateLimiterOptions {
21+
return Reflect.getMetadata(RATE_LIMIT_METADATA_KEY, target, properyKey) || defaultOptions;
22+
}
23+
}

components/server/src/api/server.ts

Lines changed: 71 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,13 @@ import { APIStatsService as StatsServiceAPI } from "./stats";
3030
import { APITeamsService as TeamsServiceAPI } from "./teams";
3131
import { APIUserService as UserServiceAPI } from "./user";
3232
import { WorkspaceServiceAPI } from "./workspace-service-api";
33+
import { IRateLimiterOptions, RateLimiterMemory, RateLimiterRedis, RateLimiterRes } from "rate-limiter-flexible";
34+
import { Redis } from "ioredis";
35+
import { RateLimited } from "./rate-limited";
36+
import { Config } from "../config";
37+
import { ApplicationError, ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error";
38+
import { UserService } from "../user/user-service";
39+
import { User } from "@gitpod/gitpod-protocol";
3340

3441
decorate(injectable(), PublicAPIConverter);
3542

@@ -46,6 +53,9 @@ export class API {
4653
@inject(HelloServiceAPI) private readonly helloServiceApi: HelloServiceAPI;
4754
@inject(SessionHandler) private readonly sessionHandler: SessionHandler;
4855
@inject(PublicAPIConverter) private readonly apiConverter: PublicAPIConverter;
56+
@inject(Redis) private readonly redis: Redis;
57+
@inject(Config) private readonly config: Config;
58+
@inject(UserService) private readonly userService: UserService;
4959

5060
listenPrivate(): http.Server {
5161
const app = express();
@@ -174,9 +184,30 @@ export class API {
174184

175185
const context = args[1] as HandlerContext;
176186

187+
const rateLimit = async (subjectId: string) => {
188+
const key = `${grpc_service}/${grpc_method}`;
189+
const options = self.config.rateLimits?.[key] || RateLimited.getOptions(target, prop);
190+
try {
191+
await self.getRateLimitter(options).consume(`${subjectId}_${key}`);
192+
} catch (e) {
193+
if (e instanceof RateLimiterRes) {
194+
throw new ConnectError("rate limit exceeded", Code.ResourceExhausted, {
195+
// http compatibility, can be respected by gRPC clients as well
196+
// instead of doing an ad-hoc retry, the client can wait for the given amount of seconds
197+
"Retry-After": e.msBeforeNext / 1000,
198+
"X-RateLimit-Limit": options.points,
199+
"X-RateLimit-Remaining": e.remainingPoints,
200+
"X-RateLimit-Reset": new Date(Date.now() + e.msBeforeNext),
201+
});
202+
}
203+
throw e;
204+
}
205+
};
206+
177207
const apply = async <T>(): Promise<T> => {
178-
const user = await self.verify(context);
179-
context.user = user;
208+
const subjectId = await self.verify(context);
209+
await rateLimit(subjectId);
210+
context.user = await self.ensureFgaMigration(subjectId);
180211

181212
return Reflect.apply(target[prop as any], target, args);
182213
};
@@ -211,16 +242,49 @@ export class API {
211242
};
212243
}
213244

214-
private async verify(context: HandlerContext) {
215-
const user = await this.sessionHandler.verify(context.requestHeader.get("cookie") || "");
216-
if (!user) {
245+
private async verify(context: HandlerContext): Promise<string> {
246+
const claims = await this.sessionHandler.verifyJWTCookie(context.requestHeader.get("cookie") || "");
247+
const subjectId = claims?.sub;
248+
if (!subjectId) {
217249
throw new ConnectError("unauthenticated", Code.Unauthenticated);
218250
}
219-
const fgaChecksEnabled = await isFgaChecksEnabled(user.id);
251+
return subjectId;
252+
}
253+
254+
private async ensureFgaMigration(userId: string): Promise<User> {
255+
const fgaChecksEnabled = await isFgaChecksEnabled(userId);
220256
if (!fgaChecksEnabled) {
221257
throw new ConnectError("unauthorized", Code.PermissionDenied);
222258
}
223-
return user;
259+
try {
260+
return await this.userService.findUserById(userId, userId);
261+
} catch (e) {
262+
if (e instanceof ApplicationError && e.code === ErrorCodes.NOT_FOUND) {
263+
throw new ConnectError("unauthorized", Code.PermissionDenied);
264+
}
265+
throw e;
266+
}
267+
}
268+
269+
private readonly rateLimiters = new Map<string, RateLimiterRedis>();
270+
private getRateLimitter(options: IRateLimiterOptions): RateLimiterRedis {
271+
const sortedKeys = Object.keys(options).sort();
272+
const sortedObject: { [key: string]: any } = {};
273+
for (const key of sortedKeys) {
274+
sortedObject[key] = options[key as keyof IRateLimiterOptions];
275+
}
276+
const key = JSON.stringify(sortedObject);
277+
278+
let rateLimiter = this.rateLimiters.get(key);
279+
if (!rateLimiter) {
280+
rateLimiter = new RateLimiterRedis({
281+
storeClient: this.redis,
282+
...options,
283+
insuranceLimiter: new RateLimiterMemory(options),
284+
});
285+
this.rateLimiters.set(key, rateLimiter);
286+
}
287+
return rateLimiter;
224288
}
225289

226290
static bindAPI(bind: interfaces.Bind): void {

components/server/src/api/teams.spec.db.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ import { UserAuthentication } from "../user/user-authentication";
2121
import { WorkspaceService } from "../workspace/workspace-service";
2222
import { API } from "./server";
2323
import { SessionHandler } from "../session-handler";
24+
import { Redis } from "ioredis";
25+
import { UserService } from "../user/user-service";
26+
import { Config } from "../config";
2427

2528
const expect = chai.expect;
2629

@@ -39,6 +42,9 @@ export class APITeamsServiceSpec {
3942
this.container.bind(WorkspaceService).toConstantValue({} as WorkspaceService);
4043
this.container.bind(UserAuthentication).toConstantValue({} as UserAuthentication);
4144
this.container.bind(SessionHandler).toConstantValue({} as SessionHandler);
45+
this.container.bind(Config).toConstantValue({} as Config);
46+
this.container.bind(Redis).toConstantValue({} as Redis);
47+
this.container.bind(UserService).toConstantValue({} as UserService);
4248

4349
// Clean-up database
4450
const typeorm = testContainer.get<TypeORM>(TypeORM);

components/server/src/config.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { log } from "@gitpod/gitpod-protocol/lib/util/logging";
1717
import { filePathTelepresenceAware } from "@gitpod/gitpod-protocol/lib/env";
1818
import { WorkspaceClassesConfig } from "./workspace/workspace-classes";
1919
import { PrebuildRateLimiters } from "./workspace/prebuild-rate-limiter";
20+
import { IRateLimiterOptions } from "rate-limiter-flexible";
2021

2122
export const Config = Symbol("Config");
2223
export type Config = Omit<
@@ -172,9 +173,20 @@ export interface ConfigSerialized {
172173

173174
/**
174175
* The configuration for the rate limiter we (mainly) use for the websocket API
176+
* @deprecated used for JSON-RPC API, for gRPC use rateLimits
175177
*/
176178
rateLimiter: RateLimiterConfig;
177179

180+
/**
181+
* The configuration for the rate limiter we use for the gRPC API.
182+
* As a primary means use RateLimited decorator.
183+
* Only use this if you need to adjst in production, make sure to apply changes to the decorator as well.
184+
* Key is of the form `<grpc_service>/<grpc_method>`
185+
*/
186+
rateLimits?: {
187+
[key: string]: IRateLimiterOptions;
188+
};
189+
178190
/**
179191
* The address content service clients connect to
180192
* Example: content-service:8080

components/server/src/session-handler.ts

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { Config } from "./config";
1515
import { WsNextFunction, WsRequestHandler } from "./express/ws-handler";
1616
import { reportJWTCookieIssued } from "./prometheus-metrics";
1717
import { UserService } from "./user/user-service";
18+
import { JwtPayload } from "jsonwebtoken";
1819

1920
@injectable()
2021
export class SessionHandler {
@@ -103,29 +104,41 @@ export class SessionHandler {
103104
}
104105

105106
async verify(cookie: string): Promise<User | undefined> {
106-
const cookies = parseCookieHeader(cookie);
107-
const jwtToken = cookies[this.getJWTCookieName(this.config)];
108-
if (!jwtToken) {
109-
log.debug("No JWT session present on request");
110-
return undefined;
111-
}
112107
try {
113-
const claims = await this.authJWT.verify(jwtToken);
114-
log.debug("JWT Session token verified", { claims });
115-
108+
const claims = await this.verifyJWTCookie(cookie);
109+
if (!claims) {
110+
return undefined;
111+
}
116112
const subject = claims.sub;
117113
if (!subject) {
118114
throw new Error("Subject is missing from JWT session claims");
119115
}
120-
121-
return await this.userService.findUserById(subject, subject);
116+
return await this.userService.findUserById(claims.subject, claims.subject);
122117
} catch (err) {
123118
log.warn("Failed to authenticate user with JWT Session", err);
124119
// Remove the existing cookie, to force the user to re-sing in, and hence refresh it
125120
return undefined;
126121
}
127122
}
128123

124+
/**
125+
* Verify the JWT session cookie.
126+
* @throws only in case of programming errors, if the cookie is invalid, undefined is returned
127+
* @param cookie - the cookie header value to verify
128+
* @returns returns the claims of the JWT session cookie or undefined if the cookie is not present or invalid
129+
*/
130+
async verifyJWTCookie(cookie: string): Promise<JwtPayload | undefined> {
131+
const cookies = parseCookieHeader(cookie);
132+
const jwtToken = cookies[this.getJWTCookieName(this.config)];
133+
if (!jwtToken) {
134+
log.debug("No JWT session present on request");
135+
return undefined;
136+
}
137+
const claims = await this.authJWT.verify(jwtToken);
138+
log.debug("JWT Session token verified", { claims });
139+
return claims;
140+
}
141+
129142
public async createJWTSessionCookie(
130143
userID: string,
131144
): Promise<{ name: string; value: string; opts: express.CookieOptions }> {

0 commit comments

Comments
 (0)