From b506dd564b25066b921dccc294010cbd510c53a3 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Mon, 26 May 2025 11:15:59 -0400 Subject: [PATCH 01/10] support fetching anonymous AP objects --- .../backend/src/core/HttpRequestService.ts | 8 +++-- .../src/core/activitypub/ApRequestService.ts | 13 ++++--- .../src/core/activitypub/ApResolverService.ts | 34 ++++++++++++------- 3 files changed, 37 insertions(+), 18 deletions(-) diff --git a/packages/backend/src/core/HttpRequestService.ts b/packages/backend/src/core/HttpRequestService.ts index 5c271b81e3..a0f2607ddc 100644 --- a/packages/backend/src/core/HttpRequestService.ts +++ b/packages/backend/src/core/HttpRequestService.ts @@ -235,7 +235,7 @@ export class HttpRequestService { } @bindThis - public async getActivityJson(url: string, isLocalAddressAllowed = false): Promise { + public async getActivityJson(url: string, isLocalAddressAllowed = false, allowAnonymous = false): Promise { this.apUtilityService.assertApUrl(url); const res = await this.send(url, { @@ -255,7 +255,11 @@ export class HttpRequestService { // Make sure the object ID matches the final URL (which is where it actually exists). // The caller (ApResolverService) will verify the ID against the original / entry URL, which ensures that all three match. - this.apUtilityService.assertIdMatchesUrlAuthority(activity, res.url); + if (allowAnonymous && activity.id == null) { + activity.id = res.url; + } else { + this.apUtilityService.assertIdMatchesUrlAuthority(activity, res.url); + } return activity as IObjectWithId; } diff --git a/packages/backend/src/core/activitypub/ApRequestService.ts b/packages/backend/src/core/activitypub/ApRequestService.ts index b665b51700..4c7cac2169 100644 --- a/packages/backend/src/core/activitypub/ApRequestService.ts +++ b/packages/backend/src/core/activitypub/ApRequestService.ts @@ -184,10 +184,11 @@ export class ApRequestService { * Get AP object with http-signature * @param user http-signature user * @param url URL to fetch - * @param followAlternate + * @param allowAnonymous If a fetched object lacks an ID, then it will be auto-generated from the final URL. (default: false) + * @param followAlternate Whether to resolve HTML responses to their referenced canonical AP endpoint. (default: true) */ @bindThis - public async signedGet(url: string, user: { id: MiUser['id'] }, followAlternate?: boolean): Promise { + public async signedGet(url: string, user: { id: MiUser['id'] }, allowAnonymous = false, followAlternate?: boolean): Promise { this.apUtilityService.assertApUrl(url); const _followAlternate = followAlternate ?? true; @@ -258,7 +259,7 @@ export class ApRequestService { if (alternate) { const href = alternate.getAttribute('href'); if (href && this.apUtilityService.haveSameAuthority(url, href)) { - return await this.signedGet(href, user, false); + return await this.signedGet(href, user, allowAnonymous, false); } } } catch { @@ -275,7 +276,11 @@ export class ApRequestService { // Make sure the object ID matches the final URL (which is where it actually exists). // The caller (ApResolverService) will verify the ID against the original / entry URL, which ensures that all three match. - this.apUtilityService.assertIdMatchesUrlAuthority(activity, res.url); + if (allowAnonymous && activity.id == null) { + activity.id = res.url; + } else { + this.apUtilityService.assertIdMatchesUrlAuthority(activity, res.url); + } return activity as IObjectWithId; } diff --git a/packages/backend/src/core/activitypub/ApResolverService.ts b/packages/backend/src/core/activitypub/ApResolverService.ts index 5e58f848c0..a7c5125928 100644 --- a/packages/backend/src/core/activitypub/ApResolverService.ts +++ b/packages/backend/src/core/activitypub/ApResolverService.ts @@ -63,10 +63,12 @@ export class Resolver { return this.recursionLimit; } + public async resolveCollection(value: string, allowAnonymous?: boolean): Promise; + public async resolveCollection(value: string | IObject, allowAnonymous?: boolean): Promise; @bindThis - public async resolveCollection(value: string | IObject): Promise { + public async resolveCollection(value: string | IObject, allowAnonymous?: boolean): Promise { const collection = typeof value === 'string' - ? await this.resolve(value) + ? await this.resolve(value, allowAnonymous) : value; if (isCollectionOrOrderedCollection(collection)) { @@ -103,25 +105,33 @@ export class Resolver { return await this.resolve(id); } - public async resolve(value: string | [string]): Promise; - public async resolve(value: string | IObject | [string | IObject]): Promise; + public async resolve(value: string | [string], allowAnonymous?: boolean): Promise; + public async resolve(value: string | IObject | [string | IObject], allowAnonymous?: boolean): Promise; + /** + * Resolves a URL or object to an AP object. + * Tuples are expanded to their first element before anything else, and non-string inputs are returned as-is. + * Otherwise, the string URL is fetched and validated to represent a valid ActivityPub object. + * @param value The input value to resolve + * @param allowAnonymous Determines what to do if a response object lacks an ID field. If false (default), then an exception is thrown. If true, then the ID is populated from the final response URL. + */ @bindThis - public async resolve(value: string | IObject | [string | IObject]): Promise { + public async resolve(value: string | IObject | [string | IObject], allowAnonymous = false): Promise { value = fromTuple(value); + // TODO try and remove this eventually, as it's a major security foot-gun if (typeof value !== 'string') { return value; } const host = this.utilityService.extractDbHost(value); if (this.config.activityLogging.enabled && !this.utilityService.isSelfHost(host)) { - return await this._resolveLogged(value, host); + return await this._resolveLogged(value, host, allowAnonymous); } else { - return await this._resolve(value, host); + return await this._resolve(value, host, allowAnonymous); } } - private async _resolveLogged(requestUri: string, host: string): Promise { + private async _resolveLogged(requestUri: string, host: string, allowAnonymous: boolean): Promise { const startTime = process.hrtime.bigint(); const log = await this.apLogService.createFetchLog({ @@ -130,7 +140,7 @@ export class Resolver { }); try { - const result = await this._resolve(requestUri, host, log); + const result = await this._resolve(requestUri, host, allowAnonymous, log); log.accepted = true; log.result = 'ok'; @@ -150,7 +160,7 @@ export class Resolver { } } - private async _resolve(value: string, host: string, log?: SkApFetchLog): Promise { + private async _resolve(value: string, host: string, allowAnonymous: boolean, log?: SkApFetchLog): Promise { if (value.includes('#')) { // URLs with fragment parts cannot be resolved correctly because // the fragment part does not get transmitted over HTTP(S). @@ -181,8 +191,8 @@ export class Resolver { } const object = (this.user - ? await this.apRequestService.signedGet(value, this.user) - : await this.httpRequestService.getActivityJson(value)); + ? await this.apRequestService.signedGet(value, this.user, allowAnonymous) + : await this.httpRequestService.getActivityJson(value, false, allowAnonymous)); if (log) { const { object: objectOnly, context, contextHash } = extractObjectContext(object); From 5f0bb5dcd7ced04035e409f7768c6bfccb950683 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Mon, 26 May 2025 11:16:48 -0400 Subject: [PATCH 02/10] implement resolver.resolveCollectionItems --- .../src/core/activitypub/ApResolverService.ts | 64 ++++++++++++++++++- packages/backend/src/core/activitypub/type.ts | 48 +++++++------- packages/misskey-js/src/autogen/types.ts | 2 + 3 files changed, 87 insertions(+), 27 deletions(-) diff --git a/packages/backend/src/core/activitypub/ApResolverService.ts b/packages/backend/src/core/activitypub/ApResolverService.ts index a7c5125928..74050f456b 100644 --- a/packages/backend/src/core/activitypub/ApResolverService.ts +++ b/packages/backend/src/core/activitypub/ApResolverService.ts @@ -19,11 +19,12 @@ import { ApLogService, calculateDurationSince, extractObjectContext } from '@/co import { ApUtilityService } from '@/core/activitypub/ApUtilityService.js'; import { SystemAccountService } from '@/core/SystemAccountService.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; -import { getApId, getNullableApId, IObjectWithId, isCollectionOrOrderedCollection } from './type.js'; +import { toArray } from '@/misc/prelude/array.js'; +import { AnyCollection, getApId, getNullableApId, IObjectWithId, isCollection, isCollectionOrOrderedCollection, isCollectionPage, isOrderedCollection, isOrderedCollectionPage } from './type.js'; import { ApDbResolverService } from './ApDbResolverService.js'; import { ApRendererService } from './ApRendererService.js'; import { ApRequestService } from './ApRequestService.js'; -import type { IObject, ICollection, IOrderedCollection, ApObject } from './type.js'; +import type { IObject, ApObject } from './type.js'; export class Resolver { private history: Set; @@ -78,6 +79,65 @@ export class Resolver { } } + @bindThis + public async resolveCollectionItems(value: string | IObject, limit?: number, allowAnonymousItems?: boolean): Promise { + const items: IObjectWithId[] = []; + + const collection = await this.resolveCollection(value); + await this.resolveCollectionItemsTo(collection, limit, allowAnonymousItems, collection.id, items); + + return items; + } + + private async resolveCollectionItemsTo(current: AnyCollection | null, limit: number | undefined, allowAnonymousItems: boolean | undefined, sourceUri: string | undefined, destination: IObjectWithId[]): Promise { + // This is pulled up to avoid code duplication below + const iterate = async(items: ApObject): Promise => { + for (const item of toArray(items)) { + // Stop when we reach the fetch limit + if (this.history.size > this.recursionLimit) break; + + // Stop when we reach the item limit + if (limit != null && limit < 1) break; + + // Use secureResolve whenever possible, to avoid re-fetching items that were included inline. + const resolved = (sourceUri && !allowAnonymousItems) + ? await this.secureResolve(item, sourceUri) + : await this.resolve(getApId(item), allowAnonymousItems); + destination.push(resolved); + + // Decrement the outer variable directly, because the code below checks it too + if (limit != null) limit--; + } + }; + + while (current != null) { + // Iterate all items in the current page + if (current.items) { + await iterate(current.items); + } + if (current.orderedItems) { + await iterate(current.orderedItems); + } + + if (this.history.size >= this.recursionLimit) { + // Stop when we reach the fetch limit + current = null; + } else if (limit != null && limit < 1) { + // Stop when we reach the item limit + current = null; + } else if (isCollection(current) || isOrderedCollection(current)) { + // Continue to first page + current = current.first ? await this.resolveCollection(current.first, true) : null; + } else if (isCollectionPage(current) || isOrderedCollectionPage(current)) { + // Continue to next page + current = current.next ? await this.resolveCollection(current.next, true) : null; + } else { + // Stop in all other conditions + current = null; + } + } + } + /** * Securely resolves an AP object or URL that has been sent from another instance. * An input object is trusted if and only if its ID matches the authority of sentFromUri. diff --git a/packages/backend/src/core/activitypub/type.ts b/packages/backend/src/core/activitypub/type.ts index 281733d484..e33dec18d7 100644 --- a/packages/backend/src/core/activitypub/type.ts +++ b/packages/backend/src/core/activitypub/type.ts @@ -125,48 +125,46 @@ export interface IActivity extends IObject { }; } -export interface ICollection extends IObject { +export interface CollectionBase extends IObject { + totalItems?: number; + first?: IObject | string; + last?: IObject | string; + current?: IObject | string; + partOf?: IObject | string; + next?: IObject | string; + prev?: IObject | string; + items?: ApObject; + orderedItems?: ApObject; +} + +export interface ICollection extends CollectionBase { type: 'Collection'; totalItems: number; - first?: IObject | string; - last?: IObject | string; - current?: IObject | string; items?: ApObject; + orderedItems?: undefined; } -export interface IOrderedCollection extends IObject { +export interface IOrderedCollection extends CollectionBase { type: 'OrderedCollection'; totalItems: number; - first?: IObject | string; - last?: IObject | string; - current?: IObject | string; + items?: undefined; orderedItems?: ApObject; } -export interface ICollectionPage extends IObject { +export interface ICollectionPage extends CollectionBase { type: 'CollectionPage'; - totalItems: number; - first?: IObject | string; - last?: IObject | string; - current?: IObject | string; - partOf?: IObject | string; - next?: IObject | string; - prev?: IObject | string; items?: ApObject; + orderedItems?: undefined; } -export interface IOrderedCollectionPage extends IObject { +export interface IOrderedCollectionPage extends CollectionBase { type: 'OrderedCollectionPage'; - totalItems: number; - first?: IObject | string; - last?: IObject | string; - current?: IObject | string; - partOf?: IObject | string; - next?: IObject | string; - prev?: IObject | string; + items?: undefined; orderedItems?: ApObject; } +export type AnyCollection = ICollection | IOrderedCollection | ICollectionPage | IOrderedCollectionPage; + export const validPost = ['Note', 'Question', 'Article', 'Audio', 'Document', 'Image', 'Page', 'Video', 'Event']; export const isPost = (object: IObject): object is IPost => { @@ -269,7 +267,7 @@ export const isCollectionPage = (object: IObject): object is ICollectionPage => export const isOrderedCollectionPage = (object: IObject): object is IOrderedCollectionPage => getApType(object) === 'OrderedCollectionPage'; -export const isCollectionOrOrderedCollection = (object: IObject): object is ICollection | IOrderedCollection => +export const isCollectionOrOrderedCollection = (object: IObject): object is AnyCollection => isCollection(object) || isOrderedCollection(object) || isCollectionPage(object) || isOrderedCollectionPage(object); export interface IApPropertyValue extends IObject { diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index 55302960dc..678e980892 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -12918,6 +12918,8 @@ export type operations = { content: { 'application/json': { uri: string; + expandCollectionItems?: boolean; + allowAnonymous?: boolean; }; }; }; From bdccb203ea01c897a6818498d54681dd137f63aa Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Mon, 26 May 2025 11:17:01 -0400 Subject: [PATCH 03/10] resolve collection items in ApInboxService --- .../src/core/activitypub/ApInboxService.ts | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts index b8526a972c..2b0da52332 100644 --- a/packages/backend/src/core/activitypub/ApInboxService.ts +++ b/packages/backend/src/core/activitypub/ApInboxService.ts @@ -36,7 +36,7 @@ import InstanceChart from '@/core/chart/charts/instance.js'; import FederationChart from '@/core/chart/charts/federation.js'; import { FetchInstanceMetadataService } from '@/core/FetchInstanceMetadataService.js'; import { UpdateInstanceQueue } from '@/core/UpdateInstanceQueue.js'; -import { getApHrefNullable, getApId, getApIds, getApType, getNullableApId, isAccept, isActor, isAdd, isAnnounce, isApObject, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isDislike, isMove, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost, isActivity, IObjectWithId } from './type.js'; +import { getApHrefNullable, getApId, getApIds, getApType, getNullableApId, isAccept, isActor, isAdd, isAnnounce, isApObject, isBlock, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isDislike, isMove, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost, isActivity, IObjectWithId } from './type.js'; import { ApNoteService } from './models/ApNoteService.js'; import { ApLoggerService } from './ApLoggerService.js'; import { ApDbResolverService } from './ApDbResolverService.js'; @@ -106,22 +106,16 @@ export class ApInboxService { let result = undefined as string | void; if (isCollectionOrOrderedCollection(activity)) { const results = [] as [string, string | void][]; - // eslint-disable-next-line no-param-reassign resolver ??= this.apResolverService.createResolver(); - const items = toArray(isCollection(activity) ? activity.items : activity.orderedItems); - if (items.length >= resolver.getRecursionLimit()) { - throw new Error(`skipping activity: collection would surpass recursion limit: ${this.utilityService.extractDbHost(actor.uri)}`); - } - - for (const item of items) { - const act = await resolver.resolve(item); - if (act.id == null || this.utilityService.extractDbHost(act.id) !== this.utilityService.extractDbHost(actor.uri)) { + const items = await resolver.resolveCollectionItems(activity); + for (const act of items) { + if (this.utilityService.extractDbHost(act.id) !== this.utilityService.extractDbHost(actor.uri)) { this.logger.debug('skipping activity: activity id is null or mismatching'); continue; } try { - results.push([getApId(item), await this.performOneActivity(actor, act, resolver)]); + results.push([getApId(act), await this.performOneActivity(actor, act, resolver)]); } catch (err) { if (err instanceof Error || typeof err === 'string') { this.logger.error(err); From e689c047644e4083bafe8e2a79ff9a677fa96e1b Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Mon, 26 May 2025 11:17:20 -0400 Subject: [PATCH 04/10] add options expandCollectionItems and allowAnonymous to ap/get endpoint --- .../backend/src/server/api/endpoints/ap/get.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/server/api/endpoints/ap/get.ts b/packages/backend/src/server/api/endpoints/ap/get.ts index 14286bc23e..3fe5c60a44 100644 --- a/packages/backend/src/server/api/endpoints/ap/get.ts +++ b/packages/backend/src/server/api/endpoints/ap/get.ts @@ -7,6 +7,7 @@ import { Injectable } from '@nestjs/common'; import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { ApResolverService } from '@/core/activitypub/ApResolverService.js'; +import { isCollectionOrOrderedCollection, isOrderedCollection, isOrderedCollectionPage } from '@/core/activitypub/type.js'; export const meta = { tags: ['federation'], @@ -33,6 +34,8 @@ export const paramDef = { type: 'object', properties: { uri: { type: 'string' }, + expandCollectionItems: { type: 'boolean' }, + allowAnonymous: { type: 'boolean' }, }, required: ['uri'], } as const; @@ -44,7 +47,18 @@ export default class extends Endpoint { // eslint- ) { super(meta, paramDef, async (ps, me) => { const resolver = this.apResolverService.createResolver(); - const object = await resolver.resolve(ps.uri); + const object = await resolver.resolve(ps.uri, ps.allowAnonymous ?? false); + + if (ps.expandCollectionItems && isCollectionOrOrderedCollection(object)) { + const items = await resolver.resolveCollectionItems(object, undefined, ps.allowAnonymous ?? false); + + if (isOrderedCollection(object) || isOrderedCollectionPage(object)) { + object.orderedItems = items; + } else { + object.items = items; + } + } + return object; }); } From 02787f75ef194474d23c94cf5ac8a34a5f9a19d0 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Mon, 26 May 2025 11:22:18 -0400 Subject: [PATCH 05/10] add JSDocs to resolveCollectionItems --- .../src/core/activitypub/ApResolverService.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/backend/src/core/activitypub/ApResolverService.ts b/packages/backend/src/core/activitypub/ApResolverService.ts index 74050f456b..8312294a1f 100644 --- a/packages/backend/src/core/activitypub/ApResolverService.ts +++ b/packages/backend/src/core/activitypub/ApResolverService.ts @@ -79,12 +79,21 @@ export class Resolver { } } + /** + * Recursively resolves items from a collection. + * Stops when reaching the resolution limit or an optional item limit - whichever is lower. + * This method supports Collection, OrderedCollection, and individual pages of either type. + * Malformed collections (mixing Ordered and un-Ordered types) are also supported. + * @param collection Collection to resolve from - can be a URL or object of any supported collection type. + * @param limit Maximum number of items to resolve. If null or undefined (default), then items will be resolved until reaching the recursion limit. + * @param allowAnonymousItems If true, collection items can be anonymous (lack an ID). If false (default), then an error is thrown when reaching an item without ID. + */ @bindThis - public async resolveCollectionItems(value: string | IObject, limit?: number, allowAnonymousItems?: boolean): Promise { + public async resolveCollectionItems(collection: string | IObject, limit?: number | null, allowAnonymousItems?: boolean): Promise { const items: IObjectWithId[] = []; - const collection = await this.resolveCollection(value); - await this.resolveCollectionItemsTo(collection, limit, allowAnonymousItems, collection.id, items); + const collectionObj = await this.resolveCollection(collection); + await this.resolveCollectionItemsTo(collectionObj, limit ?? undefined, allowAnonymousItems, collectionObj.id, items); return items; } From facedd364667e09f280cdb903d86748ba7d22629 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Mon, 26 May 2025 11:33:53 -0400 Subject: [PATCH 06/10] allow anonymous objects in secureResolve --- .../src/core/activitypub/ApResolverService.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/backend/src/core/activitypub/ApResolverService.ts b/packages/backend/src/core/activitypub/ApResolverService.ts index 8312294a1f..1b4fed4461 100644 --- a/packages/backend/src/core/activitypub/ApResolverService.ts +++ b/packages/backend/src/core/activitypub/ApResolverService.ts @@ -151,16 +151,26 @@ export class Resolver { * Securely resolves an AP object or URL that has been sent from another instance. * An input object is trusted if and only if its ID matches the authority of sentFromUri. * In all other cases, the object is re-fetched from remote by input string or object ID. + * @param input The input object or URL to resolve + * @param sentFromUri The URL where this object originated. This MUST be accurate - all security checks depend on this value! + * @param allowAnonymous If true, anonymous objects are allowed and will have their ID set to sentFromUri. If false (default) then anonymous objects will be rejected with an error. */ @bindThis - public async secureResolve(input: ApObject, sentFromUri: string): Promise { + public async secureResolve(input: ApObject, sentFromUri: string, allowAnonymous?: boolean): Promise { // Unpack arrays to get the value element. const value = fromTuple(input); if (value == null) { throw new IdentifiableError('20058164-9de1-4573-8715-425753a21c1d', 'Cannot resolve null input'); } - // This will throw if the input has no ID, which is good because we can't verify an anonymous object anyway. + // If anonymous input is allowed, then any object is automatically valid if we set the ID. + // We can short-circuit here and avoid un-necessary checks. + if (allowAnonymous && typeof(value) === 'object' && value.id == null) { + value.id = sentFromUri; + return value as IObjectWithId; + } + + // This ensures the input has a string ID, protecting against type confusion and rejecting anonymous objects. const id = getApId(value); // Check if we can use the provided object as-is. @@ -171,7 +181,7 @@ export class Resolver { } // If the checks didn't pass, then we must fetch the object and use that. - return await this.resolve(id); + return await this.resolve(id, allowAnonymous); } public async resolve(value: string | [string], allowAnonymous?: boolean): Promise; From 1ab5ceb65aab5170a556e1880ea07b6eccc5d710 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Mon, 26 May 2025 11:34:46 -0400 Subject: [PATCH 07/10] fix ID checks in resolveCollectionItems --- .../backend/src/core/activitypub/ApResolverService.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/backend/src/core/activitypub/ApResolverService.ts b/packages/backend/src/core/activitypub/ApResolverService.ts index 1b4fed4461..6b95d9c93b 100644 --- a/packages/backend/src/core/activitypub/ApResolverService.ts +++ b/packages/backend/src/core/activitypub/ApResolverService.ts @@ -93,12 +93,12 @@ export class Resolver { const items: IObjectWithId[] = []; const collectionObj = await this.resolveCollection(collection); - await this.resolveCollectionItemsTo(collectionObj, limit ?? undefined, allowAnonymousItems, collectionObj.id, items); + await this.resolveCollectionItemsTo(collectionObj, limit ?? undefined, allowAnonymousItems, items); return items; } - private async resolveCollectionItemsTo(current: AnyCollection | null, limit: number | undefined, allowAnonymousItems: boolean | undefined, sourceUri: string | undefined, destination: IObjectWithId[]): Promise { + private async resolveCollectionItemsTo(current: AnyCollection | null, limit: number | undefined, allowAnonymousItems: boolean | undefined, destination: IObjectWithId[]): Promise { // This is pulled up to avoid code duplication below const iterate = async(items: ApObject): Promise => { for (const item of toArray(items)) { @@ -109,8 +109,8 @@ export class Resolver { if (limit != null && limit < 1) break; // Use secureResolve whenever possible, to avoid re-fetching items that were included inline. - const resolved = (sourceUri && !allowAnonymousItems) - ? await this.secureResolve(item, sourceUri) + const resolved = current?.id + ? await this.secureResolve(item, current.id, allowAnonymousItems) : await this.resolve(getApId(item), allowAnonymousItems); destination.push(resolved); From 3da3ce9a40085f55da7dc9a911d1c03796ec0681 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Mon, 26 May 2025 11:43:05 -0400 Subject: [PATCH 08/10] pass limit from ap/get to resolveCollectionItems --- packages/backend/src/server/api/endpoints/ap/get.ts | 3 ++- packages/misskey-js/src/autogen/types.ts | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/server/api/endpoints/ap/get.ts b/packages/backend/src/server/api/endpoints/ap/get.ts index 3fe5c60a44..06dd37a140 100644 --- a/packages/backend/src/server/api/endpoints/ap/get.ts +++ b/packages/backend/src/server/api/endpoints/ap/get.ts @@ -35,6 +35,7 @@ export const paramDef = { properties: { uri: { type: 'string' }, expandCollectionItems: { type: 'boolean' }, + expandCollectionLimit: { type: 'integer', nullable: true }, allowAnonymous: { type: 'boolean' }, }, required: ['uri'], @@ -50,7 +51,7 @@ export default class extends Endpoint { // eslint- const object = await resolver.resolve(ps.uri, ps.allowAnonymous ?? false); if (ps.expandCollectionItems && isCollectionOrOrderedCollection(object)) { - const items = await resolver.resolveCollectionItems(object, undefined, ps.allowAnonymous ?? false); + const items = await resolver.resolveCollectionItems(object, ps.expandCollectionLimit, ps.allowAnonymous ?? false); if (isOrderedCollection(object) || isOrderedCollectionPage(object)) { object.orderedItems = items; diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index 678e980892..5e5f4f5db5 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -12919,6 +12919,7 @@ export type operations = { 'application/json': { uri: string; expandCollectionItems?: boolean; + expandCollectionLimit?: number | null; allowAnonymous?: boolean; }; }; From a3f9ff68fa2e1b08d9a32560f72288dd550a5d99 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Mon, 26 May 2025 13:08:05 -0400 Subject: [PATCH 09/10] resolve collection items in parallel --- .../src/core/activitypub/ApResolverService.ts | 82 ++++++++++--------- 1 file changed, 44 insertions(+), 38 deletions(-) diff --git a/packages/backend/src/core/activitypub/ApResolverService.ts b/packages/backend/src/core/activitypub/ApResolverService.ts index 6b95d9c93b..cf370c730c 100644 --- a/packages/backend/src/core/activitypub/ApResolverService.ts +++ b/packages/backend/src/core/activitypub/ApResolverService.ts @@ -5,6 +5,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { IsNull, Not } from 'typeorm'; +import promiseLimit from 'promise-limit'; import type { MiLocalUser, MiRemoteUser } from '@/models/User.js'; import type { NotesRepository, PollsRepository, NoteReactionsRepository, UsersRepository, FollowRequestsRepository, MiMeta, SkApFetchLog } from '@/models/_.js'; import type { Config } from '@/config.js'; @@ -64,13 +65,16 @@ export class Resolver { return this.recursionLimit; } - public async resolveCollection(value: string, allowAnonymous?: boolean): Promise; - public async resolveCollection(value: string | IObject, allowAnonymous?: boolean): Promise; + public async resolveCollection(value: string | IObjectWithId, allowAnonymous?: boolean, sentFromUri?: string): Promise; + public async resolveCollection(value: string | IObject, allowAnonymous: boolean | undefined, sentFromUri: string): Promise; + public async resolveCollection(value: string | IObject, allowAnonymous?: boolean, sentFromUri?: string): Promise; @bindThis - public async resolveCollection(value: string | IObject, allowAnonymous?: boolean): Promise { + public async resolveCollection(value: string | IObject, allowAnonymous?: boolean, sentFromUri?: string): Promise { const collection = typeof value === 'string' - ? await this.resolve(value, allowAnonymous) - : value; + ? sentFromUri + ? await this.secureResolve(value, sentFromUri, allowAnonymous) + : await this.resolve(value, allowAnonymous) + : value; // TODO try and remove this eventually, as it's a major security foot-gun if (isCollectionOrOrderedCollection(collection)) { return collection; @@ -87,66 +91,67 @@ export class Resolver { * @param collection Collection to resolve from - can be a URL or object of any supported collection type. * @param limit Maximum number of items to resolve. If null or undefined (default), then items will be resolved until reaching the recursion limit. * @param allowAnonymousItems If true, collection items can be anonymous (lack an ID). If false (default), then an error is thrown when reaching an item without ID. + * @param concurrency Maximum number of items to resolve at once. (default: 4) */ @bindThis - public async resolveCollectionItems(collection: string | IObject, limit?: number | null, allowAnonymousItems?: boolean): Promise { - const items: IObjectWithId[] = []; + public async resolveCollectionItems(collection: string | IObjectWithId, limit?: number | null, allowAnonymousItems?: boolean, concurrency = 4): Promise { + const resolvedItems: IObjectWithId[] = []; - const collectionObj = await this.resolveCollection(collection); - await this.resolveCollectionItemsTo(collectionObj, limit ?? undefined, allowAnonymousItems, items); - - return items; - } - - private async resolveCollectionItemsTo(current: AnyCollection | null, limit: number | undefined, allowAnonymousItems: boolean | undefined, destination: IObjectWithId[]): Promise { // This is pulled up to avoid code duplication below - const iterate = async(items: ApObject): Promise => { - for (const item of toArray(items)) { - // Stop when we reach the fetch limit - if (this.history.size > this.recursionLimit) break; - - // Stop when we reach the item limit - if (limit != null && limit < 1) break; - - // Use secureResolve whenever possible, to avoid re-fetching items that were included inline. - const resolved = current?.id - ? await this.secureResolve(item, current.id, allowAnonymousItems) - : await this.resolve(getApId(item), allowAnonymousItems); - destination.push(resolved); - - // Decrement the outer variable directly, because the code below checks it too - if (limit != null) limit--; - } + const iterate = async(items: ApObject, current: AnyCollection & IObjectWithId) => { + const sentFrom = current.id; + const itemArr = toArray(items); + const itemLimit = limit ?? Number.MAX_SAFE_INTEGER; + const allowAnonymous = allowAnonymousItems ?? false; + await this.resolveItemArray(itemArr, sentFrom, itemLimit, concurrency, allowAnonymous, resolvedItems); }; - while (current != null) { + let current: (AnyCollection & IObjectWithId) | null = await this.resolveCollection(collection); + do { // Iterate all items in the current page if (current.items) { - await iterate(current.items); + await iterate(current.items, current); } if (current.orderedItems) { - await iterate(current.orderedItems); + await iterate(current.orderedItems, current); } if (this.history.size >= this.recursionLimit) { // Stop when we reach the fetch limit current = null; - } else if (limit != null && limit < 1) { + } else if (limit != null && resolvedItems.length >= limit) { // Stop when we reach the item limit current = null; } else if (isCollection(current) || isOrderedCollection(current)) { // Continue to first page - current = current.first ? await this.resolveCollection(current.first, true) : null; + current = current.first ? await this.resolveCollection(current.first, true, current.id) : null; } else if (isCollectionPage(current) || isOrderedCollectionPage(current)) { // Continue to next page - current = current.next ? await this.resolveCollection(current.next, true) : null; + current = current.next ? await this.resolveCollection(current.next, true, current.id) : null; } else { // Stop in all other conditions current = null; } - } + } while (current != null); + + return resolvedItems; } + private async resolveItemArray(source: (string | IObject)[], sentFrom: string, itemLimit: number, concurrency: number, allowAnonymousItems: boolean, destination: IObjectWithId[]): Promise { + const recursionLimit = this.recursionLimit - this.history.size; + const batchLimit = Math.min(source.length, recursionLimit, itemLimit); + + const limiter = promiseLimit(concurrency); + const batch = await Promise.all(source + .slice(0, batchLimit) + .map(item => limiter(async () => { + // Use secureResolve to avoid re-fetching items that were included inline. + return await this.secureResolve(item, sentFrom, allowAnonymousItems); + }))); + + destination.push(...batch); + }; + /** * Securely resolves an AP object or URL that has been sent from another instance. * An input object is trusted if and only if its ID matches the authority of sentFromUri. @@ -185,6 +190,7 @@ export class Resolver { } public async resolve(value: string | [string], allowAnonymous?: boolean): Promise; + public async resolve(value: string | IObjectWithId | [string | IObjectWithId], allowAnonymous?: boolean): Promise; public async resolve(value: string | IObject | [string | IObject], allowAnonymous?: boolean): Promise; /** * Resolves a URL or object to an AP object. From 13d7326506402a6474e33567949c78850dcba1c3 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Mon, 26 May 2025 22:55:10 -0400 Subject: [PATCH 10/10] fix type errors --- .../src/core/activitypub/ApInboxService.ts | 19 +++++-- .../src/core/activitypub/ApResolverService.ts | 52 ++++++++++++++----- packages/backend/src/core/activitypub/type.ts | 12 +++++ 3 files changed, 65 insertions(+), 18 deletions(-) diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts index 2b0da52332..0c3b8359ea 100644 --- a/packages/backend/src/core/activitypub/ApInboxService.ts +++ b/packages/backend/src/core/activitypub/ApInboxService.ts @@ -109,13 +109,22 @@ export class ApInboxService { resolver ??= this.apResolverService.createResolver(); const items = await resolver.resolveCollectionItems(activity); - for (const act of items) { - if (this.utilityService.extractDbHost(act.id) !== this.utilityService.extractDbHost(actor.uri)) { - this.logger.debug('skipping activity: activity id is null or mismatching'); - continue; + for (let i = 0; i < items.length; i++) { + const act = items[i]; + if (act.id != null) { + if (this.utilityService.extractDbHost(act.id) !== this.utilityService.extractDbHost(actor.uri)) { + this.logger.warn('skipping activity: activity id mismatch'); + continue; + } + } else { + // Activity ID should only be string or undefined. + act.id = undefined; } + try { - results.push([getApId(act), await this.performOneActivity(actor, act, resolver)]); + const id = getNullableApId(act) ?? `${getNullableApId(activity)}#${i}`; + const result = await this.performOneActivity(actor, act, resolver); + results.push([id, result]); } catch (err) { if (err instanceof Error || typeof err === 'string') { this.logger.error(err); diff --git a/packages/backend/src/core/activitypub/ApResolverService.ts b/packages/backend/src/core/activitypub/ApResolverService.ts index cf370c730c..7997eccced 100644 --- a/packages/backend/src/core/activitypub/ApResolverService.ts +++ b/packages/backend/src/core/activitypub/ApResolverService.ts @@ -25,7 +25,7 @@ import { AnyCollection, getApId, getNullableApId, IObjectWithId, isCollection, i import { ApDbResolverService } from './ApDbResolverService.js'; import { ApRendererService } from './ApRendererService.js'; import { ApRequestService } from './ApRequestService.js'; -import type { IObject, ApObject } from './type.js'; +import type { IObject, ApObject, IAnonymousObject } from './type.js'; export class Resolver { private history: Set; @@ -83,6 +83,9 @@ export class Resolver { } } + public async resolveCollectionItems(collection: IAnonymousObject, limit?: number | null, allowAnonymousItems?: true, concurrency?: number): Promise; + public async resolveCollectionItems(collection: string | IObjectWithId, limit?: number | null, allowAnonymousItems?: boolean, concurrency?: number): Promise; + public async resolveCollectionItems(collection: string | IObject, limit?: number | null, allowAnonymousItems?: boolean, concurrency?: number): Promise; /** * Recursively resolves items from a collection. * Stops when reaching the resolution limit or an optional item limit - whichever is lower. @@ -94,11 +97,11 @@ export class Resolver { * @param concurrency Maximum number of items to resolve at once. (default: 4) */ @bindThis - public async resolveCollectionItems(collection: string | IObjectWithId, limit?: number | null, allowAnonymousItems?: boolean, concurrency = 4): Promise { - const resolvedItems: IObjectWithId[] = []; + public async resolveCollectionItems(collection: string | IObject, limit?: number | null, allowAnonymousItems?: boolean, concurrency = 4): Promise { + const resolvedItems: IObject[] = []; // This is pulled up to avoid code duplication below - const iterate = async(items: ApObject, current: AnyCollection & IObjectWithId) => { + const iterate = async(items: ApObject, current: AnyCollection) => { const sentFrom = current.id; const itemArr = toArray(items); const itemLimit = limit ?? Number.MAX_SAFE_INTEGER; @@ -106,7 +109,7 @@ export class Resolver { await this.resolveItemArray(itemArr, sentFrom, itemLimit, concurrency, allowAnonymous, resolvedItems); }; - let current: (AnyCollection & IObjectWithId) | null = await this.resolveCollection(collection); + let current: AnyCollection | null = await this.resolveCollection(collection); do { // Iterate all items in the current page if (current.items) { @@ -137,16 +140,27 @@ export class Resolver { return resolvedItems; } - private async resolveItemArray(source: (string | IObject)[], sentFrom: string, itemLimit: number, concurrency: number, allowAnonymousItems: boolean, destination: IObjectWithId[]): Promise { + private async resolveItemArray(source: (string | IObject)[], sentFrom: undefined, itemLimit: number, concurrency: number, allowAnonymousItems: true, destination: IAnonymousObject[]): Promise; + private async resolveItemArray(source: (string | IObject)[], sentFrom: string, itemLimit: number, concurrency: number, allowAnonymousItems: boolean, destination: IObjectWithId[]): Promise; + private async resolveItemArray(source: (string | IObject)[], sentFrom: string | undefined, itemLimit: number, concurrency: number, allowAnonymousItems: boolean, destination: IObject[]): Promise; + private async resolveItemArray(source: (string | IObject)[], sentFrom: string | undefined, itemLimit: number, concurrency: number, allowAnonymousItems: boolean, destination: IObject[]): Promise { const recursionLimit = this.recursionLimit - this.history.size; const batchLimit = Math.min(source.length, recursionLimit, itemLimit); - const limiter = promiseLimit(concurrency); + const limiter = promiseLimit(concurrency); const batch = await Promise.all(source .slice(0, batchLimit) .map(item => limiter(async () => { - // Use secureResolve to avoid re-fetching items that were included inline. - return await this.secureResolve(item, sentFrom, allowAnonymousItems); + if (sentFrom) { + // Use secureResolve to avoid re-fetching items that were included inline. + return await this.secureResolve(item, sentFrom, allowAnonymousItems); + } else if (allowAnonymousItems) { + return await this.resolveAnonymous(item); + } else { + // ID is required if we have neither sentFrom not allowAnonymousItems + const id = getApId(item); + return await this.resolve(id); + } }))); destination.push(...batch); @@ -161,12 +175,9 @@ export class Resolver { * @param allowAnonymous If true, anonymous objects are allowed and will have their ID set to sentFromUri. If false (default) then anonymous objects will be rejected with an error. */ @bindThis - public async secureResolve(input: ApObject, sentFromUri: string, allowAnonymous?: boolean): Promise { + public async secureResolve(input: string | IObject | [string | IObject], sentFromUri: string, allowAnonymous?: boolean): Promise { // Unpack arrays to get the value element. const value = fromTuple(input); - if (value == null) { - throw new IdentifiableError('20058164-9de1-4573-8715-425753a21c1d', 'Cannot resolve null input'); - } // If anonymous input is allowed, then any object is automatically valid if we set the ID. // We can short-circuit here and avoid un-necessary checks. @@ -189,6 +200,21 @@ export class Resolver { return await this.resolve(id, allowAnonymous); } + /** + * Resolves an anonymous object. + * The returned value will not have any ID present. + * If one is provided in the response, it will be removed automatically. + */ + @bindThis + public async resolveAnonymous(value: string | IObject | [string | IObject]): Promise { + value = fromTuple(value); + + const object = await this.resolve(value); + object.id = undefined; + + return object as IAnonymousObject; + } + public async resolve(value: string | [string], allowAnonymous?: boolean): Promise; public async resolve(value: string | IObjectWithId | [string | IObjectWithId], allowAnonymous?: boolean): Promise; public async resolve(value: string | IObject | [string | IObject], allowAnonymous?: boolean): Promise; diff --git a/packages/backend/src/core/activitypub/type.ts b/packages/backend/src/core/activitypub/type.ts index e33dec18d7..ae9fe118bc 100644 --- a/packages/backend/src/core/activitypub/type.ts +++ b/packages/backend/src/core/activitypub/type.ts @@ -43,6 +43,18 @@ export interface IObjectWithId extends IObject { id: string; } +export function isObjectWithId(object: IObject): object is IObjectWithId { + return typeof(object.id) === 'string'; +} + +export interface IAnonymousObject extends IObject { + id: undefined; +} + +export function isAnonymousObject(object: IObject): object is IAnonymousObject { + return object.id === undefined; +} + /** * Get array of ActivityStreams Objects id */