mirror of
https://codeberg.org/yeentown/barkey.git
synced 2025-07-07 20:44:34 +00:00
add rate limit for URL preview
This commit is contained in:
parent
f8c53466ef
commit
bede498798
1 changed files with 61 additions and 11 deletions
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue