From 7a7aef71cd6344c486d129789aff64f6e79a2824 Mon Sep 17 00:00:00 2001 From: eternal-flame-AD Date: Wed, 20 Nov 2024 12:53:29 -0600 Subject: [PATCH 1/2] do not use media proxy if emoji is local Signed-off-by: eternal-flame-AD --- .../src/core/entities/EmojiEntityService.ts | 28 ++++++++++++++-- packages/backend/src/models/Following.ts | 3 +- packages/backend/src/server/ServerService.ts | 32 ++++++++++++++++--- 3 files changed, 56 insertions(+), 7 deletions(-) diff --git a/packages/backend/src/core/entities/EmojiEntityService.ts b/packages/backend/src/core/entities/EmojiEntityService.ts index 8929d4e640..391d972320 100644 --- a/packages/backend/src/core/entities/EmojiEntityService.ts +++ b/packages/backend/src/core/entities/EmojiEntityService.ts @@ -11,15 +11,39 @@ import type { } from '@/models/Blocking.js'; import type { MiEmoji } from '@/models/Emoji.js'; import { bindThis } from '@/decorators.js'; import { In } from 'typeorm'; +import type { Config } from '@/config.js'; @Injectable() export class EmojiEntityService { constructor( @Inject(DI.emojisRepository) private emojisRepository: EmojisRepository, + + @Inject(DI.config) + private config: Config, ) { } + private stripProxyIfOrigin(url: string): string { + try { + const u = new URL(url); + let origin = u.origin; + if (u.origin === new URL(this.config.mediaProxy).origin) { + const innerUrl = u.searchParams.get('url'); + if (innerUrl) { + origin = new URL(innerUrl).origin; + } + } + if (origin === u.origin) { + return url; + } + } catch (e) { + return url; + } + + return url; + } + @bindThis public packSimpleNoQuery( emoji: MiEmoji, @@ -29,7 +53,7 @@ export class EmojiEntityService { name: emoji.name, category: emoji.category, // || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ) - url: emoji.publicUrl || emoji.originalUrl, + url: this.stripProxyIfOrigin(emoji.publicUrl || emoji.originalUrl), localOnly: emoji.localOnly ? true : undefined, isSensitive: emoji.isSensitive ? true : undefined, roleIdsThatCanBeUsedThisEmojiAsReaction: emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.length > 0 ? emoji.roleIdsThatCanBeUsedThisEmojiAsReaction : undefined, @@ -72,7 +96,7 @@ export class EmojiEntityService { category: emoji.category, host: emoji.host, // || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ) - url: emoji.publicUrl || emoji.originalUrl, + url: this.stripProxyIfOrigin(emoji.publicUrl || emoji.originalUrl), license: emoji.license, isSensitive: emoji.isSensitive, localOnly: emoji.localOnly, diff --git a/packages/backend/src/models/Following.ts b/packages/backend/src/models/Following.ts index 62cbc29f26..a64d1a4caf 100644 --- a/packages/backend/src/models/Following.ts +++ b/packages/backend/src/models/Following.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne, ViewEntity } from 'typeorm'; import { id } from './util/id.js'; import { MiUser } from './User.js'; @@ -98,3 +98,4 @@ export class MiFollowing { public followeeSharedInbox: string | null; //#endregion } + diff --git a/packages/backend/src/server/ServerService.ts b/packages/backend/src/server/ServerService.ts index e49ea9432b..e55a52fcab 100644 --- a/packages/backend/src/server/ServerService.ts +++ b/packages/backend/src/server/ServerService.ts @@ -33,7 +33,6 @@ import { OpenApiServerService } from './api/openapi/OpenApiServerService.js'; import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js'; import { makeHstsHook } from './hsts.js'; import { generateCSP } from './csp.js'; -import * as prom from 'prom-client'; import { sanitizeRequestURI } from '@/misc/log-sanitization.js'; import { metricCounter, metricGauge, metricHistogram, MetricsService } from './api/MetricsService.js'; @@ -110,6 +109,11 @@ const mLastSuccessfulRequest = metricGauge({ labelNames: [], }); +// This function is used to determine if a path is safe to redirect to. +function redirectSafePath(path: string): boolean { + return ['/files/', '/identicon/', '/proxy/', '/static-assets/', '/vite/', '/embed_vite/'].some(prefix => path.startsWith(prefix)); +} + @Injectable() export class ServerService implements OnApplicationShutdown { private logger: Logger; @@ -348,7 +352,7 @@ export class ServerService implements OnApplicationShutdown { name: name, }); - reply.header('Content-Security-Policy', 'default-src \'none\'; style-src \'unsafe-inline\''); + reply.header('Content-Security-Policy', 'default-src \'none\''); if (emoji == null) { if ('fallback' in request.query) { @@ -359,16 +363,26 @@ export class ServerService implements OnApplicationShutdown { } } + const dbUrl = emoji?.publicUrl || emoji?.originalUrl; + const dbUrlParsed = new URL(dbUrl); + const instanceUrl = new URL(this.config.url); + if (dbUrlParsed.origin === instanceUrl.origin) { + if (!redirectSafePath(dbUrlParsed.pathname)) { + return await reply.status(508); + } + return await reply.redirect(dbUrl, 301); + } + let url: URL; if ('badge' in request.query) { url = new URL(`${this.config.mediaProxy}/emoji.png`); // || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ) - url.searchParams.set('url', emoji.publicUrl || emoji.originalUrl); + url.searchParams.set('url', dbUrl); url.searchParams.set('badge', '1'); } else { url = new URL(`${this.config.mediaProxy}/emoji.webp`); // || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ) - url.searchParams.set('url', emoji.publicUrl || emoji.originalUrl); + url.searchParams.set('url', dbUrl); url.searchParams.set('emoji', '1'); if ('static' in request.query) url.searchParams.set('static', '1'); } @@ -392,6 +406,16 @@ export class ServerService implements OnApplicationShutdown { reply.header('Cache-Control', 'public, max-age=86400'); if (user) { + const dbUrl = user?.avatarUrl ?? this.userEntityService.getIdenticonUrl(user); + const dbUrlParsed = new URL(dbUrl); + const instanceUrl = new URL(this.config.url); + if (dbUrlParsed.origin === instanceUrl.origin) { + if (!redirectSafePath(dbUrlParsed.pathname)) { + return await reply.status(508); + } + return await reply.redirect(dbUrl, 301); + } + reply.redirect(user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user)); } else { reply.redirect('/static-assets/user-unknown.png'); From 9b8d02d1c3892f7f0e973bf488123a432896b753 Mon Sep 17 00:00:00 2001 From: eternal-flame-AD Date: Thu, 21 Nov 2024 00:47:39 -0600 Subject: [PATCH 2/2] type-safe sanitization of AP objects Signed-off-by: eternal-flame-AD --- .../backend/src/core/HttpRequestService.ts | 12 +- .../src/core/RemoteUserResolveService.ts | 4 +- .../src/core/activitypub/ApInboxService.ts | 155 +++--- .../src/core/activitypub/ApRendererService.ts | 106 ++--- .../src/core/activitypub/ApResolverService.ts | 17 +- .../core/activitypub/models/ApNoteService.ts | 7 +- .../activitypub/models/ApPersonService.ts | 5 +- packages/backend/src/core/activitypub/type.ts | 441 ++++++++++++++++-- .../queue/processors/InboxProcessorService.ts | 2 +- .../src/server/api/endpoints/ap/show.ts | 4 +- packages/backend/test/unit/activitypub.ts | 43 +- 11 files changed, 626 insertions(+), 170 deletions(-) diff --git a/packages/backend/src/core/HttpRequestService.ts b/packages/backend/src/core/HttpRequestService.ts index bea5dee6ab..60ecc03e4a 100644 --- a/packages/backend/src/core/HttpRequestService.ts +++ b/packages/backend/src/core/HttpRequestService.ts @@ -18,7 +18,7 @@ import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/val import { assertActivityMatchesUrls } from '@/core/activitypub/misc/check-against-url.js'; import type { IObject } from '@/core/activitypub/type.js'; import type { Response } from 'node-fetch'; -import type { URL } from 'node:url'; +import { URL } from 'node:url'; export type HttpRequestSendOptions = { throwErrorWhenResponseNotOk: boolean; @@ -183,6 +183,16 @@ export class HttpRequestService { controller.abort(); }, timeout); + const urlParsed = new URL(url); + + if (urlParsed.protocol !== 'https:') { + throw new Error('Invalid protocol'); + } + + if (urlParsed.port && urlParsed.port !== '443') { + throw new Error('Invalid port'); + } + const res = await fetch(url, { method: args.method ?? 'GET', headers: { diff --git a/packages/backend/src/core/RemoteUserResolveService.ts b/packages/backend/src/core/RemoteUserResolveService.ts index f5a55eb8bc..29d17328ca 100644 --- a/packages/backend/src/core/RemoteUserResolveService.ts +++ b/packages/backend/src/core/RemoteUserResolveService.ts @@ -18,6 +18,7 @@ import { RemoteLoggerService } from '@/core/RemoteLoggerService.js'; import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js'; import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; import { bindThis } from '@/decorators.js'; +import { ApResolverService } from './activitypub/ApResolverService.js'; @Injectable() export class RemoteUserResolveService { @@ -35,6 +36,7 @@ export class RemoteUserResolveService { private remoteLoggerService: RemoteLoggerService, private apDbResolverService: ApDbResolverService, private apPersonService: ApPersonService, + private apResolverService: ApResolverService, ) { this.logger = this.remoteLoggerService.logger.createSubLogger('resolve-user'); } @@ -91,7 +93,7 @@ export class RemoteUserResolveService { } this.logger.succ(`return new remote user: ${chalk.magenta(acctLower)}`); - return await this.apPersonService.createPerson(self.href); + return await this.apPersonService.createPerson(self.href, this.apResolverService.createResolver()); } // ユーザー情報が古い場合は、WebFingerからやりなおして返す diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts index fccf86cb91..1b28b4b4a5 100644 --- a/packages/backend/src/core/activitypub/ApInboxService.ts +++ b/packages/backend/src/core/activitypub/ApInboxService.ts @@ -29,7 +29,7 @@ import { bindThis } from '@/decorators.js'; import type { MiRemoteUser } from '@/models/User.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { AbuseReportService } from '@/core/AbuseReportService.js'; -import { getApHrefNullable, getApId, getApIds, getApType, isAccept, isActor, isAdd, isAnnounce, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isMove, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js'; +import { getApHrefNullable, getApId, getApIds, getApType, isActor, isCollection, isCollectionOrOrderedCollection, isPost, isTombstone, validActor, validPost, yumeDowncastAccept, yumeDowncastAdd, yumeDowncastAnnounce, yumeDowncastBlock, yumeDowncastCreate, yumeDowncastDelete, yumeDowncastFlag, yumeDowncastFollow, yumeDowncastLike, yumeDowncastMove, yumeDowncastReject, yumeDowncastRemove, yumeDowncastUndo, yumeDowncastUpdate } from './type.js'; import { ApNoteService } from './models/ApNoteService.js'; import { ApLoggerService } from './ApLoggerService.js'; import { ApDbResolverService } from './ApDbResolverService.js'; @@ -138,52 +138,92 @@ export class ApInboxService { public async performOneActivity(actor: MiRemoteUser, activity: IObject): Promise { if (actor.isSuspended) return; - if (isCreate(activity)) { + const create = yumeDowncastCreate(activity); + if (create) { mInboxReceived?.inc({ host: actor.host, type: 'create' }); - return await this.create(actor, activity); - } else if (isDelete(activity)) { - mInboxReceived?.inc({ host: actor.host, type: 'delete' }); - return await this.delete(actor, activity); - } else if (isUpdate(activity)) { - mInboxReceived?.inc({ host: actor.host, type: 'update' }); - return await this.update(actor, activity); - } else if (isFollow(activity)) { - mInboxReceived?.inc({ host: actor.host, type: 'follow' }); - return await this.follow(actor, activity); - } else if (isAccept(activity)) { - mInboxReceived?.inc({ host: actor.host, type: 'accept' }); - return await this.accept(actor, activity); - } else if (isReject(activity)) { - mInboxReceived?.inc({ host: actor.host, type: 'reject' }); - return await this.reject(actor, activity); - } else if (isAdd(activity)) { - mInboxReceived?.inc({ host: actor.host, type: 'add' }); - return await this.add(actor, activity); - } else if (isRemove(activity)) { - mInboxReceived?.inc({ host: actor.host, type: 'remove' }); - return await this.remove(actor, activity); - } else if (isAnnounce(activity)) { - mInboxReceived?.inc({ host: actor.host, type: 'announce' }); - return await this.announce(actor, activity); - } else if (isLike(activity)) { - mInboxReceived?.inc({ host: actor.host, type: 'like' }); - return await this.like(actor, activity); - } else if (isUndo(activity)) { - mInboxReceived?.inc({ host: actor.host, type: 'undo' }); - return await this.undo(actor, activity); - } else if (isBlock(activity)) { - mInboxReceived?.inc({ host: actor.host, type: 'block' }); - return await this.block(actor, activity); - } else if (isFlag(activity)) { - mInboxReceived?.inc({ host: actor.host, type: 'flag' }); - return await this.flag(actor, activity); - } else if (isMove(activity)) { - mInboxReceived?.inc({ host: actor.host, type: 'move' }); - return await this.move(actor, activity); - } else { - mInboxReceived?.inc({ host: actor.host, type: 'unknown' }); - return `unrecognized activity type: ${activity.type}`; + return await this.create(actor, create); } + + const update = yumeDowncastUpdate(activity); + if (update) { + mInboxReceived?.inc({ host: actor.host, type: 'update' }); + return await this.update(actor, update); + } + + const del = yumeDowncastDelete(activity); + if (del) { + mInboxReceived?.inc({ host: actor.host, type: 'delete' }); + return await this.delete(actor, del); + } + + const follow = yumeDowncastFollow(activity); + if (follow) { + mInboxReceived?.inc({ host: actor.host, type: 'follow' }); + return await this.follow(actor, follow); + } + + const accept = yumeDowncastAccept(activity); + if (accept) { + mInboxReceived?.inc({ host: actor.host, type: 'accept' }); + return await this.accept(actor, accept); + } + + const reject = yumeDowncastReject(activity); + if (reject) { + mInboxReceived?.inc({ host: actor.host, type: 'reject' }); + return await this.reject(actor, reject); + } + + const add = yumeDowncastAdd(activity); + if (add) { + mInboxReceived?.inc({ host: actor.host, type: 'add' }); + return await this.add(actor, add); + } + + const remove = yumeDowncastRemove(activity); + if (remove) { + mInboxReceived?.inc({ host: actor.host, type: 'remove' }); + return await this.remove(actor, remove); + } + + const announce = yumeDowncastAnnounce(activity); + if (announce) { + mInboxReceived?.inc({ host: actor.host, type: 'announce' }); + return await this.announce(actor, announce); + } + + const like = yumeDowncastLike(activity); + if (like) { + mInboxReceived?.inc({ host: actor.host, type: 'like' }); + return await this.like(actor, like); + } + + const move = yumeDowncastMove(activity); + if (move) { + mInboxReceived?.inc({ host: actor.host, type: 'move' }); + return await this.move(actor, move); + } + + const undo = yumeDowncastUndo(activity); + if (undo) { + mInboxReceived?.inc({ host: actor.host, type: 'undo' }); + return await this.undo(actor, undo); + } + + const block = yumeDowncastBlock(activity); + if (block) { + mInboxReceived?.inc({ host: actor.host, type: 'block' }); + return await this.block(actor, block); + } + + const flag = yumeDowncastFlag(activity); + if (flag) { + mInboxReceived?.inc({ host: actor.host, type: 'flag' }); + return await this.flag(actor, flag); + } + + mInboxReceived?.inc({ host: actor.host, type: 'unknown' }); + return `unrecognized activity type: ${activity.type}`; } @bindThis @@ -234,7 +274,8 @@ export class ApInboxService { throw err; }); - if (isFollow(object)) return await this.acceptFollow(actor, object); + const follow = yumeDowncastFollow(object); + if (follow) return await this.acceptFollow(actor, follow); return `skip: Unknown Accept type: ${getApType(object)}`; } @@ -583,7 +624,8 @@ export class ApInboxService { throw e; }); - if (isFollow(object)) return await this.rejectFollow(actor, object); + const follow = yumeDowncastFollow(object); + if (follow) return await this.rejectFollow(actor, follow); return `skip: Unknown Reject type: ${getApType(object)}`; } @@ -650,11 +692,20 @@ export class ApInboxService { }); // don't queue because the sender may attempt again when timeout - if (isFollow(object)) return await this.undoFollow(actor, object); - if (isBlock(object)) return await this.undoBlock(actor, object); - if (isLike(object)) return await this.undoLike(actor, object); - if (isAnnounce(object)) return await this.undoAnnounce(actor, object); - if (isAccept(object)) return await this.undoAccept(actor, object); + const follow = yumeDowncastFollow(object); + if (follow) return await this.undoFollow(actor, follow); + + const block = yumeDowncastBlock(object); + if (block) return await this.undoBlock(actor, block); + + const like = yumeDowncastLike(object); + if (like) return await this.undoLike(actor, like); + + const announce = yumeDowncastAnnounce(object); + if (announce) return await this.undoAnnounce(actor, announce); + + const accept = yumeDowncastAccept(object); + if (accept) return await this.undoAccept(actor, accept); return `skip: unknown object type ${getApType(object)}`; } diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts index 5617a29bab..106e2a880c 100644 --- a/packages/backend/src/core/activitypub/ApRendererService.ts +++ b/packages/backend/src/core/activitypub/ApRendererService.ts @@ -30,7 +30,7 @@ import { IdService } from '@/core/IdService.js'; import { JsonLdService } from './JsonLdService.js'; import { ApMfmService } from './ApMfmService.js'; import { CONTEXT } from './misc/contexts.js'; -import type { IAccept, IActivity, IAdd, IAnnounce, IApDocument, IApEmoji, IApHashtag, IApImage, IApMention, IBlock, ICreate, IDelete, IFlag, IFollow, IKey, ILike, IMove, IObject, IPost, IQuestion, IReject, IRemove, ITombstone, IUndo, IUpdate } from './type.js'; +import { markOutgoing, type IAccept, type IActivity, type IAdd, type IAnnounce, type IApDocument, type IApEmoji, type IApHashtag, type IApImage, type IApMention, type IBlock, type ICreate, type IDelete, type IFlag, type IFollow, type IKey, type ILike, type IMove, type IObject, type IPost, type IQuestion, type IReject, type IRemove, type ITombstone, type IUndo, type IUpdate } from './type.js'; @Injectable() export class ApRendererService { @@ -66,21 +66,21 @@ export class ApRendererService { @bindThis public renderAccept(object: string | IObject, user: { id: MiUser['id']; host: null }): IAccept { - return { + return markOutgoing({ type: 'Accept', actor: this.userEntityService.genLocalUserUri(user.id), object, - }; + }, undefined); } @bindThis public renderAdd(user: MiLocalUser, target: string | IObject | undefined, object: string | IObject): IAdd { - return { + return markOutgoing({ type: 'Add', actor: this.userEntityService.genLocalUserUri(user.id), target, object, - }; + }, undefined); } @bindThis @@ -103,7 +103,7 @@ export class ApRendererService { throw new Error('renderAnnounce: cannot render non-public note'); } - return { + return markOutgoing({ id: `${this.config.url}/notes/${note.id}/activity`, actor: this.userEntityService.genLocalUserUri(note.userId), type: 'Announce', @@ -111,7 +111,7 @@ export class ApRendererService { to, cc, object, - }; + }, undefined); } /** @@ -125,23 +125,23 @@ export class ApRendererService { throw new Error('renderBlock: missing blockee uri'); } - return { + return markOutgoing({ type: 'Block', id: `${this.config.url}/blocks/${block.id}`, actor: this.userEntityService.genLocalUserUri(block.blockerId), object: block.blockee.uri, - }; + }, undefined); } @bindThis public renderCreate(object: IObject, note: MiNote): ICreate { - const activity: ICreate = { + const activity: ICreate = markOutgoing({ id: `${this.config.url}/notes/${note.id}/activity`, actor: this.userEntityService.genLocalUserUri(note.userId), type: 'Create', published: this.idService.parse(note.id).date.toISOString(), object, - }; + }, undefined); if (object.to) activity.to = object.to; if (object.cc) activity.cc = object.cc; @@ -151,28 +151,28 @@ export class ApRendererService { @bindThis public renderDelete(object: IObject | string, user: { id: MiUser['id']; host: null }): IDelete { - return { + return markOutgoing({ type: 'Delete', actor: this.userEntityService.genLocalUserUri(user.id), object, published: new Date().toISOString(), - }; + }, undefined); } @bindThis public renderDocument(file: MiDriveFile): IApDocument { - return { + return markOutgoing({ type: 'Document', mediaType: file.webpublicType ?? file.type, url: this.driveFileEntityService.getPublicUrl(file), name: file.comment, sensitive: file.isSensitive, - }; + }, undefined); } @bindThis public renderEmoji(emoji: MiEmoji): IApEmoji { - return { + return markOutgoing( { id: `${this.config.url}/emojis/${emoji.name}`, type: 'Emoji', name: `:${emoji.name}:`, @@ -183,28 +183,28 @@ export class ApRendererService { // || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ) url: emoji.publicUrl || emoji.originalUrl, }, - }; + }, undefined); } // to anonymise reporters, the reporting actor must be a system user @bindThis public renderFlag(user: MiLocalUser, object: IObject | string, content: string): IFlag { - return { + return markOutgoing({ type: 'Flag', actor: this.userEntityService.genLocalUserUri(user.id), content, object, - }; + }, undefined); } @bindThis public renderFollowRelay(relay: MiRelay, relayActor: MiLocalUser): IFollow { - return { + return markOutgoing({ id: `${this.config.url}/activities/follow-relay/${relay.id}`, type: 'Follow', actor: this.userEntityService.genLocalUserUri(relayActor.id), object: 'https://www.w3.org/ns/activitystreams#Public', - }; + }, undefined); } /** @@ -223,36 +223,36 @@ export class ApRendererService { followee: MiPartialLocalUser | MiPartialRemoteUser, requestId?: string, ): IFollow { - return { + return markOutgoing({ id: requestId ?? `${this.config.url}/follows/${follower.id}/${followee.id}`, type: 'Follow', actor: this.userEntityService.getUserUri(follower), object: this.userEntityService.getUserUri(followee), - }; + }, undefined); } @bindThis public renderHashtag(tag: string): IApHashtag { - return { + return markOutgoing({ type: 'Hashtag', href: `${this.config.url}/tags/${encodeURIComponent(tag)}`, name: `#${tag}`, - }; + }, undefined); } @bindThis public renderImage(file: MiDriveFile): IApImage { - return { + return markOutgoing({ type: 'Image', url: this.driveFileEntityService.getPublicUrl(file), sensitive: file.isSensitive, name: file.comment, - }; + }, undefined); } @bindThis public renderKey(user: MiLocalUser, key: MiUserKeypair, postfix?: string): IKey { - return { + return markOutgoing({ id: `${this.config.url}/users/${user.id}${postfix ?? '/publickey'}`, type: 'Key', owner: this.userEntityService.genLocalUserUri(user.id), @@ -260,21 +260,21 @@ export class ApRendererService { type: 'spki', format: 'pem', }), - }; + }, undefined); } @bindThis public async renderLike(noteReaction: MiNoteReaction, note: { uri: string | null }): Promise { const reaction = noteReaction.reaction; - const object: ILike = { + const object: ILike = markOutgoing({ type: 'Like', id: `${this.config.url}/likes/${noteReaction.id}`, actor: `${this.config.url}/users/${noteReaction.userId}`, object: note.uri ? note.uri : `${this.config.url}/notes/${noteReaction.noteId}`, content: reaction, _misskey_reaction: reaction, - }; + }, undefined); if (reaction.startsWith(':')) { const name = reaction.replaceAll(':', ''); @@ -288,11 +288,11 @@ export class ApRendererService { @bindThis public renderMention(mention: MiPartialLocalUser | MiPartialRemoteUser): IApMention { - return { + return markOutgoing({ type: 'Mention', href: this.userEntityService.getUserUri(mention), name: this.userEntityService.isRemoteUser(mention) ? `@${mention.username}@${mention.host}` : `@${(mention as MiLocalUser).username}`, - }; + }, undefined); } @bindThis @@ -302,13 +302,13 @@ export class ApRendererService { ): IMove { const actor = this.userEntityService.getUserUri(src); const target = this.userEntityService.getUserUri(dst); - return { + return markOutgoing({ id: `${this.config.url}/moves/${src.id}/${dst.id}`, actor, type: 'Move', object: actor, target, - }; + }, undefined); } @bindThis @@ -422,7 +422,7 @@ export class ApRendererService { })), } as const : {}; - return { + return markOutgoing({ id: `${this.config.url}/notes/${note.id}`, type: 'Note', attributedTo, @@ -445,7 +445,7 @@ export class ApRendererService { sensitive: note.cw != null || files.some(file => file.isSensitive), tag, ...asPoll, - }; + }, undefined); } @bindThis @@ -529,7 +529,7 @@ export class ApRendererService { @bindThis public renderQuestion(user: { id: MiUser['id'] }, note: MiNote, poll: MiPoll): IQuestion { - return { + return markOutgoing({ type: 'Question', id: `${this.config.url}/questions/${note.id}`, actor: this.userEntityService.genLocalUserUri(user.id), @@ -542,78 +542,78 @@ export class ApRendererService { totalItems: poll.votes[i], }, })), - }; + }, 'question'); } @bindThis public renderReject(object: string | IObject, user: { id: MiUser['id'] }): IReject { - return { + return markOutgoing({ type: 'Reject', actor: this.userEntityService.genLocalUserUri(user.id), object, - }; + }, undefined); } @bindThis public renderRemove(user: { id: MiUser['id'] }, target: string | IObject | undefined, object: string | IObject): IRemove { - return { + return markOutgoing({ type: 'Remove', actor: this.userEntityService.genLocalUserUri(user.id), target, object, - }; + }, undefined); } @bindThis public renderTombstone(id: string): ITombstone { - return { + return markOutgoing({ id, type: 'Tombstone', - }; + }, undefined); } @bindThis public renderUndo(object: string | IObject, user: { id: MiUser['id'] }): IUndo { const id = typeof object !== 'string' && typeof object.id === 'string' && object.id.startsWith(this.config.url) ? `${object.id}/undo` : undefined; - return { + return markOutgoing({ type: 'Undo', ...(id ? { id } : {}), actor: this.userEntityService.genLocalUserUri(user.id), object, published: new Date().toISOString(), - }; + }, undefined); } @bindThis public renderUpdate(object: string | IObject, user: { id: MiUser['id'] }): IUpdate { - return { + return markOutgoing( { id: `${this.config.url}/users/${user.id}#updates/${new Date().getTime()}`, actor: this.userEntityService.genLocalUserUri(user.id), type: 'Update', to: ['https://www.w3.org/ns/activitystreams#Public'], object, published: new Date().toISOString(), - }; + }, undefined); } @bindThis public renderVote(user: { id: MiUser['id'] }, vote: MiPollVote, note: MiNote, poll: MiPoll, pollOwner: MiRemoteUser): ICreate { - return { + return markOutgoing({ id: `${this.config.url}/users/${user.id}#votes/${vote.id}/activity`, actor: this.userEntityService.genLocalUserUri(user.id), type: 'Create', to: [pollOwner.uri], published: new Date().toISOString(), - object: { + object: markOutgoing({ id: `${this.config.url}/users/${user.id}#votes/${vote.id}`, type: 'Note', attributedTo: this.userEntityService.genLocalUserUri(user.id), to: [pollOwner.uri], inReplyTo: note.uri, name: poll.choices[vote.choice], - }, - }; + }, undefined), + }, undefined); } @bindThis diff --git a/packages/backend/src/core/activitypub/ApResolverService.ts b/packages/backend/src/core/activitypub/ApResolverService.ts index d38fb71f5b..5244de43de 100644 --- a/packages/backend/src/core/activitypub/ApResolverService.ts +++ b/packages/backend/src/core/activitypub/ApResolverService.ts @@ -16,11 +16,11 @@ import { UtilityService } from '@/core/UtilityService.js'; import { bindThis } from '@/decorators.js'; import { LoggerService } from '@/core/LoggerService.js'; import type Logger from '@/logger.js'; -import { isCollectionOrOrderedCollection } from './type.js'; +import { isCollectionOrOrderedCollection, yumeNormalizeObject } from './type.js'; import { ApDbResolverService } from './ApDbResolverService.js'; import { ApRendererService } from './ApRendererService.js'; import { ApRequestService } from './ApRequestService.js'; -import type { IObject, ICollection, IOrderedCollection } from './type.js'; +import type { IObject, ICollection, IOrderedCollection, IUnsanitizedObject } from './type.js'; export class Resolver { private history: Set; @@ -67,7 +67,7 @@ export class Resolver { } @bindThis - public async resolve(value: string | IObject): Promise { + public async resolveNotNormalized(value: string | IObject): Promise { if (typeof value !== 'string') { return value; } @@ -103,8 +103,8 @@ export class Resolver { } const object = (this.user - ? await this.apRequestService.signedGet(value, this.user) as IObject - : await this.httpRequestService.getActivityJson(value)) as IObject; + ? await this.apRequestService.signedGet(value, this.user) as IUnsanitizedObject + : await this.httpRequestService.getActivityJson(value)) as IUnsanitizedObject; if ( Array.isArray(object['@context']) ? @@ -117,6 +117,13 @@ export class Resolver { return object; } + @bindThis + public async resolve(value: string | IObject): Promise { + const object = await this.resolveNotNormalized(value); + + return yumeNormalizeObject(object); + } + @bindThis private resolveLocal(url: string): Promise { const parsed = this.apDbResolverService.parseUri(url); diff --git a/packages/backend/src/core/activitypub/models/ApNoteService.ts b/packages/backend/src/core/activitypub/models/ApNoteService.ts index 2d333b3634..43c4994706 100644 --- a/packages/backend/src/core/activitypub/models/ApNoteService.ts +++ b/packages/backend/src/core/activitypub/models/ApNoteService.ts @@ -115,10 +115,7 @@ export class ApNoteService { * Noteを作成します。 */ @bindThis - public async createNote(value: string | IObject, resolver?: Resolver, silent = false): Promise { - // eslint-disable-next-line no-param-reassign - if (resolver == null) resolver = this.apResolverService.createResolver(); - + public async createNote(value: string | IObject, resolver: Resolver, silent = false): Promise { const object = await resolver.resolve(value); const entryUri = getApId(value); @@ -356,7 +353,7 @@ export class ApNoteService { // ここでuriの代わりに添付されてきたNote Objectが指定されていると、サーバーフェッチを経ずにノートが生成されるが // 添付されてきたNote Objectは偽装されている可能性があるため、常にuriを指定してサーバーフェッチを行う。 const createFrom = options.sentFrom?.origin === new URL(uri).origin ? value : uri; - return await this.createNote(createFrom, options.resolver, true); + return await this.createNote(createFrom, this.apResolverService.createResolver(), true); } finally { unlock(); } diff --git a/packages/backend/src/core/activitypub/models/ApPersonService.ts b/packages/backend/src/core/activitypub/models/ApPersonService.ts index e01b098194..d02701da52 100644 --- a/packages/backend/src/core/activitypub/models/ApPersonService.ts +++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts @@ -277,16 +277,13 @@ export class ApPersonService implements OnModuleInit { * Personを作成します。 */ @bindThis - public async createPerson(uri: string, resolver?: Resolver): Promise { + public async createPerson(uri: string, resolver: Resolver): Promise { if (typeof uri !== 'string') throw new Error('uri is not string'); if (uri.startsWith(this.config.url)) { throw new StatusError('cannot resolve local user', 400, 'cannot resolve local user'); } - // eslint-disable-next-line no-param-reassign - if (resolver == null) resolver = this.apResolverService.createResolver(); - const object = await resolver.resolve(uri); if (object.id == null) throw new Error('invalid object.id: ' + object.id); diff --git a/packages/backend/src/core/activitypub/type.ts b/packages/backend/src/core/activitypub/type.ts index 7496315f09..c42a1b4769 100644 --- a/packages/backend/src/core/activitypub/type.ts +++ b/packages/backend/src/core/activitypub/type.ts @@ -3,20 +3,44 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { target } from "happy-dom/lib/PropertySymbol.js"; +import { toASCII } from "node:punycode"; + export type Obj = { [x: string]: any }; export type ApObject = IObject | string | (IObject | string)[]; -export interface IObject { +export interface MisskeyVendorKeys { + _misskey_summary: string; + _misskey_followedMessage: string | null; + _misskey_requireSigninToViewContents: boolean; + _misskey_makeNotesFollowersOnlyBefore: number | null; + _misskey_makeNotesHiddenBefore: number | null; + _misskey_quote: string; + _misskey_content: string; + _misskey_reaction: string; + _misskey_votes: number; +} + +function extractMisskeyVendorKeys(object: IObject): Partial { + return { + _misskey_summary: object._misskey_summary, + _misskey_followedMessage: object._misskey_followedMessage, + _misskey_requireSigninToViewContents: object._misskey_requireSigninToViewContents, + _misskey_makeNotesFollowersOnlyBefore: object._misskey_makeNotesFollowersOnlyBefore, + _misskey_makeNotesHiddenBefore: object._misskey_makeNotesHiddenBefore, + _misskey_quote: object._misskey_quote, + _misskey_content: object._misskey_content, + _misskey_reaction: object._misskey_reaction, + _misskey_votes: object._misskey_votes, + }; +} + +export interface IUnsanitizedObject extends Partial { '@context'?: string | string[] | Obj | Obj[]; type: string | string[]; id?: string; name?: string | null; summary?: string; - _misskey_summary?: string; - _misskey_followedMessage?: string | null; - _misskey_requireSigninToViewContents?: boolean; - _misskey_makeNotesFollowersOnlyBefore?: number | null; - _misskey_makeNotesHiddenBefore?: number | null; published?: string; cc?: ApObject; to?: ApObject; @@ -34,6 +58,78 @@ export interface IObject { href?: string; tag?: IObject | IObject[]; sensitive?: boolean; + + visibility?: string; + mentionedUsers?: any[]; + visibleUsers?: any[]; +} + +export interface IObject extends IUnsanitizedObject { + __yume_normalized_object: true | 'outgoing'; +}; + +export interface YumeDowncastSanitizedBadge { + __yume_normalized_badge: L | 'outgoing'; +}; + +export function markOutgoing(object: T, _badge: L): T & IObject & YumeDowncastSanitizedBadge { + return object as T & IObject & YumeDowncastSanitizedBadge; +} + +export function yumeNormalizeURL(url: string): string { + const u = new URL(url); + u.hash = ''; + u.host = toASCII(u.host); + if (u.protocol && u.protocol !== 'https:') { + throw new Error('protocol is not https'); + } + u.protocol = 'https:'; + if (u.port && u.port !== '443') { + throw new Error('port is not 443'); + } + return u.toString(); +} + +export function yumeNormalizeRecursive(object: O, depth = 0): + IObject | string | (IObject | string)[] { + if (depth > 16) { + throw new Error('recursion limit exceeded'); + } + + if (typeof object === 'string') { + return yumeNormalizeURL(object); + } + if (Array.isArray(object)) { + if (object.length > 64) { + throw new Error('array length limit exceeded'); + } + return object.flatMap(yumeNormalizeRecursive); + } + + return yumeNormalizeObject(object); +} + +export function yumeNormalizeObject(object: IUnsanitizedObject): IObject { + if (object.cc) { + object.cc = yumeNormalizeRecursive(object.cc); + } + if (object.id) { + object.id = yumeNormalizeURL(object.id); + } + + if (object.url) { + object.url = yumeNormalizeRecursive(object.url); + } + + if (object.attachment) { + object.attachment = object.attachment.map(yumeNormalizeRecursive); + } + + if (object.inReplyTo) { + object.inReplyTo = yumeNormalizeRecursive(object.inReplyTo); + } + + return object as IObject; } /** @@ -80,7 +176,7 @@ export function getOneApHrefNullable(value: ApObject | undefined): string | unde } export function getApHrefNullable(value: string | IObject | undefined): string | undefined { - if (typeof value === 'string') return value; +if (typeof value === 'string') return value; if (typeof value?.href === 'string') return value.href; return undefined; } @@ -101,6 +197,24 @@ export interface IActivity extends IObject { }; } +export interface SafeList { + id: string; + published: string; + visibility: string; + mentionedUsers: any[]; + visibleUsers: any[]; +} + +function extractSafe(object: IObject): Partial { + return { + id: object.id, + published: object.published, + visibility: object.visibility, + mentionedUsers: object.mentionedUsers, + visibleUsers: object.visibleUsers, + }; +} + export interface ICollection extends IObject { type: 'Collection'; totalItems: number; @@ -122,7 +236,7 @@ export const isPost = (object: IObject): object is IPost => { return type != null && validPost.includes(type); }; -export interface IPost extends IObject { +export interface IPost extends IObject{ type: 'Note' | 'Question' | 'Article' | 'Audio' | 'Document' | 'Image' | 'Page' | 'Video' | 'Event'; source?: { content: string; @@ -133,7 +247,7 @@ export interface IPost extends IObject { quoteUrl?: string; } -export interface IQuestion extends IObject { +export interface IUnsanitizedQuestion extends IObject { type: 'Note' | 'Question'; actor: string; source?: { @@ -148,7 +262,25 @@ export interface IQuestion extends IObject { closed?: Date; } -export const isQuestion = (object: IObject): object is IQuestion => +export interface IQuestion extends IUnsanitizedQuestion, YumeDowncastSanitizedBadge<'question'> {} + +export function yumeSanitizeQuestion(object: IUnsanitizedQuestion): IQuestion { + return { + type: object.type, + actor: yumeNormalizeURL(object.actor), + source: object.source, + _misskey_quote: object._misskey_quote, + quoteUrl: object.quoteUrl ? yumeNormalizeURL(object.quoteUrl) : '', + oneOf: object.oneOf, + anyOf: object.anyOf, + endTime: object.endTime, + closed: object.closed, + __yume_normalized_object: true, + __yume_normalized_badge: 'question', + }; +} + +export const isQuestion = (object: IObject): object is IUnsanitizedQuestion => getApType(object) === 'Note' || getApType(object) === 'Question'; interface IQuestionChoice { @@ -264,88 +396,307 @@ export const isDocument = (object: IObject): object is IApDocument => { return type != null && validDocumentTypes.includes(type); }; -export interface IApImage extends IApDocument { +export interface IApImage extends IApDocument, Partial { type: 'Image'; } -export interface ICreate extends IActivity { +export interface ICreate extends IActivity, Partial { type: 'Create'; } -export interface IDelete extends IActivity { +export interface IDelete extends IActivity, Partial { type: 'Delete'; } -export interface IUpdate extends IActivity { +export interface IUpdate extends IActivity, Partial { type: 'Update'; } -export interface IRead extends IActivity { +export interface IRead extends IActivity, Partial { type: 'Read'; } -export interface IUndo extends IActivity { +export interface IUndo extends IActivity, Partial { type: 'Undo'; } -export interface IFollow extends IActivity { +export interface IFollow extends IActivity, Partial { type: 'Follow'; } -export interface IAccept extends IActivity { +export interface IAccept extends IActivity, Partial { type: 'Accept'; } -export interface IReject extends IActivity { +export interface IReject extends IActivity, Partial { type: 'Reject'; } -export interface IAdd extends IActivity { +export interface IAdd extends IActivity, Partial { type: 'Add'; } -export interface IRemove extends IActivity { +export interface IRemove extends IActivity, Partial { type: 'Remove'; } -export interface ILike extends IActivity { +export interface ILike extends IActivity, Partial { type: 'Like' | 'EmojiReaction' | 'EmojiReact'; _misskey_reaction?: string; } -export interface IAnnounce extends IActivity { +export interface IAnnounce extends IActivity, Partial { type: 'Announce'; } -export interface IBlock extends IActivity { +export interface IBlock extends IActivity, Partial { type: 'Block'; } -export interface IFlag extends IActivity { +export interface IFlag extends IActivity, Partial { type: 'Flag'; } -export interface IMove extends IActivity { +export interface IMove extends IActivity, Partial { type: 'Move'; target: IObject | string; } -export const isCreate = (object: IObject): object is ICreate => getApType(object) === 'Create'; -export const isDelete = (object: IObject): object is IDelete => getApType(object) === 'Delete'; -export const isUpdate = (object: IObject): object is IUpdate => getApType(object) === 'Update'; -export const isRead = (object: IObject): object is IRead => getApType(object) === 'Read'; -export const isUndo = (object: IObject): object is IUndo => getApType(object) === 'Undo'; -export const isFollow = (object: IObject): object is IFollow => getApType(object) === 'Follow'; -export const isAccept = (object: IObject): object is IAccept => getApType(object) === 'Accept'; -export const isReject = (object: IObject): object is IReject => getApType(object) === 'Reject'; -export const isAdd = (object: IObject): object is IAdd => getApType(object) === 'Add'; -export const isRemove = (object: IObject): object is IRemove => getApType(object) === 'Remove'; -export const isLike = (object: IObject): object is ILike => { - const type = getApType(object); - return type != null && ['Like', 'EmojiReaction', 'EmojiReact'].includes(type); -}; -export const isAnnounce = (object: IObject): object is IAnnounce => getApType(object) === 'Announce'; -export const isBlock = (object: IObject): object is IBlock => getApType(object) === 'Block'; -export const isFlag = (object: IObject): object is IFlag => getApType(object) === 'Flag'; -export const isMove = (object: IObject): object is IMove => getApType(object) === 'Move'; -export const isNote = (object: IObject): object is IPost => getApType(object) === 'Note'; +export function yumeDowncastCreate(object: IObject): ICreate | null { + if (getApType(object) !== 'Create') return null; + const obj = object as ICreate; + if (!obj.actor || !obj.object) return null; + return { + ...extractMisskeyVendorKeys(object), + ...extractSafe(object), + type: 'Create', + actor: typeof obj.actor === 'string' ? yumeNormalizeURL(obj.actor) : yumeNormalizeObject(obj.actor), + object: typeof obj.object === 'string' ? yumeNormalizeURL(obj.object) : yumeNormalizeObject(obj.object), + target: obj.target ? (typeof obj.target === 'string' ? yumeNormalizeURL(obj.target) : yumeNormalizeObject(obj.target)) : undefined, + __yume_normalized_object: true, + }; +} + +export function yumeDowncastDelete(object: IObject): IDelete | null { + if (getApType(object) !== 'Delete') return null; + const obj = object as IDelete; + if (!obj.actor || !obj.object) return null; + return { + ...extractMisskeyVendorKeys(object), + ...extractSafe(object), + type: 'Delete', + actor: typeof obj.actor === 'string' ? yumeNormalizeURL(obj.actor) : yumeNormalizeObject(obj.actor), + object: typeof obj.object === 'string' ? yumeNormalizeURL(obj.object) : yumeNormalizeObject(obj.object), + target: obj.target ? (typeof obj.target === 'string' ? yumeNormalizeURL(obj.target) : yumeNormalizeObject(obj.target)) : undefined, + __yume_normalized_object: true, + }; +} + +export function yumeDowncastUpdate(object: IObject): IUpdate | null { + if (getApType(object) !== 'Update') return null; + const obj = object as IUpdate; + if (!obj.actor || !obj.object) return null; + return { + ...extractMisskeyVendorKeys(object), + ...extractSafe(object), + type: 'Update', + actor: typeof obj.actor === 'string' ? yumeNormalizeURL(obj.actor) : yumeNormalizeObject(obj.actor), + object: typeof obj.object === 'string' ? yumeNormalizeURL(obj.object) : yumeNormalizeObject(obj.object), + target: obj.target ? (typeof obj.target === 'string' ? yumeNormalizeURL(obj.target) : yumeNormalizeObject(obj.target)) : undefined, + __yume_normalized_object: true, + }; +} + +export function yumeDowncastRead(object: IObject): IRead | null { + if (getApType(object) !== 'Read') return null; + const obj = object as IRead; + if (!obj.actor || !obj.object) return null; + return { + ...extractMisskeyVendorKeys(object), + ...extractSafe(object), + type: 'Read', + actor: typeof obj.actor === 'string' ? yumeNormalizeURL(obj.actor) : yumeNormalizeObject(obj.actor), + object: typeof obj.object === 'string' ? yumeNormalizeURL(obj.object) : yumeNormalizeObject(obj.object), + target: obj.target ? (typeof obj.target === 'string' ? yumeNormalizeURL(obj.target) : yumeNormalizeObject(obj.target)) : undefined, + __yume_normalized_object: true, + }; +} + +export function yumeDowncastUndo(object: IObject): IUndo | null { + if (getApType(object) !== 'Undo') return null; + const obj = object as IUndo; + if (!obj.actor || !obj.object) return null; + return { + ...extractMisskeyVendorKeys(object), + ...extractSafe(object), + type: 'Undo', + actor: typeof obj.actor === 'string' ? yumeNormalizeURL(obj.actor) : yumeNormalizeObject(obj.actor), + object: typeof obj.object === 'string' ? yumeNormalizeURL(obj.object) : yumeNormalizeObject(obj.object), + target: obj.target ? (typeof obj.target === 'string' ? yumeNormalizeURL(obj.target) : yumeNormalizeObject(obj.target)) : undefined, + __yume_normalized_object: true, + }; +} + +export function yumeDowncastFollow(object: IObject): IFollow | null { + if (getApType(object) !== 'Follow') return null; + const obj = object as IFollow; + if (!obj.actor || !obj.object) return null; + return { + ...extractMisskeyVendorKeys(object), + ...extractSafe(object), + type: 'Follow', + actor: typeof obj.actor === 'string' ? yumeNormalizeURL(obj.actor) : yumeNormalizeObject(obj.actor), + object: typeof obj.object === 'string' ? yumeNormalizeURL(obj.object) : yumeNormalizeObject(obj.object), + target: obj.target ? (typeof obj.target === 'string' ? yumeNormalizeURL(obj.target) : yumeNormalizeObject(obj.target)) : undefined, + __yume_normalized_object: true, + }; +} + +export function yumeDowncastAccept(object: IObject): IAccept | null { + if (getApType(object) !== 'Accept') return null; + const obj = object as IAccept; + if (!obj.actor || !obj.object) return null; + return { + ...extractMisskeyVendorKeys(object), + ...extractSafe(object), + type: 'Accept', + actor: typeof obj.actor === 'string' ? yumeNormalizeURL(obj.actor) : yumeNormalizeObject(obj.actor), + object: typeof obj.object === 'string' ? yumeNormalizeURL(obj.object) : yumeNormalizeObject(obj.object), + target: obj.target ? (typeof obj.target === 'string' ? yumeNormalizeURL(obj.target) : yumeNormalizeObject(obj.target)) : undefined, + __yume_normalized_object: true, + }; +} + +export function yumeDowncastReject(object: IObject): IReject | null { + if (getApType(object) !== 'Reject') return null; + const obj = object as IReject; + if (!obj.actor || !obj.object) return null; + return { + ...extractMisskeyVendorKeys(object), + ...extractSafe(object), + type: 'Reject', + actor: typeof obj.actor === 'string' ? yumeNormalizeURL(obj.actor) : yumeNormalizeObject(obj.actor), + object: typeof obj.object === 'string' ? yumeNormalizeURL(obj.object) : yumeNormalizeObject(obj.object), + target: obj.target ? (typeof obj.target === 'string' ? yumeNormalizeURL(obj.target) : yumeNormalizeObject(obj.target)) : undefined, + __yume_normalized_object: true, + }; +} + +export function yumeDowncastAdd(object: IObject): IAdd | null { + if (getApType(object) !== 'Add') return null; + const obj = object as IAdd; + if (!obj.actor || !obj.object ) return null; + return { + ...extractMisskeyVendorKeys(object), + ...extractSafe(object), + type: 'Add', + actor: typeof obj.actor === 'string' ? yumeNormalizeURL(obj.actor) : yumeNormalizeObject(obj.actor), + object: typeof obj.object === 'string' ? yumeNormalizeURL(obj.object) : yumeNormalizeObject(obj.object), + target: obj.target ? (typeof obj.target === 'string' ? yumeNormalizeURL(obj.target) : yumeNormalizeObject(obj.target)) : undefined, + __yume_normalized_object: true, + }; +} + +export function yumeDowncastRemove(object: IObject): IRemove | null { + if (getApType(object) !== 'Remove') return null; + const obj = object as IRemove; + if (!obj.actor || !obj.object) return null; + return { + ...extractMisskeyVendorKeys(object), + ...extractSafe(object), + type: 'Remove', + actor: typeof obj.actor === 'string' ? yumeNormalizeURL(obj.actor) : yumeNormalizeObject(obj.actor), + object: typeof obj.object === 'string' ? yumeNormalizeURL(obj.object) : yumeNormalizeObject(obj.object), + target: obj.target ? (typeof obj.target === 'string' ? yumeNormalizeURL(obj.target) : yumeNormalizeObject(obj.target)) : undefined, + __yume_normalized_object: true, + }; +} + +export function yumeDowncastLike(object: IObject): ILike | null { + if (getApType(object) !== 'Like') return null; + const obj = object as ILike; + if (!obj.actor || !obj.object || !obj.target) return null; + return { + ...extractMisskeyVendorKeys(object), + ...extractSafe(object), + type: 'Like', + actor: typeof obj.actor === 'string' ? yumeNormalizeURL(obj.actor) : yumeNormalizeObject(obj.actor), + object: typeof obj.object === 'string' ? yumeNormalizeURL(obj.object) : yumeNormalizeObject(obj.object), + target: obj.target ? (typeof obj.target === 'string' ? yumeNormalizeURL(obj.target) : yumeNormalizeObject(obj.target)) : undefined, + __yume_normalized_object: true, + }; +} + +export function yumeDowncastAnnounce(object: IObject): IAnnounce | null { + if (getApType(object) !== 'Announce') return null; + const obj = object as IAnnounce; + if (!obj.actor || !obj.object) return null; + return { + // ...extractMisskeyVendorKeys(object), + ...extractSafe(object), + type: 'Announce', + actor: typeof obj.actor === 'string' ? yumeNormalizeURL(obj.actor) : yumeNormalizeObject(obj.actor), + object: typeof obj.object === 'string' ? yumeNormalizeURL(obj.object) : yumeNormalizeObject(obj.object), + target: obj.target ? (typeof obj.target === 'string' ? yumeNormalizeURL(obj.target) : yumeNormalizeObject(obj.target)) : undefined, + __yume_normalized_object: true, + }; +} + +export function yumeDowncastBlock(object: IObject): IBlock | null { + if (getApType(object) !== 'Block') return null; + const obj = object as IBlock; + if (!obj.actor || !obj.object) return null; + return { + ...extractMisskeyVendorKeys(object), + ...extractSafe(object), + type: 'Block', + actor: typeof obj.actor === 'string' ? yumeNormalizeURL(obj.actor) : yumeNormalizeObject(obj.actor), + object: typeof obj.object === 'string' ? yumeNormalizeURL(obj.object) : yumeNormalizeObject(obj.object), + target: obj.target ? (typeof obj.target === 'string' ? yumeNormalizeURL(obj.target) : yumeNormalizeObject(obj.target)) : undefined, + __yume_normalized_object: true, + }; +} + +export function yumeDowncastFlag(object: IObject): IFlag | null { + if (getApType(object) !== 'Flag') return null; + const obj = object as IFlag; + if (!obj.actor || !obj.object) return null; + return { + ...extractMisskeyVendorKeys(object), + ...extractSafe(object), + type: 'Flag', + actor: typeof obj.actor === 'string' ? yumeNormalizeURL(obj.actor) : yumeNormalizeObject(obj.actor), + object: typeof obj.object === 'string' ? yumeNormalizeURL(obj.object) : yumeNormalizeObject(obj.object), + target: obj.target ? (typeof obj.target === 'string' ? yumeNormalizeURL(obj.target) : yumeNormalizeObject(obj.target)) : undefined, + __yume_normalized_object: true, + }; +} + +export function yumeDowncastMove(object: IObject): IMove | null { + if (getApType(object) !== 'Move') return null; + const obj = object as IMove; + if (!obj.actor || !obj.object || !obj.target) return null; + return { + ...extractMisskeyVendorKeys(object), + ...extractSafe(object), + type: 'Move', + actor: typeof obj.actor === 'string' ? yumeNormalizeURL(obj.actor) : yumeNormalizeObject(obj.actor), + object: typeof obj.object === 'string' ? yumeNormalizeURL(obj.object) : yumeNormalizeObject(obj.object), + target: typeof obj.target === 'string' ? yumeNormalizeURL(obj.target) : yumeNormalizeObject(obj.target), + __yume_normalized_object: true, + }; +} +export function yumeDowncastMention(object: IObject): IApMention | null { + if (getApType(object) !== 'Mention') { + return null; + } + + const href = getApHrefNullable(object); + + return { + ...object, + type: 'Mention', + href: href ? yumeNormalizeURL(href) : '', + name: object.name ?? '', + }; +} diff --git a/packages/backend/src/queue/processors/InboxProcessorService.ts b/packages/backend/src/queue/processors/InboxProcessorService.ts index 2e674b0548..ca7e4275de 100644 --- a/packages/backend/src/queue/processors/InboxProcessorService.ts +++ b/packages/backend/src/queue/processors/InboxProcessorService.ts @@ -67,7 +67,7 @@ const mIncomingApReject = metricCounter({ const mincomingApProcessingError = metricCounter({ name: 'misskey_incoming_ap_processing_error', help: 'Incoming AP processing error', - labelNames: ['incoming_host', 'incoming_type'], + labelNames: ['incoming_host', 'incoming_type', 'reason'], }); @Injectable() diff --git a/packages/backend/src/server/api/endpoints/ap/show.ts b/packages/backend/src/server/api/endpoints/ap/show.ts index c52608cefb..6127e104ce 100644 --- a/packages/backend/src/server/api/endpoints/ap/show.ts +++ b/packages/backend/src/server/api/endpoints/ap/show.ts @@ -134,8 +134,8 @@ export default class extends Endpoint { // eslint- return await this.mergePack( me, - isActor(object) ? await this.apPersonService.createPerson(getApId(object)) : null, - isPost(object) ? await this.apNoteService.createNote(getApId(object), undefined, true) : null, + isActor(object) ? await this.apPersonService.createPerson(getApId(object), resolver) : null, + isPost(object) ? await this.apNoteService.createNote(getApId(object), resolver, true) : null, ); } diff --git a/packages/backend/test/unit/activitypub.ts b/packages/backend/test/unit/activitypub.ts index 2fc08aec91..fe336a0252 100644 --- a/packages/backend/test/unit/activitypub.ts +++ b/packages/backend/test/unit/activitypub.ts @@ -19,7 +19,7 @@ import { GlobalModule } from '@/GlobalModule.js'; import { CoreModule } from '@/core/CoreModule.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { LoggerService } from '@/core/LoggerService.js'; -import type { IActor, IApDocument, ICollection, IObject, IPost } from '@/core/activitypub/type.js'; +import { yumeNormalizeObject, type IActor, type IApDocument, type ICollection, type IObject, type IPost } from '@/core/activitypub/type.js'; import { MiMeta, MiNote, UserProfilesRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import { secureRndstr } from '@/misc/secure-rndstr.js'; @@ -42,6 +42,7 @@ function createRandomActor({ actorHost = host } = {}): NonTransientIActor { id: actorId, type: 'Person', preferredUsername, + __yume_normalized_object: true, inbox: `${actorId}/inbox`, outbox: `${actorId}/outbox`, }; @@ -55,6 +56,7 @@ function createRandomNote(actor: NonTransientIActor): NonTransientIPost { id: noteId, type: 'Note', attributedTo: actor.id, + __yume_normalized_object: true, content: 'test test foo', }; } @@ -71,6 +73,7 @@ function createRandomFeaturedCollection(actor: NonTransientIActor, length: numbe type: 'Collection', id: actor.outbox as string, totalItems: items.length, + __yume_normalized_object: true, items, }; } @@ -162,6 +165,34 @@ describe('ActivityPub', () => { content: 'あ', }; + const punnyPost = { + '@context': 'https://www.w3.org/ns/activitystreams', + id: `https://あ.com/users/あ`, + type: 'Note', + attributedTo: actor.id, + to: 'https://www.w3.org/ns/activitystreams#Public', + content: 'あ', + }; + + test('punnyPost normalization', async () => { + const normalized = yumeNormalizeObject(punnyPost); + assert.strictEqual(normalized.id, 'https://xn--l8j.com/users/あ'); + }); + + const portedHost = { + '@context': 'https://www.w3.org/ns/activitystreams', + id: `https://あ.com:12443/users/${secureRndstr(8)}`, + type: 'Note', + to: 'https://www.w3.org/ns/activitystreams#Public', + content: 'あ', + } + + test('actor with port should be rejected', async () => { + assert.throws(() => { + yumeNormalizeObject(portedHost); + }); + }); + test('Minimum Actor', async () => { resolver.register(actor.id, actor); @@ -220,6 +251,7 @@ describe('ActivityPub', () => { type: 'OrderedCollection', totalItems: 0, first: `${actor.id}/following?page=1`, + __yume_normalized_object: true, }; actor.followers = `${actor.id}/followers`; @@ -229,6 +261,7 @@ describe('ActivityPub', () => { type: 'OrderedCollection', totalItems: 0, first: `${actor.followers}?page=1`, + __yume_normalized_object: true, }); const user = await personService.createPerson(actor.id, resolver); @@ -244,6 +277,7 @@ describe('ActivityPub', () => { id: `${actor.id}/following`, type: 'OrderedCollection', totalItems: 0, + __yume_normalized_object: true, // first: … }; actor.followers = `${actor.id}/followers`; @@ -348,6 +382,7 @@ describe('ActivityPub', () => { mediaType: 'image/png', url: 'http://host1.test/foo.png', name: '', + __yume_normalized_object: true, }; const driveFile = await imageService.createImage( await createRandomRemoteUser(resolver, personService), @@ -361,6 +396,7 @@ describe('ActivityPub', () => { url: 'http://host1.test/bar.png', name: '', sensitive: true, + __yume_normalized_object: true, }; const sensitiveDriveFile = await imageService.createImage( await createRandomRemoteUser(resolver, personService), @@ -377,6 +413,7 @@ describe('ActivityPub', () => { mediaType: 'image/png', url: 'http://host1.test/foo.png', name: '', + __yume_normalized_object: true, }; const driveFile = await imageService.createImage( await createRandomRemoteUser(resolver, personService), @@ -390,6 +427,7 @@ describe('ActivityPub', () => { url: 'http://host1.test/bar.png', name: '', sensitive: true, + __yume_normalized_object: true, }; const sensitiveDriveFile = await imageService.createImage( await createRandomRemoteUser(resolver, personService), @@ -406,6 +444,7 @@ describe('ActivityPub', () => { mediaType: 'image/png', url: 'http://host1.test/foo.png', name: '', + __yume_normalized_object: true, }; const driveFile = await imageService.createImage( await createRandomRemoteUser(resolver, personService), @@ -419,6 +458,7 @@ describe('ActivityPub', () => { url: 'http://host1.test/bar.png', name: '', sensitive: true, + __yume_normalized_object: true, }; const sensitiveDriveFile = await imageService.createImage( await createRandomRemoteUser(resolver, personService), @@ -431,6 +471,7 @@ describe('ActivityPub', () => { const linkObject: IObject = { type: 'Link', href: 'https://example.com/', + __yume_normalized_object: true, }; const driveFile = await imageService.createImage( await createRandomRemoteUser(resolver, personService),