remove assertActivityMatchesUrls in favor of three-way same-authority checks

This commit is contained in:
Hazelnoot 2025-02-22 14:12:05 -05:00
parent 14a81b4f85
commit a568333ecd
13 changed files with 318 additions and 172 deletions

View file

@ -17,6 +17,8 @@ import { WebhookTestService } from '@/core/WebhookTestService.js';
import { FlashService } from '@/core/FlashService.js'; import { FlashService } from '@/core/FlashService.js';
import { TimeService } from '@/core/TimeService.js'; import { TimeService } from '@/core/TimeService.js';
import { EnvService } from '@/core/EnvService.js'; import { EnvService } from '@/core/EnvService.js';
import { ApUtilityService } from '@/core/activitypub/ApUtilityService.js';
import { ApLogService } from '@/core/ApLogService.js';
import { AccountMoveService } from './AccountMoveService.js'; import { AccountMoveService } from './AccountMoveService.js';
import { AccountUpdateService } from './AccountUpdateService.js'; import { AccountUpdateService } from './AccountUpdateService.js';
import { AnnouncementService } from './AnnouncementService.js'; import { AnnouncementService } from './AnnouncementService.js';
@ -157,7 +159,6 @@ import { QueueService } from './QueueService.js';
import { LoggerService } from './LoggerService.js'; import { LoggerService } from './LoggerService.js';
import { SponsorsService } from './SponsorsService.js'; import { SponsorsService } from './SponsorsService.js';
import type { Provider } from '@nestjs/common'; import type { Provider } from '@nestjs/common';
import { ApLogService } from '@/core/ApLogService.js';
//#region 文字列ベースでのinjection用(循環参照対応のため) //#region 文字列ベースでのinjection用(循環参照対応のため)
const $LoggerService: Provider = { provide: 'LoggerService', useExisting: LoggerService }; const $LoggerService: Provider = { provide: 'LoggerService', useExisting: LoggerService };
@ -308,6 +309,7 @@ const $ApMentionService: Provider = { provide: 'ApMentionService', useExisting:
const $ApNoteService: Provider = { provide: 'ApNoteService', useExisting: ApNoteService }; const $ApNoteService: Provider = { provide: 'ApNoteService', useExisting: ApNoteService };
const $ApPersonService: Provider = { provide: 'ApPersonService', useExisting: ApPersonService }; const $ApPersonService: Provider = { provide: 'ApPersonService', useExisting: ApPersonService };
const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting: ApQuestionService }; const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting: ApQuestionService };
const $ApUtilityService: Provider = { provide: 'ApUtilityService', useExisting: ApUtilityService };
//#endregion //#endregion
const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: SponsorsService }; const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: SponsorsService };
@ -465,6 +467,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
ApNoteService, ApNoteService,
ApPersonService, ApPersonService,
ApQuestionService, ApQuestionService,
ApUtilityService,
QueueService, QueueService,
SponsorsService, SponsorsService,
@ -618,6 +621,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
$ApNoteService, $ApNoteService,
$ApPersonService, $ApPersonService,
$ApQuestionService, $ApQuestionService,
$ApUtilityService,
//#endregion //#endregion
$SponsorsService, $SponsorsService,
@ -771,6 +775,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
ApNoteService, ApNoteService,
ApPersonService, ApPersonService,
ApQuestionService, ApQuestionService,
ApUtilityService,
QueueService, QueueService,
SponsorsService, SponsorsService,
@ -922,6 +927,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
$ApNoteService, $ApNoteService,
$ApPersonService, $ApPersonService,
$ApQuestionService, $ApQuestionService,
$ApUtilityService,
//#endregion //#endregion
$SponsorsService, $SponsorsService,

View file

