feat: provide a way to bypass redis based rate limits (#7726)

This commit is contained in:
Laurin 2026-02-24 15:38:13 +01:00 committed by GitHub
parent 31246ebd4a
commit 0ab6551c0a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 33 additions and 5 deletions

View file

@ -98,6 +98,7 @@ export function deployGraphQL({
const supertokensSecrets = new ServiceSecret('supertokens-at-home', {
refreshTokenKey: supertokensConfig.requireSecret('refreshTokenKey'),
accessTokenKey: supertokensConfig.requireSecret('accessTokenKey'),
bypassRateLimitKey: supertokensConfig.requireSecret('bypassRateLimitKey'),
});
return (
@ -224,6 +225,7 @@ export function deployGraphQL({
// Supertokens
.withSecret('SUPERTOKENS_REFRESH_TOKEN_KEY', supertokensSecrets, 'refreshTokenKey')
.withSecret('SUPERTOKENS_ACCESS_TOKEN_KEY', supertokensSecrets, 'accessTokenKey')
.withSecret('SUPERTOKENS_RATE_LIMIT_BYPASS_KEY', supertokensSecrets, 'bypassRateLimitKey')
// Zendesk
.withConditionalSecret(zendesk.enabled, 'ZENDESK_SUBDOMAIN', zendesk.secret, 'subdomain')
.withConditionalSecret(zendesk.enabled, 'ZENDESK_USERNAME', zendesk.secret, 'username')

View file

@ -157,6 +157,7 @@ export function createRegistry({
baseUrl: string;
rateLimit: null | {
ipHeaderName: string;
bypassKey: string | null;
};
} | null;
schemaConfig: SchemaModuleConfig;

View file

@ -1,6 +1,8 @@
import { timingSafeEqual } from 'node:crypto';
import { Inject, Injectable, Scope } from 'graphql-modules';
import { type Redis } from 'ioredis';
import { type FastifyRequest } from '@hive/service-common';
import { sha256 } from '../../auth/lib/supertokens-at-home/crypto';
import { Logger } from './logger';
import { REDIS_INSTANCE } from './redis';
import { RateLimitConfig } from './tokens';
@ -9,7 +11,8 @@ import { RateLimitConfig } from './tokens';
scope: Scope.Singleton,
})
export class RedisRateLimiter {
logger: Logger;
private logger: Logger;
private bypassKey: Buffer | null;
constructor(
@Inject(REDIS_INSTANCE) private redis: Redis,
@ -17,6 +20,7 @@ export class RedisRateLimiter {
logger: Logger,
) {
this.logger = logger.child({ module: 'RateLimiter' });
this.bypassKey = config.config?.bypassKey ? Buffer.from(config.config.bypassKey) : null;
}
/**
@ -34,6 +38,20 @@ export class RedisRateLimiter {
return false;
}
const cookies: undefined | Record<string, undefined | string> = (req as any).cookies;
if (this.bypassKey !== null && cookies?.['sBypassRateLimitKey']) {
const incomingBypassKey = Buffer.from(cookies['sBypassRateLimitKey']);
if (
this.bypassKey.length === incomingBypassKey.length &&
timingSafeEqual(incomingBypassKey, this.bypassKey)
) {
this.logger.debug('rate limit bypassed via provided key');
return false;
}
}
req.routeOptions.url;
let ip = req.ip;
@ -46,18 +64,20 @@ export class RedisRateLimiter {
ip = req.headers[this.config.config.ipHeaderName] as string;
}
const key = `server-rate-limiter:${req.routeOptions.url}:${ip}`;
const ipHash = sha256(ip);
const key = `server-rate-limiter:${req.routeOptions.url}:${ipHash}`;
const current = await this.redis.incr(key);
if (current === 1) {
await this.redis.expire(key, timeWindowSeconds);
}
if (current > maxActionsPerTimeWindow) {
this.logger.debug('request is rate limited');
this.logger.debug('request is rate limited (ip_hash=%s)', ipHash);
return true;
}
this.logger.debug('request is not rate limited');
this.logger.debug('request is not rate limited (ip_hash=%s)', ipHash);
return false;
}
}

View file

@ -7,6 +7,7 @@ export class RateLimitConfig {
constructor(
public readonly config: null | {
ipHeaderName: string;
bypassKey: string | null;
},
) {}
}

View file

@ -106,6 +106,7 @@ const SuperTokensModel = zod.union([
SUPERTOKENS_API_KEY: zod.string(),
SUPERTOKENS_RATE_LIMIT: emptyString(zod.union([zod.literal('1'), zod.literal('0')]).optional()),
SUPERTOKENS_RATE_LIMIT_IP_HEADER_NAME: emptyString(zod.string().optional()),
SUPERTOKENS_RATE_LIMIT_BYPASS_KEY: emptyString(zod.string().optional()),
}),
zod.object({
SUPERTOKENS_AT_HOME: zod.literal('1'),
@ -113,6 +114,7 @@ const SuperTokensModel = zod.union([
SUPERTOKENS_ACCESS_TOKEN_KEY: zod.string(),
SUPERTOKENS_RATE_LIMIT: emptyString(zod.union([zod.literal('1'), zod.literal('0')]).optional()),
SUPERTOKENS_RATE_LIMIT_IP_HEADER_NAME: emptyString(zod.string().optional()),
SUPERTOKENS_RATE_LIMIT_BYPASS_KEY: emptyString(zod.string().optional()),
}),
]);
@ -455,6 +457,7 @@ export const env = {
: {
ipHeaderName:
supertokens.SUPERTOKENS_RATE_LIMIT_IP_HEADER_NAME ?? 'CF-Connecting-IP',
bypassKey: supertokens.SUPERTOKENS_RATE_LIMIT_BYPASS_KEY ?? null,
},
}
: {
@ -467,6 +470,7 @@ export const env = {
: {
ipHeaderName:
supertokens.SUPERTOKENS_RATE_LIMIT_IP_HEADER_NAME ?? 'CF-Connecting-IP',
bypassKey: supertokens.SUPERTOKENS_RATE_LIMIT_BYPASS_KEY ?? null,
},
},
auth: {

View file

@ -460,7 +460,7 @@ export async function registerSupertokensAtHome(
return rep.status(401).send();
}
const refreshToken = req.cookies['sRefreshToken'] ?? null;
const refreshToken = req.cookies?.['sRefreshToken'] ?? null;
if (!refreshToken) {
req.log.debug('No refresh token provided.');