diff options
| author | Julia <julia@insertdomain.name> | 2025-05-29 00:07:38 +0000 |
|---|---|---|
| committer | Julia <julia@insertdomain.name> | 2025-05-29 00:07:38 +0000 |
| commit | 6b554c178b81f13f83a69b19d44b72b282a0c119 (patch) | |
| tree | f5537f1a56323a4dd57ba150b3cb84a2d8b5dc63 /packages/backend/src/server/web/UrlPreviewService.ts | |
| parent | merge: Security fixes (!970) (diff) | |
| parent | bump version for release (diff) | |
| download | sharkey-6b554c178b81f13f83a69b19d44b72b282a0c119.tar.gz sharkey-6b554c178b81f13f83a69b19d44b72b282a0c119.tar.bz2 sharkey-6b554c178b81f13f83a69b19d44b72b282a0c119.zip | |
merge: release 2025.4.2 (!1051)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/1051
Approved-by: Hazelnoot <acomputerdog@gmail.com>
Approved-by: Marie <github@yuugi.dev>
Approved-by: Julia <julia@insertdomain.name>
Diffstat (limited to 'packages/backend/src/server/web/UrlPreviewService.ts')
| -rw-r--r-- | packages/backend/src/server/web/UrlPreviewService.ts | 434 |
1 files changed, 375 insertions, 59 deletions
diff --git a/packages/backend/src/server/web/UrlPreviewService.ts b/packages/backend/src/server/web/UrlPreviewService.ts index b8d7020598..203bc908a8 100644 --- a/packages/backend/src/server/web/UrlPreviewService.ts +++ b/packages/backend/src/server/web/UrlPreviewService.ts @@ -4,9 +4,10 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import { summaly } from '@transfem-org/summaly'; -import { SummalyResult } from '@transfem-org/summaly/built/summary.js'; +import { summaly } from '@misskey-dev/summaly'; +import { SummalyResult } from '@misskey-dev/summaly/built/summary.js'; import * as Redis from 'ioredis'; +import { IsNull, Not } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; @@ -14,33 +15,81 @@ import type Logger from '@/logger.js'; import { query } from '@/misc/prelude/url.js'; import { LoggerService } from '@/core/LoggerService.js'; import { bindThis } from '@/decorators.js'; -import { ApiError } from '@/server/api/error.js'; 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 { 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 { isRetryableError } from '@/misc/is-retryable-error.js'; import type { FastifyRequest, FastifyReply } from 'fastify'; +export type LocalSummalyResult = SummalyResult & { + haveNoteLocally?: boolean; +}; + +// Increment this to invalidate cached previews after a major change. +const cacheFormatVersion = 3; + +type PreviewRoute = { + Querystring: { + url?: string + lang?: string, + fetch?: string, + i?: string, + }, +}; + +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; - private previewCache: RedisKVCache<SummalyResult>; + private previewCache: RedisKVCache<LocalSummalyResult>; constructor( @Inject(DI.config) private config: Config, @Inject(DI.redis) - private redisClient: Redis.Redis, + private readonly redisClient: Redis.Redis, @Inject(DI.meta) - private meta: MiMeta, + private readonly meta: MiMeta, + + @Inject(DI.notesRepository) + private readonly notesRepository: NotesRepository, private httpRequestService: HttpRequestService, private loggerService: LoggerService, - private utilityService: UtilityService, + private readonly utilityService: UtilityService, + private readonly apUtilityService: ApUtilityService, + private readonly apDbResolverService: ApDbResolverService, + private readonly apRequestService: ApRequestService, + 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<SummalyResult>(this.redisClient, 'summaly', { + this.previewCache = new RedisKVCache<LocalSummalyResult>(this.redisClient, 'summaly', { lifetime: 1000 * 60 * 60 * 24, // 1d memoryCacheLifetime: 1000 * 60 * 10, // 10m fetcher: () => { throw new Error('the UrlPreview cache should never fetch'); }, @@ -51,19 +100,23 @@ export class UrlPreviewService { @bindThis private wrap(url?: string | null): string | null { - return url != null - ? `${this.config.mediaProxy}/preview.webp?${query({ - url, - preview: '1', - })}` - : null; + if (url == null) return null; + + // Don't proxy our own media + if (this.utilityService.isUriLocal(url)) { + return url; + } + + // But proxy everything else! + const mediaQuery = query({ url, preview: '1' }); + return `${this.config.mediaProxy}/preview.webp?${mediaQuery}`; } @bindThis public async handle( - request: FastifyRequest<{ Querystring: { url: string; lang?: string; } }>, + request: FastifyRequest<PreviewRoute>, reply: FastifyReply, - ): Promise<object | undefined> { + ): Promise<void> { const url = request.query.url; if (typeof url !== 'string' || !URL.canParse(url)) { reply.code(400); @@ -77,81 +130,139 @@ export class UrlPreviewService { } if (!this.meta.urlPreviewEnabled) { - reply.code(403); - return { - error: new ApiError({ + return reply.code(403).send({ + error: { message: 'URL preview is disabled', code: 'URL_PREVIEW_DISABLED', id: '58b36e13-d2f5-0323-b0c6-76aa9dabefb8', - }), - }; + }, + }); + } + + // Check rate limit + const auth = await this.authenticate(request); + if (!await this.checkRateLimit(auth, reply)) { + return; } - const host = new URL(url).host; - if (this.utilityService.isBlockedHost(this.meta.blockedHosts, host)) { - reply.code(403); - return { - error: new ApiError({ + if (this.utilityService.isBlockedHost(this.meta.blockedHosts, new URL(url).host)) { + return reply.code(403).send({ + error: { message: 'URL is blocked', code: 'URL_PREVIEW_BLOCKED', id: '50294652-857b-4b13-9700-8e5c7a8deae8', - }), - }; + }, + }); } - const key = `${url}@${lang}`; - const cached = await this.previewCache.get(key); - if (cached !== undefined) { - this.logger.info(`Returning cache preview of ${key}`); - // Cache 7days - reply.header('Cache-Control', 'max-age=604800, immutable'); - - return cached; + const fetch = !!request.query.fetch; + if (fetch && !await this.checkFetchPermissions(auth, reply)) { + return; } - this.logger.info(this.meta.urlPreviewSummaryProxyUrl - ? `(Proxy) Getting preview of ${key} ...` - : `Getting preview of ${key} ...`); + const cacheKey = `${url}@${lang}@${cacheFormatVersion}`; + if (await this.sendCachedPreview(cacheKey, reply, fetch)) { + return; + } try { - const summary = this.meta.urlPreviewSummaryProxyUrl + const summary: LocalSummalyResult = this.meta.urlPreviewSummaryProxyUrl ? await this.fetchSummaryFromProxy(url, this.meta, lang) : await this.fetchSummary(url, this.meta, lang); - this.logger.succ(`Got preview of ${url}: ${summary.title}`); + this.validateUrls(summary); - if (!(summary.url.startsWith('http://') || summary.url.startsWith('https://'))) { - throw new Error('unsupported schema included'); + // Repeat check, since redirects are allowed. + if (this.utilityService.isBlockedHost(this.meta.blockedHosts, new URL(summary.url).host)) { + return reply.code(403).send({ + error: { + message: 'URL is blocked', + code: 'URL_PREVIEW_BLOCKED', + id: '50294652-857b-4b13-9700-8e5c7a8deae8', + }, + }); } - if (summary.player.url && !(summary.player.url.startsWith('http://') || summary.player.url.startsWith('https://'))) { - throw new Error('unsupported schema included'); - } + this.logger.info(`Got preview of ${url} in ${lang}: ${summary.title}`); summary.icon = this.wrap(summary.icon); summary.thumbnail = this.wrap(summary.thumbnail); - this.previewCache.set(key, summary); + // Summaly cannot always detect links to a fedi post, so do some additional tests to try and find missed cases. + if (!summary.activityPub) { + await this.inferActivityPubLink(summary); + } + + if (summary.activityPub && !summary.haveNoteLocally) { + // Avoid duplicate checks in case inferActivityPubLink already set this. + const exists = await this.noteExists(summary.activityPub, fetch); - // Cache 7days - reply.header('Cache-Control', 'max-age=604800, immutable'); + // Remove the AP flag if we encounter a permanent error fetching the note. + if (exists === false) { + summary.activityPub = null; + summary.haveNoteLocally = undefined; + } else { + summary.haveNoteLocally = exists ?? false; + } + } + + // Await this to avoid hammering redis when a bunch of URLs are fetched at once + await this.previewCache.set(cacheKey, summary); + + // Cache 1 day (matching redis), but only once we finalize the result + if (!summary.activityPub || summary.haveNoteLocally) { + reply.header('Cache-Control', 'public, max-age=86400'); + } - return summary; + return reply.code(200).send(summary); } catch (err) { - this.logger.warn(`Failed to get preview of ${url}: ${err}`); + this.logger.warn(`Failed to get preview of ${url} for ${lang}: ${err}`); - reply.code(422); - reply.header('Cache-Control', 'max-age=86400, immutable'); - return { - error: new ApiError({ + reply.header('Cache-Control', 'max-age=3600'); + return reply.code(422).send({ + error: { message: 'Failed to get preview', code: 'URL_PREVIEW_FAILED', id: '09d01cb5-53b9-4856-82e5-38a50c290a3b', - }), - }; + }, + }); } } + private async sendCachedPreview(cacheKey: string, reply: FastifyReply, fetch: boolean): Promise<boolean> { + const summary = await this.previewCache.get(cacheKey); + if (summary === undefined) { + return false; + } + + // Check if note has loaded since we last cached the preview + if (summary.activityPub && !summary.haveNoteLocally) { + // Avoid duplicate checks in case inferActivityPubLink already set this. + const exists = await this.noteExists(summary.activityPub, fetch); + + // Remove the AP flag if we encounter a permanent error fetching the note. + if (exists === false) { + summary.activityPub = null; + summary.haveNoteLocally = undefined; + } else { + summary.haveNoteLocally = exists ?? false; + } + + // Persist the result once we finalize the result + if (!summary.activityPub || summary.haveNoteLocally) { + await this.previewCache.set(cacheKey, summary); + } + } + + // Cache 1 day (matching redis), but only once we finalize the result + if (!summary.activityPub || summary.haveNoteLocally) { + reply.header('Cache-Control', 'public, max-age=86400'); + } + + reply.code(200).send(summary); + return true; + } + private fetchSummary(url: string, meta: MiMeta, lang?: string): Promise<SummalyResult> { const agent = this.config.proxy ? { @@ -161,7 +272,7 @@ export class UrlPreviewService { : undefined; return summaly(url, { - followRedirects: false, + followRedirects: true, lang: lang ?? 'ja-JP', agent: agent, userAgent: meta.urlPreviewUserAgent ?? undefined, @@ -172,8 +283,10 @@ export class UrlPreviewService { } private fetchSummaryFromProxy(url: string, meta: MiMeta, lang?: string): Promise<SummalyResult> { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const proxy = meta.urlPreviewSummaryProxyUrl!; const queryStr = query({ + followRedirects: true, url: url, lang: lang ?? 'ja-JP', userAgent: meta.urlPreviewUserAgent ?? undefined, @@ -182,6 +295,209 @@ export class UrlPreviewService { contentLengthRequired: meta.urlPreviewRequireContentLength, }); - return this.httpRequestService.getJson<SummalyResult>(`${proxy}?${queryStr}`, 'application/json, */*', undefined, true); + return this.httpRequestService.getJson<LocalSummalyResult>(`${proxy}?${queryStr}`, 'application/json, */*', undefined, true); + } + + private validateUrls(summary: LocalSummalyResult) { + const urlScheme = this.utilityService.getUrlScheme(summary.url); + if (urlScheme !== 'http:' && urlScheme !== 'https:') { + throw new Error(`unsupported scheme in preview URL: "${urlScheme}"`); + } + + if (summary.player.url) { + const playerScheme = this.utilityService.getUrlScheme(summary.player.url); + if (playerScheme !== 'http:' && playerScheme !== 'https:') { + this.logger.warn(`Redacting preview for ${summary.url}: player URL has unsupported scheme "${playerScheme}"`); + summary.player.url = null; + } + } + + if (summary.icon) { + const iconScheme = this.utilityService.getUrlScheme(summary.icon); + if (iconScheme !== 'http:' && iconScheme !== 'https:') { + this.logger.warn(`Redacting preview for ${summary.url}: icon URL has unsupported scheme "${iconScheme}"`); + summary.icon = null; + } + } + + if (summary.thumbnail) { + const thumbnailScheme = this.utilityService.getUrlScheme(summary.thumbnail); + if (thumbnailScheme !== 'http:' && thumbnailScheme !== 'https:') { + this.logger.warn(`Redacting preview for ${summary.url}: thumbnail URL has unsupported scheme "${thumbnailScheme}"`); + summary.thumbnail = null; + } + } + + if (summary.activityPub) { + const activityPubScheme = this.utilityService.getUrlScheme(summary.activityPub); + if (activityPubScheme !== 'http:' && activityPubScheme !== 'https:') { + this.logger.warn(`Redacting preview for ${summary.url}: ActivityPub URL has unsupported scheme "${activityPubScheme}"`); + summary.activityPub = null; + } + } + } + + private async inferActivityPubLink(summary: LocalSummalyResult) { + // Match canonical URI first. + // This covers local and remote links. + const isCanonicalUri = !!await this.apDbResolverService.getNoteFromApId(summary.url); + if (isCanonicalUri) { + summary.activityPub = summary.url; + summary.haveNoteLocally = true; + return; + } + + // Try public URL next. + // This is necessary for Mastodon and other software with a different public URL. + const urlMatches = await this.notesRepository.find({ + select: { + uri: true, + }, + where: { + url: summary.url, + uri: Not(IsNull()), + }, + }) as { uri: string }[]; + + // Older versions did not validate URL, so do it now to avoid impersonation. + const matchByUrl = urlMatches.find(({ uri }) => this.apUtilityService.haveSameAuthority(uri, summary.url)); + if (matchByUrl) { + summary.activityPub = matchByUrl.uri; + summary.haveNoteLocally = true; + return; + } + + // Finally, attempt a signed GET in case it's a direct link to an instance with authorized fetch. + const instanceActor = await this.systemAccountService.getInstanceActor(); + const remoteObject = await this.apRequestService.signedGet(summary.url, instanceActor).catch(() => null); + if (remoteObject && this.apUtilityService.haveSameAuthority(remoteObject.id, summary.url)) { + summary.activityPub = remoteObject.id; + return; + } + } + + // true = exists, false = does not exist (permanently), null = does not exist (temporarily) + private async noteExists(uri: string, fetch = false): Promise<boolean | null> { + try { + // Local note or cached remote note + if (await this.apDbResolverService.getNoteFromApId(uri)) { + return true; + } + + // Un-cached remote note + if (!fetch) { + return null; + } + + // Newly cached remote note + if (await this.apNoteService.resolveNote(uri)) { + return true; + } + + // Non-existent or deleted note + return false; + } catch (err) { + // Errors, including invalid notes and network errors + return isRetryableError(err) ? null : false; + } + } + + // Adapted from ApiCallService + 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) + const token = request.headers.authorization?.startsWith('Bearer ') + ? request.headers.authorization.slice(7) + : body?.['i']; + if (token != null && typeof token !== 'string') { + return [undefined, undefined, getIpHash(request.ip)]; + } + + 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 [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 (user === undefined) { + reply.code(401).send({ + error: { + message: 'Authentication failed. Please ensure your token is correct.', + code: 'AUTHENTICATION_FAILED', + id: 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14', + }, + }); + return false; + } + if (user === null) { + reply.code(401).send({ + error: { + message: 'Credential required.', + code: 'CREDENTIAL_REQUIRED', + id: '1384574d-a912-4b81-8601-c7b1c4085df1', + }, + }); + return false; + } + + // Authorization + if (user.isSuspended || user.isDeleted) { + reply.code(403).send({ + error: { + message: 'Your account has been suspended.', + code: 'YOUR_ACCOUNT_SUSPENDED', + kind: 'permission', + + id: 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370', + }, + }); + return false; + } + if (app && !app.permission.includes('read:account')) { + reply.code(403).send({ + error: { + message: 'Your app does not have the necessary permissions to use this endpoint.', + code: 'PERMISSION_DENIED', + kind: 'permission', + id: '1370e5b7-d4eb-4566-bb1d-7748ee6a1838', + }, + }); + return false; + } + + 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; } } |