diff --git a/packages/backend/src/server/web/UrlPreviewService.ts b/packages/backend/src/server/web/UrlPreviewService.ts index 737cf9e536..a5254b5b40 100644 --- a/packages/backend/src/server/web/UrlPreviewService.ts +++ b/packages/backend/src/server/web/UrlPreviewService.ts @@ -19,12 +19,16 @@ import { MiMeta } from '@/models/Meta.js'; import { RedisKVCache } from '@/misc/cache.js'; import { UtilityService } from '@/core/UtilityService.js'; import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js'; -import type { NotesRepository } from '@/models/_.js'; +import type { MiAccessToken, NotesRepository } from '@/models/_.js'; import { ApUtilityService } from '@/core/activitypub/ApUtilityService.js'; import { ApRequestService } from '@/core/activitypub/ApRequestService.js'; import { SystemAccountService } from '@/core/SystemAccountService.js'; import { ApNoteService } from '@/core/activitypub/models/ApNoteService.js'; import { AuthenticateService, AuthenticationError } from '@/server/api/AuthenticateService.js'; +import { SkRateLimiterService } from '@/server/SkRateLimiterService.js'; +import { BucketRateLimit, Keyed, sendRateLimitHeaders } from '@/misc/rate-limit-utils.js'; +import type { MiLocalUser } from '@/models/User.js'; +import { getIpHash } from '@/misc/get-ip-hash.js'; import type { FastifyRequest, FastifyReply } from 'fastify'; export type LocalSummalyResult = SummalyResult & { @@ -43,6 +47,17 @@ type PreviewRoute = { }, }; +type AuthArray = [user: MiLocalUser | null | undefined, app: MiAccessToken | null | undefined, actor: MiLocalUser | string]; + +// Up to 50 requests, then 10 / second (at 2 / 200ms rate) +const previewLimit: Keyed = { + key: '/url', + type: 'bucket', + size: 50, + dripSize: 2, + dripRate: 200, +}; + @Injectable() export class UrlPreviewService { private logger: Logger; @@ -70,6 +85,7 @@ export class UrlPreviewService { private readonly systemAccountService: SystemAccountService, private readonly apNoteService: ApNoteService, private readonly authenticateService: AuthenticateService, + private readonly rateLimiterService: SkRateLimiterService, ) { this.logger = this.loggerService.getLogger('url-preview'); this.previewCache = new RedisKVCache(this.redisClient, 'summaly', { @@ -122,6 +138,12 @@ export class UrlPreviewService { }); } + // Check rate limit + const auth = await this.authenticate(request); + if (!await this.checkRateLimit(auth, reply)) { + return; + } + if (this.utilityService.isBlockedHost(this.meta.blockedHosts, new URL(url).host)) { return reply.code(403).send({ error: { @@ -133,7 +155,7 @@ export class UrlPreviewService { } const fetch = !!request.query.fetch; - if (fetch && !await this.hasFetchPermissions(request, reply)) { + if (fetch && !await this.checkFetchPermissions(auth, reply)) { return; } @@ -347,7 +369,7 @@ export class UrlPreviewService { } // Adapted from ApiCallService - private async hasFetchPermissions(request: FastifyRequest<{ Querystring?: { i?: string | string[] }, Body?: { i?: string | string[] } }>, reply: FastifyReply): Promise { + private async authenticate(request: FastifyRequest<{ Querystring?: { i?: string | string[] }, Body?: { i?: string | string[] } }>): Promise { const body = request.method === 'GET' ? request.query : request.body; // https://datatracker.ietf.org/doc/html/rfc6750.html#section-2.1 (case sensitive) @@ -355,20 +377,27 @@ export class UrlPreviewService { ? request.headers.authorization.slice(7) : body?.['i']; if (token != null && typeof token !== 'string') { - reply.code(400); - return false; + return [undefined, undefined, getIpHash(request.ip)]; } - const auth = await this.authenticateService.authenticate(token).catch(async (err) => { + try { + const auth = await this.authenticateService.authenticate(token); + return [auth[0], auth[1], auth[0] ?? getIpHash(request.ip)]; + } catch (err) { if (err instanceof AuthenticationError) { - return null; + return [undefined, undefined, getIpHash(request.ip)]; } else { throw err; } - }); + } + } + + // Adapted from ApiCallService + private async checkFetchPermissions(auth: AuthArray, reply: FastifyReply): Promise { + const [user, app] = auth; // Authentication - if (!auth) { + if (user === undefined) { reply.code(401).send({ error: { message: 'Authentication failed. Please ensure your token is correct.', @@ -378,8 +407,7 @@ export class UrlPreviewService { }); return false; } - const [user, app] = auth; - if (user == null) { + if (user === null) { reply.code(401).send({ error: { message: 'Credential required.', @@ -397,6 +425,7 @@ export class UrlPreviewService { message: 'Your account has been suspended.', code: 'YOUR_ACCOUNT_SUSPENDED', kind: 'permission', + id: 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370', }, }); @@ -416,4 +445,25 @@ export class UrlPreviewService { return true; } + + private async checkRateLimit(auth: AuthArray, reply: FastifyReply): Promise { + const info = await this.rateLimiterService.limit(previewLimit, auth[2]); + + // Always send headers, even if not blocked + sendRateLimitHeaders(reply, info); + + if (info.blocked) { + reply.code(429).send({ + error: { + message: 'Rate limit exceeded. Please try again later.', + code: 'RATE_LIMIT_EXCEEDED', + id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef', + }, + }); + + return false; + } + + return true; + } }