summaryrefslogtreecommitdiff
path: root/packages/backend/src/server/web/UrlPreviewService.ts
diff options
context:
space:
mode:
authorJulia <julia@insertdomain.name>2025-05-29 00:07:38 +0000
committerJulia <julia@insertdomain.name>2025-05-29 00:07:38 +0000
commit6b554c178b81f13f83a69b19d44b72b282a0c119 (patch)
treef5537f1a56323a4dd57ba150b3cb84a2d8b5dc63 /packages/backend/src/server/web/UrlPreviewService.ts
parentmerge: Security fixes (!970) (diff)
parentbump version for release (diff)
downloadsharkey-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.ts434
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;
}
}