Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: RateLimiterService #13997

Open
wants to merge 16 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
- Fix: アンテナ・クリップ・リスト・ウェブフックがロールポリシーの上限より一つ多く作れてしまうのを修正 (#14036)
- Fix: notRespondingSinceが実装される前に不通になったインスタンスが自動的に配信停止にならない (#14059)
- Fix: FTT有効時、タイムライン用エンドポイントで`sinceId`にキャッシュ内最古のものより古いものを指定した場合に正しく結果が返ってこない問題を修正
- Fix: レートリミットのfactorが二回適用されて二乗の効果がある問題を修正 (#13997)

## 2024.5.0

Expand Down
22 changes: 9 additions & 13 deletions packages/backend/src/server/api/ApiCallService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,19 +317,15 @@ export class ApiCallService implements OnApplicationShutdown {

if (factor > 0) {
// Rate limit
await this.rateLimiterService.limit(limit as IEndpointMeta['limit'] & { key: NonNullable<string> }, limitActor, factor).catch(err => {
if ('info' in err) {
// errはLimiter.LimiterInfoであることが期待される
throw new ApiError({
message: 'Rate limit exceeded. Please try again later.',
code: 'RATE_LIMIT_EXCEEDED',
id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef',
httpStatusCode: 429,
}, err.info);
} else {
throw new TypeError('information must be a rate-limiter information.');
}
});
const rateLimit = await this.rateLimiterService.limit(limit as IEndpointMeta['limit'] & { key: NonNullable<string> }, limitActor, factor);
if (rateLimit != null) {
throw new ApiError({
message: 'Rate limit exceeded. Please try again later.',
code: 'RATE_LIMIT_EXCEEDED',
id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef',
httpStatusCode: 429,
}, rateLimit.info);
}
}
}

Expand Down
110 changes: 49 additions & 61 deletions packages/backend/src/server/api/RateLimiterService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@
import { bindThis } from '@/decorators.js';
import type { IEndpointMeta } from './endpoints.js';

type RateLimitInfo = {
code: 'BRIEF_REQUEST_INTERVAL',
info: Limiter.LimiterInfo,
} | {
code: 'RATE_LIMIT_EXCEEDED',
info: Limiter.LimiterInfo,
}

@Injectable()
export class RateLimiterService {
private logger: Logger;
Expand All @@ -31,77 +39,57 @@
}

@bindThis
public limit(limitation: IEndpointMeta['limit'] & { key: NonNullable<string> }, actor: string, factor = 1) {
{
if (this.disabled) {
return Promise.resolve();
}

// Short-term limit
const min = new Promise<void>((ok, reject) => {
const minIntervalLimiter = new Limiter({
id: `${actor}:${limitation.key}:min`,
duration: limitation.minInterval! * factor,
max: 1,
db: this.redisClient,
});

minIntervalLimiter.get((err, info) => {
if (err) {
return reject({ code: 'ERR', info });
}
private checkLimiter(options: Limiter.LimiterOption): Promise<Limiter.LimiterInfo> {
return new Promise<Limiter.LimiterInfo>((resolve, reject) => {
new Limiter(options).get((err, info) => {
if (err) {
return reject(err);
}
resolve(info);
});
});
}

this.logger.debug(`${actor} ${limitation.key} min remaining: ${info.remaining}`);
@bindThis
public async limit(limitation: IEndpointMeta['limit'] & { key: NonNullable<string> }, actor: string, factor = 1): Promise<RateLimitInfo | null> {
if (this.disabled) {
return null;
}

if (info.remaining === 0) {
return reject({ code: 'BRIEF_REQUEST_INTERVAL', info });
} else {
if (hasLongTermLimit) {
return max.then(ok, reject);
} else {
return ok();
}
}
});
// Short-term limit
if (limitation.minInterval != null) {
const info = await this.checkLimiter({
id: `${actor}:${limitation.key}:min`,
duration: limitation.minInterval * factor,
max: 1,
db: this.redisClient,
});

// Long term limit
const max = new Promise<void>((ok, reject) => {
const limiter = new Limiter({
id: `${actor}:${limitation.key}`,
duration: limitation.duration! * factor,
max: limitation.max! / factor,
db: this.redisClient,
});
this.logger.debug(`${actor} ${limitation.key} min remaining: ${info.remaining}`);

limiter.get((err, info) => {
if (err) {
return reject({ code: 'ERR', info });
}

this.logger.debug(`${actor} ${limitation.key} max remaining: ${info.remaining}`);
if (info.remaining === 0) {
// eslint-disable-next-line no-throw-literal
return { code: 'BRIEF_REQUEST_INTERVAL', info };
}
}

if (info.remaining === 0) {
return reject({ code: 'RATE_LIMIT_EXCEEDED', info });
} else {
return ok();
}
});
// Long term limit
if (limitation.duration != null && limitation.max != null) {
const info = await this.checkLimiter({
id: `${actor}:${limitation.key}`,
duration: limitation.duration,
max: limitation.max / factor,
db: this.redisClient,
});

const hasShortTermLimit = typeof limitation.minInterval === 'number';

const hasLongTermLimit =
typeof limitation.duration === 'number' &&
typeof limitation.max === 'number';
this.logger.debug(`${actor} ${limitation.key} max remaining: ${info.remaining}`);

if (hasShortTermLimit) {
return min;
} else if (hasLongTermLimit) {
return max;
} else {
return Promise.resolve();
if (info.remaining === 0) {
// eslint-disable-next-line no-throw-literal
return { code: 'RATE_LIMIT_EXCEEDED', info };
}
}

return null

Check failure on line 93 in packages/backend/src/server/api/RateLimiterService.ts

View workflow job for this annotation

GitHub Actions / lint (backend)

Missing semicolon
}
}
5 changes: 2 additions & 3 deletions packages/backend/src/server/api/SigninApiService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,9 @@ export class SigninApiService {
return { error };
}

try {
// not more than 1 attempt per second and not more than 10 attempts per hour
await this.rateLimiterService.limit({ key: 'signin', duration: 60 * 60 * 1000, max: 10, minInterval: 1000 }, getIpHash(request.ip));
} catch (err) {
const rateLimit = await this.rateLimiterService.limit({ key: 'signin', duration: 60 * 60 * 1000, max: 10, minInterval: 1000 }, getIpHash(request.ip));
if (rateLimit != null) {
reply.code(429);
return {
error: {
Expand Down
Loading