@ -16,8 +16,8 @@ import type { Config } from '@/config.js';
import { StatusError } from '@/misc/status-error.js'; import { StatusError } from '@/misc/status-error.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js'; import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
import { assertActivityMatchesUrls } from '@/core/activitypub/misc/check-against-url.js'; import { IObject } from '@/core/activitypub/type.js';
import type { IObject } from '@/core/activitypub/type.js'; import { ApUtilityService } from './activitypub/ApUtilityService.js';
import type { Response } from 'node-fetch'; import type { Response } from 'node-fetch';
import type { URL } from 'node:url'; import type { URL } from 'node:url';
@ -145,6 +145,7 @@ export class HttpRequestService {
constructor( constructor(
@Inject(DI.config) @Inject(DI.config)
private config: Config, private config: Config,
private readonly apUtilityService: ApUtilityService,
) { ) {
const cache = new CacheableLookup({ const cache = new CacheableLookup({
maxTtl: 3600, // 1hours maxTtl: 3600, // 1hours
@ -198,6 +199,7 @@ export class HttpRequestService {
* Get agent by URL * Get agent by URL
* @param url URL * @param url URL
* @param bypassProxy Allways bypass proxy * @param bypassProxy Allways bypass proxy
* @param isLocalAddressAllowed
*/ */
@bindThis @bindThis
public getAgentByUrl(url: URL, bypassProxy = false, isLocalAddressAllowed = false): http.Agent | https.Agent { public getAgentByUrl(url: URL, bypassProxy = false, isLocalAddressAllowed = false): http.Agent | https.Agent {
@ -229,10 +231,11 @@ export class HttpRequestService {
validators: [validateContentTypeSetAsActivityPub], validators: [validateContentTypeSetAsActivityPub],
}); });
const finalUrl = res.url; // redirects may have been involved
const activity = await res.json() as IObject; const activity = await res.json() as IObject;
assertActivityMatchesUrls(activity, [finalUrl]); // 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);
return activity; return activity;
} }

View file

@ -11,14 +11,13 @@ import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
import type { MiUser } from '@/models/User.js'; import type { MiUser } from '@/models/User.js';
import { UserKeypairService } from '@/core/UserKeypairService.js'; import { UserKeypairService } from '@/core/UserKeypairService.js';
import { UtilityService } from '@/core/UtilityService.js'; import { ApUtilityService } from '@/core/activitypub/ApUtilityService.js';
import { HttpRequestService } from '@/core/HttpRequestService.js'; import { HttpRequestService } from '@/core/HttpRequestService.js';
import { LoggerService } from '@/core/LoggerService.js'; import { LoggerService } from '@/core/LoggerService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import type Logger from '@/logger.js'; import type Logger from '@/logger.js';
import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js'; import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
import { assertActivityMatchesUrls } from '@/core/activitypub/misc/check-against-url.js'; import { IObject } from './type.js';
import type { IObject } from './type.js';
type Request = { type Request = {
url: string; url: string;
@ -148,7 +147,7 @@ export class ApRequestService {
private userKeypairService: UserKeypairService, private userKeypairService: UserKeypairService,
private httpRequestService: HttpRequestService, private httpRequestService: HttpRequestService,
private loggerService: LoggerService, private loggerService: LoggerService,
private utilityService: UtilityService, private readonly apUtilityService: ApUtilityService,
) { ) {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
this.logger = this.loggerService?.getLogger('ap-request'); // なぜか TypeError: Cannot read properties of undefined (reading 'getLogger') と言われる this.logger = this.loggerService?.getLogger('ap-request'); // なぜか TypeError: Cannot read properties of undefined (reading 'getLogger') と言われる
@ -183,9 +182,10 @@ export class ApRequestService {
* Get AP object with http-signature * Get AP object with http-signature
* @param user http-signature user * @param user http-signature user
* @param url URL to fetch * @param url URL to fetch
* @param followAlternate
*/ */
@bindThis @bindThis
public async signedGet(url: string, user: { id: MiUser['id'] }, followAlternate?: boolean): Promise<object> { public async signedGet(url: string, user: { id: MiUser['id'] }, followAlternate?: boolean): Promise<IObject> {
const _followAlternate = followAlternate ?? true; const _followAlternate = followAlternate ?? true;
const keypair = await this.userKeypairService.getUserKeypair(user.id); const keypair = await this.userKeypairService.getUserKeypair(user.id);
@ -253,7 +253,7 @@ export class ApRequestService {
if (alternate) { if (alternate) {
const href = alternate.getAttribute('href'); const href = alternate.getAttribute('href');
if (href && this.utilityService.punyHostPSLDomain(url) === this.utilityService.punyHostPSLDomain(href)) { if (href && this.apUtilityService.haveSameAuthority(url, href)) {
return await this.signedGet(href, user, false); return await this.signedGet(href, user, false);
} }
} }
@ -266,10 +266,12 @@ export class ApRequestService {
//#endregion //#endregion
validateContentTypeSetAsActivityPub(res); validateContentTypeSetAsActivityPub(res);
const finalUrl = res.url; // redirects may have been involved
const activity = await res.json() as IObject; const activity = await res.json() as IObject;
assertActivityMatchesUrls(activity, [finalUrl]); // 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);
return activity; return activity;
} }

View file

@ -18,7 +18,8 @@ import type Logger from '@/logger.js';
import { fromTuple } from '@/misc/from-tuple.js'; import { fromTuple } from '@/misc/from-tuple.js';
import { IdentifiableError } from '@/misc/identifiable-error.js'; import { IdentifiableError } from '@/misc/identifiable-error.js';
import { ApLogService, calculateDurationSince, extractObjectContext } from '@/core/ApLogService.js'; import { ApLogService, calculateDurationSince, extractObjectContext } from '@/core/ApLogService.js';
import { getNullableApId, isCollectionOrOrderedCollection } from './type.js'; import { ApUtilityService } from '@/core/activitypub/ApUtilityService.js';
import { getApId, getNullableApId, isCollectionOrOrderedCollection } from './type.js';
import { ApDbResolverService } from './ApDbResolverService.js'; import { ApDbResolverService } from './ApDbResolverService.js';
import { ApRendererService } from './ApRendererService.js'; import { ApRendererService } from './ApRendererService.js';
import { ApRequestService } from './ApRequestService.js'; import { ApRequestService } from './ApRequestService.js';
@ -45,6 +46,7 @@ export class Resolver {
private apDbResolverService: ApDbResolverService, private apDbResolverService: ApDbResolverService,
private loggerService: LoggerService, private loggerService: LoggerService,
private readonly apLogService: ApLogService, private readonly apLogService: ApLogService,
private readonly apUtilityService: ApUtilityService,
private recursionLimit = 256, private recursionLimit = 256,
) { ) {
this.history = new Set(); this.history = new Set();
@ -176,20 +178,16 @@ export class Resolver {
throw new IdentifiableError('72180409-793c-4973-868e-5a118eb5519b', `invalid AP object ${value}: does not have ActivityStreams context`); throw new IdentifiableError('72180409-793c-4973-868e-5a118eb5519b', `invalid AP object ${value}: does not have ActivityStreams context`);
} }
// Since redirects are allowed, we cannot safely validate an anonymous object. // The object ID is already validated to match the final URL's authority by signedGet / getActivityJson.
// Reject any responses without an ID, as all other checks depend on that value. // We only need to validate that it also matches the original URL's authority, in case of redirects.
if (object.id == null) { const objectId = getApId(object);
throw new IdentifiableError('ad2dc287-75c1-44c4-839d-3d2e64576675', `invalid AP object ${value}: missing id`);
}
// We allow some limited cross-domain redirects, which means the host may have changed during fetch. // We allow some limited cross-domain redirects, which means the host may have changed during fetch.
// Additional checks are needed to validate the scope of cross-domain redirects. // Additional checks are needed to validate the scope of cross-domain redirects.
const finalHost = this.utilityService.extractDbHost(object.id); const finalHost = this.utilityService.extractDbHost(objectId);
if (finalHost !== host) { if (finalHost !== host) {
// Make sure the redirect stayed within the same authority. // Make sure the redirect stayed within the same authority.
if (this.utilityService.punyHostPSLDomain(object.id) !== this.utilityService.punyHostPSLDomain(value)) { this.apUtilityService.assertIdMatchesUrlAuthority(object, value);
throw new IdentifiableError('fd93c2fa-69a8-440f-880b-bf178e0ec877', `invalid AP object ${value}: id ${object.id} has different host`);
}
// Check if the redirect bounce from [allowed domain] to [blocked domain]. // Check if the redirect bounce from [allowed domain] to [blocked domain].
if (!this.utilityService.isFederationAllowedHost(finalHost)) { if (!this.utilityService.isFederationAllowedHost(finalHost)) {
@ -287,6 +285,7 @@ export class ApResolverService {
private apDbResolverService: ApDbResolverService, private apDbResolverService: ApDbResolverService,
private loggerService: LoggerService, private loggerService: LoggerService,
private readonly apLogService: ApLogService, private readonly apLogService: ApLogService,
private readonly apUtilityService: ApUtilityService,
) { ) {
} }
@ -308,6 +307,7 @@ export class ApResolverService {
this.apDbResolverService, this.apDbResolverService,
this.loggerService, this.loggerService,
this.apLogService, this.apLogService,
this.apUtilityService,
); );
} }
} }

View file

@ -0,0 +1,80 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Injectable } from '@nestjs/common';
import { UtilityService } from '@/core/UtilityService.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { toArray } from '@/misc/prelude/array.js';
import { EnvService } from '@/core/EnvService.js';
import { getApId, getOneApHrefNullable, IObject } from './type.js';
@Injectable()
export class ApUtilityService {
constructor(
private readonly utilityService: UtilityService,
private readonly envService: EnvService,
) {}
/**
* Verifies that the object's ID has the same authority as the provided URL.
* Returns on success, throws on any validation error.
*/
public assertIdMatchesUrlAuthority(object: IObject, url: string): void {
// This throws if the ID is missing or invalid, but that's ok.
// Anonymous objects are impossible to verify, so we don't allow fetching them.
const id = getApId(object);
// 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.
if (!this.haveSameAuthority(url, id)) {
throw new IdentifiableError('fd93c2fa-69a8-440f-880b-bf178e0ec877', `invalid AP object ${url}: id ${id} has different host authority`);
}
}
/**
* Checks if two URLs have the same host authority
*/
public haveSameAuthority(url1: string, url2: string): boolean {
if (url1 === url2) return true;
const authority1 = this.utilityService.punyHostPSLDomain(url1);
const authority2 = this.utilityService.punyHostPSLDomain(url2);
return authority1 === authority2;
}
/**
* Searches a list of URLs or Links for the first one matching a given target URL's host authority.
* Returns null if none match.
* @param targetUrl URL with the target host authority
* @param searchUrls URL, Link, or array to search for matching URLs
*/
public findSameAuthorityUrl(targetUrl: string, searchUrls: string | IObject | undefined | (string | IObject)[]): string | null {
const targetAuthority = this.utilityService.punyHostPSLDomain(targetUrl);
const match = toArray(searchUrls)
.map(raw => getOneApHrefNullable(raw))
.find(url => {
if (!url) return false;
if (!this.checkHttps(url)) return false;
const urlAuthority = this.utilityService.punyHostPSLDomain(url);
return urlAuthority === targetAuthority;
});
return match ?? null;
}
/**
* Checks if the URL contains HTTPS.
* Additionally, allows HTTP in non-production environments.
* Based on check-https.ts.
*/
private checkHttps(url: string): boolean {
const isNonProd = this.envService.env.NODE_ENV !== 'production';
// noinspection HttpUrlsUsage
return url.startsWith('https://') || (url.startsWith('http://') && isNonProd);
}
}

View file

@ -1,31 +0,0 @@
/*
* SPDX-FileCopyrightText: dakkar and sharkey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { UnrecoverableError } from 'bullmq';
import type { IObject } from '../type.js';
function getHrefsFrom(one: IObject | string | undefined | (IObject | string | undefined)[]): (string | undefined)[] {
if (Array.isArray(one)) {
return one.flatMap(h => getHrefsFrom(h));
}
return [
typeof(one) === 'object' ? one.href : one,
];
}
export function assertActivityMatchesUrls(activity: IObject, urls: string[]) {
const expectedUrls = new Set(urls
.filter(u => URL.canParse(u))
.map(u => new URL(u).href),
);
const actualUrls = [activity.id, ...getHrefsFrom(activity.url)]
.filter(u => u && URL.canParse(u))
.map(u => new URL(u as string).href);
if (!actualUrls.some(u => expectedUrls.has(u))) {
throw new UnrecoverableError(`bad Activity: neither id nor url (${actualUrls.join(', ')}) match location (${urls.join(', ')})`);
}
}

View file

@ -26,12 +26,13 @@ import { bindThis } from '@/decorators.js';
import { checkHttps } from '@/misc/check-https.js'; import { checkHttps } from '@/misc/check-https.js';
import { IdentifiableError } from '@/misc/identifiable-error.js'; import { IdentifiableError } from '@/misc/identifiable-error.js';
import { isRetryableError } from '@/misc/is-retryable-error.js'; import { isRetryableError } from '@/misc/is-retryable-error.js';
import { getOneApId, getApId, getOneApHrefNullable, validPost, isEmoji, getApType, isApObject, isDocument, IApDocument } from '../type.js'; import { getOneApId, getApId, validPost, isEmoji, getApType, isApObject, isDocument, IApDocument } from '../type.js';
import { ApLoggerService } from '../ApLoggerService.js'; import { ApLoggerService } from '../ApLoggerService.js';
import { ApMfmService } from '../ApMfmService.js'; import { ApMfmService } from '../ApMfmService.js';
import { ApDbResolverService } from '../ApDbResolverService.js'; import { ApDbResolverService } from '../ApDbResolverService.js';
import { ApResolverService } from '../ApResolverService.js'; import { ApResolverService } from '../ApResolverService.js';
import { ApAudienceService } from '../ApAudienceService.js'; import { ApAudienceService } from '../ApAudienceService.js';
import { ApUtilityService } from '../ApUtilityService.js';
import { ApPersonService } from './ApPersonService.js'; import { ApPersonService } from './ApPersonService.js';
import { extractApHashtags } from './tag.js'; import { extractApHashtags } from './tag.js';
import { ApMentionService } from './ApMentionService.js'; import { ApMentionService } from './ApMentionService.js';
@ -82,6 +83,7 @@ export class ApNoteService {
private noteEditService: NoteEditService, private noteEditService: NoteEditService,
private apDbResolverService: ApDbResolverService, private apDbResolverService: ApDbResolverService,
private apLoggerService: ApLoggerService, private apLoggerService: ApLoggerService,
private readonly apUtilityService: ApUtilityService,
) { ) {
this.logger = this.apLoggerService.logger; this.logger = this.apLoggerService.logger;
} }
@ -125,7 +127,7 @@ export class ApNoteService {
} }
if (note) { if (note) {
const url = (object.url) ? getOneApId(object.url) : note.url; const url = this.apUtilityService.findSameAuthorityUrl(uri, object.url);
if (url && url !== note.url) { if (url && url !== note.url) {
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: updated url does not match original url. updated url: ${url}, original url: ${note.url}`); return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: updated url does not match original url. updated url: ${url}, original url: ${note.url}`);
} }
@ -186,17 +188,7 @@ export class ApNoteService {
throw new UnrecoverableError(`unexpected schema of note.id ${note.id} in ${entryUri}`); throw new UnrecoverableError(`unexpected schema of note.id ${note.id} in ${entryUri}`);
} }
const url = getOneApHrefNullable(note.url); const url = this.apUtilityService.findSameAuthorityUrl(note.id, note.url);
if (url != null) {
if (!checkHttps(url)) {
throw new UnrecoverableError(`unexpected schema of note.url ${url} in ${entryUri}`);
}
if (this.utilityService.punyHostPSLDomain(url) !== this.utilityService.punyHostPSLDomain(note.id)) {
throw new UnrecoverableError(`note url <> uri host mismatch: ${url} <> ${note.id} in ${entryUri}`);
}
}
this.logger.info(`Creating the Note: ${note.id}`); this.logger.info(`Creating the Note: ${note.id}`);
@ -411,17 +403,7 @@ export class ApNoteService {
throw new UnrecoverableError(`unexpected schema of note.id ${note.id} in ${noteUri}`); throw new UnrecoverableError(`unexpected schema of note.id ${note.id} in ${noteUri}`);
} }
const url = getOneApHrefNullable(note.url); const url = this.apUtilityService.findSameAuthorityUrl(note.id, note.url);
if (url != null) {
if (!checkHttps(url)) {
throw new UnrecoverableError(`unexpected schema of note.url ${url} in ${noteUri}`);
}
if (this.utilityService.punyHostPSLDomain(url) !== this.utilityService.punyHostPSLDomain(note.id)) {
throw new UnrecoverableError(`note url <> id host mismatch: ${url} <> ${note.id} in ${noteUri}`);
}
}
this.logger.info(`Creating the Note: ${note.id}`); this.logger.info(`Creating the Note: ${note.id}`);

View file

@ -39,8 +39,8 @@ import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js'; import { RoleService } from '@/core/RoleService.js';
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
import type { AccountMoveService } from '@/core/AccountMoveService.js'; import type { AccountMoveService } from '@/core/AccountMoveService.js';
import { checkHttps } from '@/misc/check-https.js'; import { ApUtilityService } from '@/core/activitypub/ApUtilityService.js';
import { getApId, getApType, getOneApHrefNullable, isActor, isCollection, isCollectionOrOrderedCollection, isPropertyValue } from '../type.js'; import { getApId, getApType, isActor, isCollection, isCollectionOrOrderedCollection, isPropertyValue } from '../type.js';
import { extractApHashtags } from './tag.js'; import { extractApHashtags } from './tag.js';
import type { OnModuleInit } from '@nestjs/common'; import type { OnModuleInit } from '@nestjs/common';
import type { ApNoteService } from './ApNoteService.js'; import type { ApNoteService } from './ApNoteService.js';
@ -106,6 +106,7 @@ export class ApPersonService implements OnModuleInit {
private followingsRepository: FollowingsRepository, private followingsRepository: FollowingsRepository,
private roleService: RoleService, private roleService: RoleService,
private readonly apUtilityService: ApUtilityService,
) { ) {
} }
@ -346,21 +347,12 @@ export class ApPersonService implements OnModuleInit {
const bday = person['vcard:bday']?.match(/^\d{4}-\d{2}-\d{2}/); const bday = person['vcard:bday']?.match(/^\d{4}-\d{2}-\d{2}/);
const url = getOneApHrefNullable(person.url);
if (person.id == null) { if (person.id == null) {
throw new UnrecoverableError(`Refusing to create person without id: ${uri}`); throw new UnrecoverableError(`Refusing to create person without id: ${uri}`);
} }
if (url != null) { const personId = getApId(person);
if (!checkHttps(url)) { const url = this.apUtilityService.findSameAuthorityUrl(personId, person.url);
throw new UnrecoverableError(`unexpected schema of person url ${url} in ${uri}`);
}
if (this.utilityService.punyHostPSLDomain(url) !== this.utilityService.punyHostPSLDomain(person.id)) {
throw new UnrecoverableError(`person url <> uri host mismatch: ${url} <> ${person.id} in ${uri}`);
}
}
// Create user // Create user
let user: MiRemoteUser | null = null; let user: MiRemoteUser | null = null;
@ -566,21 +558,11 @@ export class ApPersonService implements OnModuleInit {
const bday = person['vcard:bday']?.match(/^\d{4}-\d{2}-\d{2}/); const bday = person['vcard:bday']?.match(/^\d{4}-\d{2}-\d{2}/);
const url = getOneApHrefNullable(person.url);
if (person.id == null) { if (person.id == null) {
throw new UnrecoverableError(`Refusing to update person without id: ${uri}`); throw new UnrecoverableError(`Refusing to update person without id: ${uri}`);
} }
const personId = getApId(person);
if (url != null) { const url = this.apUtilityService.findSameAuthorityUrl(personId, person.url);
if (!checkHttps(url)) {
throw new UnrecoverableError(`unexpected schema of person url ${url} in ${uri}`);
}
if (this.utilityService.punyHostPSLDomain(url) !== this.utilityService.punyHostPSLDomain(person.id)) {
throw new UnrecoverableError(`person url <> uri host mismatch: ${url} <> ${person.id} in ${uri}`);
}
}
const updates = { const updates = {
lastFetchedAt: new Date(), lastFetchedAt: new Date(),

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { UnrecoverableError } from 'bullmq'; import { IdentifiableError } from '@/misc/identifiable-error.js';
import { fromTuple } from '@/misc/from-tuple.js'; import { fromTuple } from '@/misc/from-tuple.js';
export type Obj = { [x: string]: any }; export type Obj = { [x: string]: any };
@ -56,29 +56,22 @@ export function getOneApId(value: ApObject): string {
return getApId(firstOne); return getApId(firstOne);
} }
/**
* Minimal AP payload - just an object with optional ID.
*/
export interface ObjectWithId {
id?: string;
}
/** /**
* Get ActivityStreams Object id * Get ActivityStreams Object id
*/ */
export function getApId(value: string | ObjectWithId | [string | ObjectWithId]): string { export function getApId(value: string | IObject | [string | IObject]): string {
// eslint-disable-next-line no-param-reassign // eslint-disable-next-line no-param-reassign
value = fromTuple(value); value = fromTuple(value);
if (typeof value === 'string') return value; if (typeof value === 'string') return value;
if (typeof value.id === 'string') return value.id; if (typeof value.id === 'string') return value.id;
throw new UnrecoverableError('cannot determine id'); throw new IdentifiableError('ad2dc287-75c1-44c4-839d-3d2e64576675', `invalid AP object ${value}: missing id`);
} }
/** /**
* Get ActivityStreams Object id, or null if not present * Get ActivityStreams Object id, or null if not present
*/ */
export function getNullableApId(value: string | ObjectWithId | [string | ObjectWithId]): string | null { export function getNullableApId(value: string | IObject | [string | IObject]): string | null {
// eslint-disable-next-line no-param-reassign // eslint-disable-next-line no-param-reassign
value = fromTuple(value); value = fromTuple(value);

View file

@ -7,7 +7,7 @@ import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import type { MiNote } from '@/models/Note.js'; import type { MiNote } from '@/models/Note.js';
import type { MiLocalUser, MiUser } from '@/models/User.js'; import type { MiLocalUser, MiUser } from '@/models/User.js';
import { isActor, isPost, getApId, getNullableApId, ObjectWithId } from '@/core/activitypub/type.js'; import { isActor, isPost, getApId, getNullableApId } from '@/core/activitypub/type.js';
import type { SchemaType } from '@/misc/json-schema.js'; import type { SchemaType } from '@/misc/json-schema.js';
import { ApResolverService } from '@/core/activitypub/ApResolverService.js'; import { ApResolverService } from '@/core/activitypub/ApResolverService.js';
import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js'; import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js';
@ -154,7 +154,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
// Before we fetch, resolve the URI in case it has a cross-origin redirect or anything like that. // Before we fetch, resolve the URI in case it has a cross-origin redirect or anything like that.
// Resolver.resolve() uses strict verification, which is overly paranoid for a user-provided lookup. // Resolver.resolve() uses strict verification, which is overly paranoid for a user-provided lookup.
uri = await this.resolveCanonicalUri(uri); // eslint-disable-line no-param-reassign uri = await this.resolveCanonicalUri(uri); // eslint-disable-line no-param-reassign
if (!this.utilityService.isFederationAllowedUri(uri)) return null; if (!this.utilityService.isFederationAllowedUri(uri)) {
throw new ApiError(meta.errors.federationNotAllowed);
}
const host = this.utilityService.extractDbHost(uri); const host = this.utilityService.extractDbHost(uri);
@ -244,7 +246,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
*/ */
private async resolveCanonicalUri(uri: string): Promise<string> { private async resolveCanonicalUri(uri: string): Promise<string> {
const user = await this.instanceActorService.getInstanceActor(); const user = await this.instanceActorService.getInstanceActor();
const res = await this.apRequestService.signedGet(uri, user, true) as ObjectWithId; const res = await this.apRequestService.signedGet(uri, user, true);
return getNullableApId(res) ?? uri; return getNullableApId(res) ?? uri;
} }
} }

View file

@ -24,6 +24,7 @@ import type {
UsersRepository, UsersRepository,
} from '@/models/_.js'; } from '@/models/_.js';
import { ApLogService } from '@/core/ApLogService.js'; import { ApLogService } from '@/core/ApLogService.js';
import { ApUtilityService } from '@/core/activitypub/ApUtilityService.js';
type MockResponse = { type MockResponse = {
type: string; type: string;
@ -51,6 +52,7 @@ export class MockResolver extends Resolver {
{} as ApDbResolverService, {} as ApDbResolverService,
loggerService, loggerService,
{} as ApLogService, {} as ApLogService,
{} as ApUtilityService,
); );
} }

View file

@ -0,0 +1,180 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { UtilityService } from '@/core/UtilityService.js';
import type { IObject } from '@/core/activitypub/type.js';
import type { EnvService } from '@/core/EnvService.js';
import { ApUtilityService } from '@/core/activitypub/ApUtilityService.js';
describe(ApUtilityService, () => {
let serviceUnderTest: ApUtilityService;
let env: Record<string, string>;
beforeEach(() => {
const utilityService = {
punyHostPSLDomain(input: string) {
const host = new URL(input).host;
const parts = host.split('.');
return `${parts[parts.length - 2]}.${parts[parts.length - 1]}`;
},
} as unknown as UtilityService;
env = {};
const envService = {
env,
} as unknown as EnvService;
serviceUnderTest = new ApUtilityService(utilityService, envService);
});
describe('assertIdMatchesUrlAuthority', () => {
it('should return when input matches', () => {
const object = { id: 'https://first.example.com' } as IObject;
const url = 'https://second.example.com';
expect(() => {
serviceUnderTest.assertIdMatchesUrlAuthority(object, url);
}).not.toThrow();
});
it('should throw when id is missing', () => {
const object = { id: undefined } as IObject;
const url = 'https://second.example.com';
expect(() => {
serviceUnderTest.assertIdMatchesUrlAuthority(object, url);
}).toThrow();
});
it('should throw when id does not match', () => {
const object = { id: 'https://other-domain.com' } as IObject;
const url = 'https://second.example.com';
expect(() => {
serviceUnderTest.assertIdMatchesUrlAuthority(object, url);
}).toThrow();
});
});
describe('haveSameAuthority', () => {
it('should return true when URLs match', () => {
const url = 'https://example.com';
const result = serviceUnderTest.haveSameAuthority(url, url);
expect(result).toBeTruthy();
});
it('should return true when URLs have same host', () => {
const first = 'https://example.com/first';
const second = 'https://example.com/second';
const result = serviceUnderTest.haveSameAuthority(first, second);
expect(result).toBeTruthy();
});
it('should return true when URLs have same authority', () => {
const first = 'https://first.example.com/first';
const second = 'https://second.example.com/second';
const result = serviceUnderTest.haveSameAuthority(first, second);
expect(result).toBeTruthy();
});
it('should return false when URLs have different authority', () => {
const first = 'https://first.com';
const second = 'https://second.com';
const result = serviceUnderTest.haveSameAuthority(first, second);
expect(result).toBeFalsy();
});
});
describe('findSameAuthorityUrl', () => {
it('should return null when input is undefined', () => {
const result = serviceUnderTest.findSameAuthorityUrl('https://example.com', undefined);
expect(result).toBeNull();
});
it('should return null when input is empty array', () => {
const result = serviceUnderTest.findSameAuthorityUrl('https://example.com', []);
expect(result).toBeNull();
});
it('should return return url if string input matches', () => {
const result = serviceUnderTest.findSameAuthorityUrl('https://example.com/1', 'https://example.com/2');
expect(result).toBe('https://example.com/2');
});
it('should return return url if object input matches', () => {
const input = {
href: 'https://example.com/2',
} as IObject;
const result = serviceUnderTest.findSameAuthorityUrl('https://example.com/1', input);
expect(result).toBe('https://example.com/2');
});
it('should return return url if string[] input matches', () => {
const result = serviceUnderTest.findSameAuthorityUrl('https://example.com/1', ['https://example.com/2']);
expect(result).toBe('https://example.com/2');
});
it('should return return url if object[] input matches', () => {
const input = {
href: 'https://example.com/2',
} as IObject;
const result = serviceUnderTest.findSameAuthorityUrl('https://example.com/1', [input]);
expect(result).toBe('https://example.com/2');
});
it('should skip invalid entries', () => {
const result = serviceUnderTest.findSameAuthorityUrl('https://example.com/1', [{} as IObject, 'https://example.com/2']);
expect(result).toBe('https://example.com/2');
});
it('should return first match', () => {
const result = serviceUnderTest.findSameAuthorityUrl('https://example.com/1', ['https://example.com/2', 'https://example.com/3']);
expect(result).toBe('https://example.com/2');
});
it('should skip invalid scheme', () => {
const result = serviceUnderTest.findSameAuthorityUrl('https://example.com/1', ['file://example.com/1', 'https://example.com/2']);
expect(result).toBe('https://example.com/2');
});
it('should skip HTTP in production', () => {
env.NODE_ENV = 'production';
// noinspection HttpUrlsUsage
const result = serviceUnderTest.findSameAuthorityUrl('https://example.com/1', ['http://example.com/1', 'https://example.com/2']);
expect(result).toBe('https://example.com/2');
});
it('should allow HTTP in non-prod', () => {
env.NODE_ENV = 'test';
// noinspection HttpUrlsUsage
const result = serviceUnderTest.findSameAuthorityUrl('https://example.com/1', ['http://example.com/1', 'https://example.com/2']);
// noinspection HttpUrlsUsage
expect(result).toBe('http://example.com/1');
});
});
});

View file

@ -1,55 +0,0 @@
/*
* SPDX-FileCopyrightText: dakkar and sharkey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, expect, test } from '@jest/globals';
import type { IObject } from '@/core/activitypub/type.js';
import { assertActivityMatchesUrls } from '@/core/activitypub/misc/check-against-url.js';
function assertOne(activity: IObject, good = 'http://good') {
// return a function so we can use `.toThrow`
return () => assertActivityMatchesUrls(activity, [good]);
}
describe('assertActivityMatchesUrls', () => {
it('should throw when no ids are URLs', () => {
expect(assertOne({ type: 'Test', id: 'bad' }, 'bad')).toThrow(/bad Activity/);
});
test('id', () => {
expect(assertOne({ type: 'Test', id: 'http://bad' })).toThrow(/bad Activity/);
expect(assertOne({ type: 'Test', id: 'http://good' })).not.toThrow();
});
test('simple url', () => {
expect(assertOne({ type: 'Test', url: 'http://bad' })).toThrow(/bad Activity/);
expect(assertOne({ type: 'Test', url: 'http://good' })).not.toThrow();
});
test('array of urls', () => {
expect(assertOne({ type: 'Test', url: ['http://bad'] })).toThrow(/bad Activity/);
expect(assertOne({ type: 'Test', url: ['http://bad', 'http://other'] })).toThrow(/bad Activity/);
expect(assertOne({ type: 'Test', url: ['http://good'] })).not.toThrow();
expect(assertOne({ type: 'Test', url: ['http://bad', 'http://good'] })).not.toThrow();
});
test('array of objects', () => {
expect(assertOne({ type: 'Test', url: [{ type: 'Test', href: 'http://bad' }] })).toThrow(/bad Activity/);
expect(assertOne({ type: 'Test', url: [{ type: 'Test', href: 'http://bad' }, { type: 'Test', href: 'http://other' }] })).toThrow(/bad Activity/);
expect(assertOne({ type: 'Test', url: [{ type: 'Test', href: 'http://good' }] })).not.toThrow();
expect(assertOne({ type: 'Test', url: [{ type: 'Test', href: 'http://bad' }, { type: 'Test', href: 'http://good' }] })).not.toThrow();
});
test('mixed array', () => {
expect(assertOne({ type: 'Test', url: [{ type: 'Test', href: 'http://bad' }, 'http://other'] })).toThrow(/bad Activity/);
expect(assertOne({ type: 'Test', url: [{ type: 'Test', href: 'http://bad' }, 'http://good'] })).not.toThrow();
expect(assertOne({ type: 'Test', url: ['http://bad', { type: 'Test', href: 'http://good' }] })).not.toThrow();
});
test('id and url', () => {
expect(assertOne({ type: 'Test', id: 'http://other', url: 'http://bad' })).toThrow(/bad Activity/);
expect(assertOne({ type: 'Test', id: 'http://bad', url: 'http://good' })).not.toThrow();
expect(assertOne({ type: 'Test', id: 'http://good', url: 'http://bad' })).not.toThrow();
});
});