summaryrefslogtreecommitdiff
path: root/packages/backend/src/server/api
diff options
context:
space:
mode:
authorHazelnoot <acomputerdog@gmail.com>2025-02-08 13:16:17 -0500
committerHazelnoot <acomputerdog@gmail.com>2025-02-08 13:16:37 -0500
commit7e1b4b259a4efd383dac368178a9c4ed0cd9fc20 (patch)
tree0068429295e31528d356c74a8dcae28bb04bae42 /packages/backend/src/server/api
parentMerge remote-tracking branch 'fEmber/merge/2024-02-03' into merge/2024-02-03 (diff)
parentmerge: Rework rate limit factors and add caching (resolves #884) (!884) (diff)
downloadsharkey-7e1b4b259a4efd383dac368178a9c4ed0cd9fc20.tar.gz
sharkey-7e1b4b259a4efd383dac368178a9c4ed0cd9fc20.tar.bz2
sharkey-7e1b4b259a4efd383dac368178a9c4ed0cd9fc20.zip
Merge branch 'develop' into merge/2024-02-03
# Conflicts: # packages/backend/src/server/ActivityPubServerService.ts # pnpm-lock.yaml
Diffstat (limited to 'packages/backend/src/server/api')
-rw-r--r--packages/backend/src/server/api/ApiCallService.ts39
-rw-r--r--packages/backend/src/server/api/SigninApiService.ts13
-rw-r--r--packages/backend/src/server/api/SigninWithPasskeyApiService.ts2
-rw-r--r--packages/backend/src/server/api/SkRateLimiterService.ts253
-rw-r--r--packages/backend/src/server/api/StreamingApiServerService.ts18
5 files changed, 34 insertions, 291 deletions
diff --git a/packages/backend/src/server/api/ApiCallService.ts b/packages/backend/src/server/api/ApiCallService.ts
index 03f25a51fe..5ce358d68f 100644
--- a/packages/backend/src/server/api/ApiCallService.ts
+++ b/packages/backend/src/server/api/ApiCallService.ts
@@ -19,7 +19,7 @@ import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js';
import type { Config } from '@/config.js';
import { sendRateLimitHeaders } from '@/misc/rate-limit-utils.js';
-import { SkRateLimiterService } from '@/server/api/SkRateLimiterService.js';
+import { SkRateLimiterService } from '@/server/SkRateLimiterService.js';
import { ApiError } from './error.js';
import { ApiLoggerService } from './ApiLoggerService.js';
import { AuthenticateService, AuthenticationError } from './AuthenticateService.js';
@@ -313,35 +313,30 @@ export class ApiCallService implements OnApplicationShutdown {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (endpointLimit) {
// koa will automatically load the `X-Forwarded-For` header if `proxy: true` is configured in the app.
- let limitActor: string;
+ let limitActor: string | MiLocalUser;
if (user) {
- limitActor = user.id;
+ limitActor = user;
} else {
limitActor = getIpHash(request.ip);
}
- // TODO: 毎リクエスト計算するのもあれだしキャッシュしたい
- const factor = user ? (await this.roleService.getUserPolicies(user.id)).rateLimitFactor : 1;
+ const limit = {
+ key: ep.name,
+ ...endpointLimit,
+ };
- if (factor > 0) {
- const limit = {
- key: ep.name,
- ...endpointLimit,
- };
+ // Rate limit
+ const info = await this.rateLimiterService.limit(limit, limitActor);
- // Rate limit
- const info = await this.rateLimiterService.limit(limit, limitActor, factor);
+ sendRateLimitHeaders(reply, info);
- sendRateLimitHeaders(reply, info);
-
- if (info.blocked) {
- throw new ApiError({
- message: 'Rate limit exceeded. Please try again later.',
- code: 'RATE_LIMIT_EXCEEDED',
- id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef',
- httpStatusCode: 429,
- }, info);
- }
+ if (info.blocked) {
+ throw new ApiError({
+ message: 'Rate limit exceeded. Please try again later.',
+ code: 'RATE_LIMIT_EXCEEDED',
+ id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef',
+ httpStatusCode: 429,
+ }, info);
}
}
diff --git a/packages/backend/src/server/api/SigninApiService.ts b/packages/backend/src/server/api/SigninApiService.ts
index fa9155d82d..72712bce60 100644
--- a/packages/backend/src/server/api/SigninApiService.ts
+++ b/packages/backend/src/server/api/SigninApiService.ts
@@ -26,12 +26,19 @@ import { UserAuthService } from '@/core/UserAuthService.js';
import { CaptchaService } from '@/core/CaptchaService.js';
import { FastifyReplyError } from '@/misc/fastify-reply-error.js';
import { isSystemAccount } from '@/misc/is-system-account.js';
-import { SkRateLimiterService } from '@/server/api/SkRateLimiterService.js';
-import { sendRateLimitHeaders } from '@/misc/rate-limit-utils.js';
+import { SkRateLimiterService } from '@/server/SkRateLimiterService.js';
+import { Keyed, RateLimit, sendRateLimitHeaders } from '@/misc/rate-limit-utils.js';
import { SigninService } from './SigninService.js';
import type { AuthenticationResponseJSON } from '@simplewebauthn/types';
import type { FastifyReply, FastifyRequest } from 'fastify';
+// Up to 10 attempts, then 1 per minute
+const signinRateLimit: Keyed<RateLimit> = {
+ key: 'signin',
+ max: 10,
+ dripRate: 1000 * 60,
+};
+
@Injectable()
export class SigninApiService {
constructor(
@@ -94,7 +101,7 @@ export class SigninApiService {
}
// not more than 1 attempt per second and not more than 10 attempts per hour
- const rateLimit = await this.rateLimiterService.limit({ key: 'signin', duration: 60 * 60 * 1000, max: 10, minInterval: 1000 }, getIpHash(request.ip));
+ const rateLimit = await this.rateLimiterService.limit(signinRateLimit, getIpHash(request.ip));
sendRateLimitHeaders(reply, rateLimit);
diff --git a/packages/backend/src/server/api/SigninWithPasskeyApiService.ts b/packages/backend/src/server/api/SigninWithPasskeyApiService.ts
index e94d2b6b68..f84f50523b 100644
--- a/packages/backend/src/server/api/SigninWithPasskeyApiService.ts
+++ b/packages/backend/src/server/api/SigninWithPasskeyApiService.ts
@@ -21,7 +21,7 @@ import { WebAuthnService } from '@/core/WebAuthnService.js';
import Logger from '@/logger.js';
import { LoggerService } from '@/core/LoggerService.js';
import type { IdentifiableError } from '@/misc/identifiable-error.js';
-import { SkRateLimiterService } from '@/server/api/SkRateLimiterService.js';
+import { SkRateLimiterService } from '@/server/SkRateLimiterService.js';
import { sendRateLimitHeaders } from '@/misc/rate-limit-utils.js';
import { SigninService } from './SigninService.js';
import type { AuthenticationResponseJSON } from '@simplewebauthn/types';
diff --git a/packages/backend/src/server/api/SkRateLimiterService.ts b/packages/backend/src/server/api/SkRateLimiterService.ts
deleted file mode 100644
index 38c97b63df..0000000000
--- a/packages/backend/src/server/api/SkRateLimiterService.ts
+++ /dev/null
@@ -1,253 +0,0 @@
-/*
- * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { Inject, Injectable } from '@nestjs/common';
-import Redis from 'ioredis';
-import { TimeService } from '@/core/TimeService.js';
-import { EnvService } from '@/core/EnvService.js';
-import { BucketRateLimit, LegacyRateLimit, LimitInfo, RateLimit, hasMinLimit, isLegacyRateLimit, Keyed, hasMaxLimit, disabledLimitInfo, MaxLegacyLimit, MinLegacyLimit } from '@/misc/rate-limit-utils.js';
-import { DI } from '@/di-symbols.js';
-
-@Injectable()
-export class SkRateLimiterService {
- private readonly disabled: boolean;
-
- constructor(
- @Inject(TimeService)
- private readonly timeService: TimeService,
-
- @Inject(DI.redis)
- private readonly redisClient: Redis.Redis,
-
- @Inject(EnvService)
- envService: EnvService,
- ) {
- this.disabled = envService.env.NODE_ENV === 'test';
- }
-
- /**
- * Check & increment a rate limit
- * @param limit The limit definition
- * @param actor Client who is calling this limit
- * @param factor Scaling factor - smaller = larger limit (less restrictive)
- */
- public async limit(limit: Keyed<RateLimit>, actor: string, factor = 1): Promise<LimitInfo> {
- if (this.disabled || factor === 0) {
- return disabledLimitInfo;
- }
-
- if (factor < 0) {
- throw new Error(`Rate limit factor is zero or negative: ${factor}`);
- }
-
- return await this.tryLimit(limit, actor, factor);
- }
-
- private async tryLimit(limit: Keyed<RateLimit>, actor: string, factor: number): Promise<LimitInfo> {
- if (isLegacyRateLimit(limit)) {
- return await this.limitLegacy(limit, actor, factor);
- } else {
- return await this.limitBucket(limit, actor, factor);
- }
- }
-
- private async limitLegacy(limit: Keyed<LegacyRateLimit>, actor: string, factor: number): Promise<LimitInfo> {
- if (hasMaxLimit(limit)) {
- return await this.limitLegacyMinMax(limit, actor, factor);
- } else if (hasMinLimit(limit)) {
- return await this.limitLegacyMinOnly(limit, actor, factor);
- } else {
- return disabledLimitInfo;
- }
- }
-
- private async limitLegacyMinMax(limit: Keyed<MaxLegacyLimit>, actor: string, factor: number): Promise<LimitInfo> {
- if (limit.duration === 0) return disabledLimitInfo;
- if (limit.duration < 0) throw new Error(`Invalid rate limit ${limit.key}: duration is negative (${limit.duration})`);
- if (limit.max < 1) throw new Error(`Invalid rate limit ${limit.key}: max is less than 1 (${limit.max})`);
-
- // Derive initial dripRate from minInterval OR duration/max.
- const initialDripRate = Math.max(limit.minInterval ?? Math.round(limit.duration / limit.max), 1);
-
- // Calculate dripSize to reach max at exactly duration
- const dripSize = Math.max(Math.round(limit.max / (limit.duration / initialDripRate)), 1);
-
- // Calculate final dripRate from dripSize and duration/max
- const dripRate = Math.max(Math.round(limit.duration / (limit.max / dripSize)), 1);
-
- const bucketLimit: Keyed<BucketRateLimit> = {
- type: 'bucket',
- key: limit.key,
- size: limit.max,
- dripRate,
- dripSize,
- };
- return await this.limitBucket(bucketLimit, actor, factor);
- }
-
- private async limitLegacyMinOnly(limit: Keyed<MinLegacyLimit>, actor: string, factor: number): Promise<LimitInfo> {
- if (limit.minInterval === 0) return disabledLimitInfo;
- if (limit.minInterval < 0) throw new Error(`Invalid rate limit ${limit.key}: minInterval is negative (${limit.minInterval})`);
-
- const dripRate = Math.max(Math.round(limit.minInterval), 1);
- const bucketLimit: Keyed<BucketRateLimit> = {
- type: 'bucket',
- key: limit.key,
- size: 1,
- dripRate,
- dripSize: 1,
- };
- return await this.limitBucket(bucketLimit, actor, factor);
- }
-
- /**
- * Implementation of Leaky Bucket rate limiting - see SkRateLimiterService.md for details.
- */
- private async limitBucket(limit: Keyed<BucketRateLimit>, actor: string, factor: number): Promise<LimitInfo> {
- if (limit.size < 1) throw new Error(`Invalid rate limit ${limit.key}: size is less than 1 (${limit.size})`);
- if (limit.dripRate != null && limit.dripRate < 1) throw new Error(`Invalid rate limit ${limit.key}: dripRate is less than 1 (${limit.dripRate})`);
- if (limit.dripSize != null && limit.dripSize < 1) throw new Error(`Invalid rate limit ${limit.key}: dripSize is less than 1 (${limit.dripSize})`);
-
- // 0 - Calculate
- const now = this.timeService.now;
- const bucketSize = Math.max(Math.ceil(limit.size / factor), 1);
- const dripRate = Math.ceil(limit.dripRate ?? 1000);
- const dripSize = Math.ceil(limit.dripSize ?? 1);
- const expirationSec = Math.max(Math.ceil((dripRate * Math.ceil(bucketSize / dripSize)) / 1000), 1);
-
- // 1 - Read
- const counterKey = createLimitKey(limit, actor, 'c');
- const timestampKey = createLimitKey(limit, actor, 't');
- const counter = await this.getLimitCounter(counterKey, timestampKey);
-
- // 2 - Drip
- const dripsSinceLastTick = Math.floor((now - counter.timestamp) / dripRate) * dripSize;
- const deltaCounter = Math.min(dripsSinceLastTick, counter.counter);
- const deltaTimestamp = dripsSinceLastTick * dripRate;
- if (deltaCounter > 0) {
- // Execute the next drip(s)
- const results = await this.executeRedisMulti(
- ['get', timestampKey],
- ['incrby', timestampKey, deltaTimestamp],
- ['expire', timestampKey, expirationSec],
- ['get', timestampKey],
- ['decrby', counterKey, deltaCounter],
- ['expire', counterKey, expirationSec],
- ['get', counterKey],
- );
- const expectedTimestamp = counter.timestamp;
- const canaryTimestamp = results[0] ? parseInt(results[0]) : 0;
- counter.timestamp = results[3] ? parseInt(results[3]) : 0;
- counter.counter = results[6] ? parseInt(results[6]) : 0;
-
- // Check for a data collision and rollback
- if (canaryTimestamp !== expectedTimestamp) {
- const rollbackResults = await this.executeRedisMulti(
- ['decrby', timestampKey, deltaTimestamp],
- ['get', timestampKey],
- ['incrby', counterKey, deltaCounter],
- ['get', counterKey],
- );
- counter.timestamp = rollbackResults[1] ? parseInt(rollbackResults[1]) : 0;
- counter.counter = rollbackResults[3] ? parseInt(rollbackResults[3]) : 0;
- }
- }
-
- // 3 - Check
- const blocked = counter.counter >= bucketSize;
- if (!blocked) {
- if (counter.timestamp === 0) {
- const results = await this.executeRedisMulti(
- ['set', timestampKey, now],
- ['expire', timestampKey, expirationSec],
- ['incr', counterKey],
- ['expire', counterKey, expirationSec],
- ['get', counterKey],
- );
- counter.timestamp = now;
- counter.counter = results[4] ? parseInt(results[4]) : 0;
- } else {
- const results = await this.executeRedisMulti(
- ['incr', counterKey],
- ['expire', counterKey, expirationSec],
- ['get', counterKey],
- );
- counter.counter = results[2] ? parseInt(results[2]) : 0;
- }
- }
-
- // Calculate how much time is needed to free up a bucket slot
- const overflow = Math.max((counter.counter + 1) - bucketSize, 0);
- const dripsNeeded = Math.ceil(overflow / dripSize);
- const timeNeeded = Math.max((dripRate * dripsNeeded) - (this.timeService.now - counter.timestamp), 0);
-
- // Calculate limit status
- const remaining = Math.max(bucketSize - counter.counter, 0);
- const resetMs = timeNeeded;
- const resetSec = Math.ceil(resetMs / 1000);
- const fullResetMs = Math.ceil(counter.counter / dripSize) * dripRate;
- const fullResetSec = Math.ceil(fullResetMs / 1000);
- return { blocked, remaining, resetSec, resetMs, fullResetSec, fullResetMs };
- }
-
- private async getLimitCounter(counterKey: string, timestampKey: string): Promise<LimitCounter> {
- const [counter, timestamp] = await this.executeRedisMulti(
- ['get', counterKey],
- ['get', timestampKey],
- );
-
- return {
- counter: counter ? parseInt(counter) : 0,
- timestamp: timestamp ? parseInt(timestamp) : 0,
- };
- }
-
- private async executeRedisMulti(...batch: RedisCommand[]): Promise<RedisResult[]> {
- const results = await this.redisClient.multi(batch).exec();
-
- // Transaction conflict (retryable)
- if (!results) {
- throw new ConflictError('Redis error: transaction conflict');
- }
-
- // Transaction failed (fatal)
- if (results.length !== batch.length) {
- throw new Error('Redis error: failed to execute batch');
- }
-
- // Map responses
- const errors: Error[] = [];
- const responses: RedisResult[] = [];
- for (const [error, response] of results) {
- if (error) errors.push(error);
- responses.push(response as RedisResult);
- }
-
- // Command failed (fatal)
- if (errors.length > 0) {
- const errorMessages = errors
- .map((e, i) => `Error in command ${i}: ${e}`)
- .join('\', \'');
- throw new AggregateError(errors, `Redis error: failed to execute command(s): '${errorMessages}'`);
- }
-
- return responses;
- }
-}
-
-// Not correct, but good enough for the basic commands we use.
-type RedisResult = string | null;
-type RedisCommand = [command: string, ...args: unknown[]];
-
-function createLimitKey(limit: Keyed<RateLimit>, actor: string, value: string): string {
- return `rl_${actor}_${limit.key}_${value}`;
-}
-
-class ConflictError extends Error {}
-
-interface LimitCounter {
- timestamp: number;
- counter: number;
-}
diff --git a/packages/backend/src/server/api/StreamingApiServerService.ts b/packages/backend/src/server/api/StreamingApiServerService.ts
index e3fd1312ae..6e7abcfae6 100644
--- a/packages/backend/src/server/api/StreamingApiServerService.ts
+++ b/packages/backend/src/server/api/StreamingApiServerService.ts
@@ -18,10 +18,9 @@ import { CacheService } from '@/core/CacheService.js';
import { MiLocalUser } from '@/models/User.js';
import { UserService } from '@/core/UserService.js';
import { ChannelFollowingService } from '@/core/ChannelFollowingService.js';
-import { RoleService } from '@/core/RoleService.js';
import { getIpHash } from '@/misc/get-ip-hash.js';
import { LoggerService } from '@/core/LoggerService.js';
-import { SkRateLimiterService } from '@/server/api/SkRateLimiterService.js';
+import { SkRateLimiterService } from '@/server/SkRateLimiterService.js';
import { AuthenticateService, AuthenticationError } from './AuthenticateService.js';
import MainStreamConnection from './stream/Connection.js';
import { ChannelsService } from './stream/ChannelsService.js';
@@ -49,7 +48,6 @@ export class StreamingApiServerService {
private usersService: UserService,
private channelFollowingService: ChannelFollowingService,
private rateLimiterService: SkRateLimiterService,
- private roleService: RoleService,
private loggerService: LoggerService,
) {
}
@@ -57,22 +55,18 @@ export class StreamingApiServerService {
@bindThis
private async rateLimitThis(
user: MiLocalUser | null | undefined,
- requestIp: string | undefined,
+ requestIp: string,
limit: IEndpointMeta['limit'] & { key: NonNullable<string> },
) : Promise<boolean> {
- let limitActor: string;
+ let limitActor: string | MiLocalUser;
if (user) {
- limitActor = user.id;
+ limitActor = user;
} else {
- limitActor = getIpHash(requestIp || 'wtf');
+ limitActor = getIpHash(requestIp);
}
- const factor = user ? (await this.roleService.getUserPolicies(user.id)).rateLimitFactor : 1;
-
- if (factor <= 0) return false;
-
// Rate limit
- const rateLimit = await this.rateLimiterService.limit(limit, limitActor, factor);
+ const rateLimit = await this.rateLimiterService.limit(limit, limitActor);
return rateLimit.blocked;
}