add rate limit for URL preview

This commit is contained in:
Hazelnoot 2025-05-19 17:58:35 -04:00
parent f8c53466ef
commit bede498798

View file

@ -19,12 +19,16 @@ import { MiMeta } from '@/models/Meta.js';
import { RedisKVCache } from '@/misc/cache.js'; import { RedisKVCache } from '@/misc/cache.js';
import { UtilityService } from '@/core/UtilityService.js'; import { UtilityService } from '@/core/UtilityService.js';
import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.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 { ApUtilityService } from '@/core/activitypub/ApUtilityService.js';
import { ApRequestService } from '@/core/activitypub/ApRequestService.js'; import { ApRequestService } from '@/core/activitypub/ApRequestService.js';
import { SystemAccountService } from '@/core/SystemAccountService.js'; import { SystemAccountService } from '@/core/SystemAccountService.js';
import { ApNoteService } from '@/core/activitypub/models/ApNoteService.js'; import { ApNoteService } from '@/core/activitypub/models/ApNoteService.js';
import { AuthenticateService, AuthenticationError } from '@/server/api/AuthenticateService.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'; import type { FastifyRequest, FastifyReply } from 'fastify';
export type LocalSummalyResult = SummalyResult & { 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<BucketRateLimit> = {
key: '/url',
type: 'bucket',
size: 50,
dripSize: 2,
dripRate: 200,
};
@Injectable() @Injectable()
export class UrlPreviewService { export class UrlPreviewService {
private logger: Logger; private logger: Logger;
@ -70,6 +85,7 @@ export class UrlPreviewService {
private readonly systemAccountService: SystemAccountService, private readonly systemAccountService: SystemAccountService,
private readonly apNoteService: ApNoteService, private readonly apNoteService: ApNoteService,
private readonly authenticateService: AuthenticateService, private readonly authenticateService: AuthenticateService,
private readonly rateLimiterService: SkRateLimiterService,
) { ) {
this.logger = this.loggerService.getLogger('url-preview'); this.logger = this.loggerService.getLogger('url-preview');
this.previewCache = new RedisKVCache<LocalSummalyResult>(this.redisClient, 'summaly', { this.previewCache = new RedisKVCache<LocalSummalyResult>(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)) { if (this.utilityService.isBlockedHost(this.meta.blockedHosts, new URL(url).host)) {
return reply.code(403).send({ return reply.code(403).send({
error: { error: {
@ -133,7 +155,7 @@ export class UrlPreviewService {
} }
const fetch = !!request.query.fetch; const fetch = !!request.query.fetch;
if (fetch && !await this.hasFetchPermissions(request, reply)) { if (fetch && !await this.checkFetchPermissions(auth, reply)) {
return; return;
} }
@ -347,7 +369,7 @@ export class UrlPreviewService {
} }
// Adapted from ApiCallService // Adapted from ApiCallService
private async hasFetchPermissions(request: FastifyRequest<{ Querystring?: { i?: string | string[] }, Body?: { i?: string | string[] } }>, reply: FastifyReply): Promise<boolean> { private async authenticate(request: FastifyRequest<{ Querystring?: { i?: string | string[] }, Body?: { i?: string | string[] } }>): Promise<AuthArray> {
const body = request.method === 'GET' ? request.query : request.body; const body = request.method === 'GET' ? request.query : request.body;
// https://datatracker.ietf.org/doc/html/rfc6750.html#section-2.1 (case sensitive) // 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) ? request.headers.authorization.slice(7)
: body?.['i']; : body?.['i'];
if (token != null && typeof token !== 'string') { if (token != null && typeof token !== 'string') {
reply.code(400); return [undefined, undefined, getIpHash(request.ip)];
return false;
} }
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) { if (err instanceof AuthenticationError) {
return null; return [undefined, undefined, getIpHash(request.ip)];
} else { } else {
throw err; throw err;
} }
}); }
}
// Adapted from ApiCallService
private async checkFetchPermissions(auth: AuthArray, reply: FastifyReply): Promise<boolean> {
const [user, app] = auth;
// Authentication // Authentication
if (!auth) { if (user === undefined) {
reply.code(401).send({ reply.code(401).send({
error: { error: {
message: 'Authentication failed. Please ensure your token is correct.', message: 'Authentication failed. Please ensure your token is correct.',
@ -378,8 +407,7 @@ export class UrlPreviewService {
}); });
return false; return false;
} }
const [user, app] = auth; if (user === null) {
if (user == null) {
reply.code(401).send({ reply.code(401).send({
error: { error: {
message: 'Credential required.', message: 'Credential required.',
@ -397,6 +425,7 @@ export class UrlPreviewService {
message: 'Your account has been suspended.', message: 'Your account has been suspended.',
code: 'YOUR_ACCOUNT_SUSPENDED', code: 'YOUR_ACCOUNT_SUSPENDED',
kind: 'permission', kind: 'permission',
id: 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370', id: 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370',
}, },
}); });
@ -416,4 +445,25 @@ export class UrlPreviewService {
return true; return true;
} }
private async checkRateLimit(auth: AuthArray, reply: FastifyReply): Promise<boolean> {
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;
}
} }