Skip to content

Commit 791c025

Browse files
authored
fix: add rate limiting for sign up, password reset, and log in (#7281)
1 parent c222824 commit 791c025

File tree

6 files changed

+79
-2
lines changed

6 files changed

+79
-2
lines changed

.changeset/every-chefs-switch.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'hive': minor
3+
---
4+
5+
Introduce rate limiting for email sign up, sign in and password rest.
6+
The IP value to use for the rate limiting can be specified via the
7+
`SUPERTOKENS_RATE_LIMIT_IP_HEADER_NAME` environment variable.
8+
By default the `CF-Connecting-IP` header is being used.

packages/services/server/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ The GraphQL API for GraphQL Hive.
4848
| `CDN_API_KV_BASE_URL` | No (**Optional** if `CDN_API` is set to `1`) | The base URL for the KV for API Provider. Used for scenarios where we cache CDN access. | `https://key-cache.graphql-hive.com` |
4949
| `SUPERTOKENS_CONNECTION_URI` | **Yes** | The URI of the SuperTokens instance. | `http://127.0.0.1:3567` |
5050
| `SUPERTOKENS_API_KEY` | **Yes** | The API KEY of the SuperTokens instance. | `iliketurtlesandicannotlie` |
51+
| `SUPERTOKENS_RATE_LIMIT_IP_HEADER_NAME` | No (Default value: `CF-Connecting-IP`) | Name of the header to be used for rate limiting. | `CF-Connecting-IP` |
5152
| `AUTH_GITHUB` | No | Whether login via GitHub should be allowed | `1` (enabled) or `0` (disabled) |
5253
| `AUTH_GITHUB_CLIENT_ID` | No (**Yes** if `AUTH_GITHUB` is set) | The GitHub client ID. | `g6aff8102efda5e1d12e` |
5354
| `AUTH_GITHUB_CLIENT_SECRET` | No (**Yes** if `AUTH_GITHUB` is set) | The GitHub client secret. | `g12e552xx54xx2b127821dc4abc4491dxxxa6b187` |

packages/services/server/src/environment.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ const RedisModel = zod.object({
9898
const SuperTokensModel = zod.object({
9999
SUPERTOKENS_CONNECTION_URI: zod.string().url(),
100100
SUPERTOKENS_API_KEY: zod.string(),
101+
SUPERTOKENS_RATE_LIMIT_IP_HEADER_NAME: emptyString(zod.string().optional()),
101102
});
102103

103104
const GitHubModel = zod.union([
@@ -408,6 +409,7 @@ export const env = {
408409
supertokens: {
409410
connectionURI: supertokens.SUPERTOKENS_CONNECTION_URI,
410411
apiKey: supertokens.SUPERTOKENS_API_KEY,
412+
rateLimitIPHeaderName: supertokens.SUPERTOKENS_RATE_LIMIT_IP_HEADER_NAME ?? 'CF-Connecting-IP',
411413
},
412414
auth: {
413415
github:

packages/services/server/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,7 @@ export async function main() {
497497
storage,
498498
crypto,
499499
logger: server.log,
500+
redis,
500501
broadcastLog(id, message) {
501502
pubSub.publish('oidcIntegrationLogs', id, {
502503
timestamp: new Date().toISOString(),

packages/services/server/src/supertokens.ts

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { FastifyBaseLogger } from 'fastify';
2+
import type Redis from 'ioredis';
23
import { CryptoProvider } from 'packages/services/api/src/modules/shared/providers/crypto';
34
import { OverrideableBuilder } from 'supertokens-js-override/lib/build/index.js';
45
import supertokens from 'supertokens-node';
@@ -16,6 +17,7 @@ import { createInternalApiCaller } from './api';
1617
import { env } from './environment';
1718
import {
1819
createOIDCSuperTokensProvider,
20+
getLoggerFromUserContext,
1921
getOIDCSuperTokensOverrides,
2022
type BroadcastOIDCIntegrationLog,
2123
} from './supertokens/oidc-provider';
@@ -34,6 +36,7 @@ export const backendConfig = (requirements: {
3436
crypto: CryptoProvider;
3537
logger: FastifyBaseLogger;
3638
broadcastLog: BroadcastOIDCIntegrationLog;
39+
redis: Redis;
3740
}): TypeInput => {
3841
const { logger } = requirements;
3942
const emailsService = createTRPCProxyClient<EmailsApi>({
@@ -146,7 +149,7 @@ export const backendConfig = (requirements: {
146149
}),
147150
},
148151
override: composeSuperTokensOverrides([
149-
getEnsureUserOverrides(internalApi),
152+
getEnsureUserOverrides(internalApi, requirements.redis),
150153
env.auth.organizationOIDC ? getOIDCSuperTokensOverrides() : null,
151154
]),
152155
}),
@@ -208,8 +211,33 @@ export const backendConfig = (requirements: {
208211
};
209212
};
210213

214+
function extractIPFromUserContext(userContext: unknown): string {
215+
return (
216+
(userContext as any)._default.request.getHeaderValue(env.supertokens.rateLimitIPHeaderName) ||
217+
(userContext as any)._default.request.original.ip
218+
);
219+
}
220+
221+
function createRedisRateLimiter(redis: Redis, windowSeconds = 5 * 60, maxRequests = 10) {
222+
async function isRateLimited(action: string, ip: string): Promise<boolean> {
223+
const key = `supertokens-rate-limit:${action}:${ip}`;
224+
const current = await redis.incr(key);
225+
if (current === 1) {
226+
await redis.expire(key, windowSeconds);
227+
}
228+
if (current > maxRequests) {
229+
return true;
230+
}
231+
232+
return false;
233+
}
234+
235+
return { isRateLimited };
236+
}
237+
211238
const getEnsureUserOverrides = (
212239
internalApi: ReturnType<typeof createInternalApiCaller>,
240+
redis: Redis,
213241
): ThirdPartEmailPasswordTypeInput['override'] => ({
214242
apis: originalImplementation => ({
215243
...originalImplementation,
@@ -218,6 +246,18 @@ const getEnsureUserOverrides = (
218246
throw Error('emailPasswordSignUpPOST is not available');
219247
}
220248

249+
const logger = getLoggerFromUserContext(input.userContext);
250+
const ip = extractIPFromUserContext(input.userContext);
251+
const rateLimiter = createRedisRateLimiter(redis);
252+
253+
if (await rateLimiter.isRateLimited('sign-up', ip)) {
254+
logger.debug('email password sign up rate limited (ip=%s)', ip);
255+
return {
256+
status: 'GENERAL_ERROR',
257+
message: 'Please try again in a few minutes.',
258+
};
259+
}
260+
221261
const response = await originalImplementation.emailPasswordSignUpPOST(input);
222262

223263
const firstName = input.formFields.find(field => field.id === 'firstName')?.value ?? null;
@@ -240,6 +280,18 @@ const getEnsureUserOverrides = (
240280
throw Error('Should never come here');
241281
}
242282

283+
const logger = getLoggerFromUserContext(input.userContext);
284+
const ip = extractIPFromUserContext(input.userContext);
285+
const rateLimiter = createRedisRateLimiter(redis);
286+
287+
if (await rateLimiter.isRateLimited('sign-in', ip)) {
288+
logger.debug('email sign in rate limited (ip=%s)', ip);
289+
return {
290+
status: 'GENERAL_ERROR',
291+
message: 'Please try again in a few minutes.',
292+
};
293+
}
294+
243295
const response = await originalImplementation.emailPasswordSignInPOST(input);
244296

245297
if (response.status === 'OK') {
@@ -285,6 +337,18 @@ const getEnsureUserOverrides = (
285337
return response;
286338
},
287339
async passwordResetPOST(input) {
340+
const logger = getLoggerFromUserContext(input.userContext);
341+
const ip = extractIPFromUserContext(input.userContext);
342+
const rateLimiter = createRedisRateLimiter(redis);
343+
344+
if (await rateLimiter.isRateLimited('password-reset', ip)) {
345+
logger.debug('password reset rate limited (ip=%s)', ip);
346+
return {
347+
status: 'GENERAL_ERROR',
348+
message: 'Please try again in a few minutes.',
349+
};
350+
}
351+
288352
const result = await originalImplementation.passwordResetPOST!(input);
289353

290354
// For security reasons we revoke all sessions when a password reset is performed.
@@ -349,6 +413,7 @@ export function initSupertokens(requirements: {
349413
crypto: CryptoProvider;
350414
logger: FastifyBaseLogger;
351415
broadcastLog: BroadcastOIDCIntegrationLog;
416+
redis: Redis;
352417
}) {
353418
supertokens.init(backendConfig(requirements));
354419
}

packages/services/server/src/supertokens/oidc-provider.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export const getOIDCSuperTokensOverrides = (): ThirdPartEmailPasswordTypeInput['
2929

3030
export type BroadcastOIDCIntegrationLog = (oidcId: string, message: string) => void;
3131

32-
function getLoggerFromUserContext(userContext: unknown): FastifyBaseLogger {
32+
export function getLoggerFromUserContext(userContext: unknown): FastifyBaseLogger {
3333
return (userContext as any)._default.request.request.log;
3434
}
3535

0 commit comments

Comments
 (0)