summaryrefslogtreecommitdiff
path: root/packages/backend/src/server
diff options
context:
space:
mode:
authorHazelnoot <acomputerdog@gmail.com>2025-05-19 17:58:35 -0400
committerHazelnoot <acomputerdog@gmail.com>2025-05-19 17:58:35 -0400
commitbede49879826a626b42e9e9046346f7bb2bc191d (patch)
tree413e65f91aef659aba0e54b884ffd62bc487d2a1 /packages/backend/src/server
parentmake sure that the "fetch linked note" button actually remembers that the not... (diff)
downloadsharkey-bede49879826a626b42e9e9046346f7bb2bc191d.tar.gz
sharkey-bede49879826a626b42e9e9046346f7bb2bc191d.tar.bz2
sharkey-bede49879826a626b42e9e9046346f7bb2bc191d.zip
add rate limit for URL preview
Diffstat (limited to 'packages/backend/src/server')
-rw-r--r--packages/backend/src/server/web/UrlPreviewService.ts72
1 files changed, 61 insertions, 11 deletions
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<BucketRateLimit> = {
+ 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<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)) {
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<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;
// 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<boolean> {
+ 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<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;
+ }
}