From 543325582cc2f40b9e61356789b253ee18c7ce4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=82=E3=82=8F=E3=82=8F=E3=82=8F=E3=81=A8=E3=83=BC?= =?UTF-8?q?=E3=81=AB=E3=82=85?= <17376330+u1-liquid@users.noreply.github.com> Date: Sat, 28 Dec 2024 18:49:13 +0900 Subject: [PATCH] =?UTF-8?q?fix(ActivityPub):=20URI=E3=81=A8URL=E3=81=8C?= =?UTF-8?q?=E4=B8=80=E8=87=B4=E3=81=97=E3=81=AA=E3=81=84=E5=A0=B4=E5=90=88?= =?UTF-8?q?=E3=80=81=E5=90=8C=E3=81=98=E3=83=89=E3=83=A1=E3=82=A4=E3=83=B3?= =?UTF-8?q?=E5=86=85=E3=81=AE=E3=82=B5=E3=83=96=E3=83=89=E3=83=A1=E3=82=A4?= =?UTF-8?q?=E3=83=B3=E3=81=AE1=E9=9A=8E=E5=B1=A4=E3=81=AE=E9=81=95?= =?UTF-8?q?=E3=81=84=E3=81=BE=E3=81=A7=E3=81=AF=E8=A8=B1=E5=AE=B9=E3=81=99?= =?UTF-8?q?=E3=82=8B=20(MisskeyIO#859)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/package.json | 2 + .../backend/src/core/HttpRequestService.ts | 6 +- packages/backend/src/core/UtilityService.ts | 101 ++++++++++++++++++ .../src/core/activitypub/ApRequestService.ts | 6 +- .../src/core/activitypub/ApResolverService.ts | 4 +- .../activitypub/misc/check-against-url.ts | 19 ---- .../core/activitypub/models/ApNoteService.ts | 27 +++-- .../activitypub/models/ApPersonService.ts | 28 ++--- pnpm-lock.yaml | 18 ++++ 9 files changed, 161 insertions(+), 50 deletions(-) delete mode 100644 packages/backend/src/core/activitypub/misc/check-against-url.ts diff --git a/packages/backend/package.json b/packages/backend/package.json index c0bde0be9..bf08f631f 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -156,6 +156,7 @@ "pkce-challenge": "4.1.0", "probe-image-size": "7.2.3", "promise-limit": "2.7.0", + "psl": "1.15.0", "pug": "3.0.3", "punycode.js": "2.3.1", "qrcode": "1.5.4", @@ -216,6 +217,7 @@ "@types/oauth2orize": "1.11.5", "@types/oauth2orize-pkce": "0.1.2", "@types/pg": "8.11.10", + "@types/psl": "1.1.3", "@types/pug": "2.0.10", "@types/punycode.js": "npm:@types/punycode@2.1.4", "@types/qrcode": "1.5.5", diff --git a/packages/backend/src/core/HttpRequestService.ts b/packages/backend/src/core/HttpRequestService.ts index e3640216f..a0c82a541 100644 --- a/packages/backend/src/core/HttpRequestService.ts +++ b/packages/backend/src/core/HttpRequestService.ts @@ -13,10 +13,10 @@ import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent'; import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; +import { UtilityService } from '@/core/UtilityService.js'; import { StatusError } from '@/misc/status-error.js'; import { bindThis } from '@/decorators.js'; import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js'; -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'; @@ -145,6 +145,8 @@ export class HttpRequestService { constructor( @Inject(DI.config) private config: Config, + + private utilityService: UtilityService, ) { const cache = new CacheableLookup({ maxTtl: 3600, // 1hours @@ -232,7 +234,7 @@ export class HttpRequestService { const finalUrl = res.url; // redirects may have been involved const activity = await res.json() as IObject; - assertActivityMatchesUrls(activity, [finalUrl]); + this.utilityService.assertActivityRelatedToUrl(activity, finalUrl); return activity; } diff --git a/packages/backend/src/core/UtilityService.ts b/packages/backend/src/core/UtilityService.ts index 2b35e86f4..71d774712 100644 --- a/packages/backend/src/core/UtilityService.ts +++ b/packages/backend/src/core/UtilityService.ts @@ -4,12 +4,15 @@ */ import { URL } from 'node:url'; +import { isIP } from 'node:net'; import punycode from 'punycode.js'; +import psl from 'psl'; import RE2 from 're2'; import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import { bindThis } from '@/decorators.js'; +import type { IObject } from '@/core/activitypub/type.js'; @Injectable() export class UtilityService { @@ -93,4 +96,102 @@ export class UtilityService { // ref: https://url.spec.whatwg.org/#host-serializing return new URL(uri).host; } + + @bindThis + public isRelatedHosts(hostA: string, hostB: string): boolean { + // hostA と hostB は呼び出す側で正規化済みであることを前提とする + + // ポート番号が付いている可能性がある場合、ポート番号を除去するためにもう一度正規化 + if (hostA.includes(':')) hostA = new URL(`urn://${hostA}`).hostname; + if (hostB.includes(':')) hostB = new URL(`urn://${hostB}`).hostname; + + // ホストが完全一致している場合は true + if (hostA === hostB) { + return true; + } + + // ----------------------------- + // 1. IPアドレスの場合の処理 + // ----------------------------- + const aIpVersion = isIP(hostA); + const bIpVersion = isIP(hostB); + if (aIpVersion !== 0 || bIpVersion !== 0) { + // どちらかが IP の場合、完全一致以外は false + return false; + } + + // ----------------------------- + // 2. ホストの場合の処理 + // ----------------------------- + const parsedA = psl.parse(hostA); + const parsedB = psl.parse(hostB); + + // どちらか一方でもパース失敗 or eTLD+1が異なる場合は false + if (parsedA.error || parsedB.error || parsedA.domain !== parsedB.domain) { + return false; + } + + // ----------------------------- + // 3. サブドメインの比較 + // ----------------------------- + // サブドメイン部分が後方一致で階層差が1以内かどうかを判定する。 + // 完全一致だと既に true で返しているので、ここでは完全一致以外の場合のみの判定 + // 例: + // subA = "www", subB = "" => true (1階層差) + // subA = "alice.users", subB = "users" => true (1階層差) + // subA = "alice.users", subB = "bob.users" => true (1階層差) + // subA = "alice.users", subB = "" => false (2階層差) + + const labelsA = parsedA.subdomain?.split('.') ?? []; + const levelsA = labelsA.length; + const labelsB = parsedB.subdomain?.split('.') ?? []; + const levelsB = labelsB.length; + + // 後ろ(右)から一致している部分をカウント + let i = 0; + while ( + i < levelsA && + i < levelsB && + labelsA[levelsA - 1 - i] === labelsB[levelsB - 1 - i] + ) { + i++; + } + + // 後方一致していないラベルの数 = (総数 - 一致数) + const unmatchedA = levelsA - i; + const unmatchedB = levelsB - i; + + // 不一致ラベルが1階層以内なら true + return Math.max(unmatchedA, unmatchedB) <= 1; + } + + @bindThis + public isRelatedUris(uriA: string, uriB: string): boolean { + // URI が完全一致している場合は true + if (uriA === uriB) { + return true; + } + + const hostA = this.extractHost(uriA); + const hostB = this.extractHost(uriB); + + return this.isRelatedHosts(hostA, hostB); + } + + @bindThis + public assertActivityRelatedToUrl(activity: IObject, url: string): void { + if (activity.id && this.isRelatedUris(activity.id, url)) return; + + if (activity.url) { + if (!Array.isArray(activity.url)) { + if (typeof(activity.url) === 'string' && this.isRelatedUris(activity.url, url)) return; + if (typeof(activity.url) === 'object' && activity.url.href && this.isRelatedUris(activity.url.href, url)) return; + } else { + if (activity.url.some(x => typeof(x) === 'string' && this.isRelatedUris(x, url))) return; + if (activity.url.some(x => typeof(x) === 'object' && x.href && this.isRelatedUris(x.href, url))) return; + } + } + + throw new Error(`Invalid object: neither id(${activity.id}) nor url(${activity.url}) related to ${url}`); + } } diff --git a/packages/backend/src/core/activitypub/ApRequestService.ts b/packages/backend/src/core/activitypub/ApRequestService.ts index 4e4d7e8d5..06c952ef7 100644 --- a/packages/backend/src/core/activitypub/ApRequestService.ts +++ b/packages/backend/src/core/activitypub/ApRequestService.ts @@ -17,7 +17,6 @@ import { LoggerService } from '@/core/LoggerService.js'; import { bindThis } from '@/decorators.js'; import type Logger from '@/logger.js'; import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js'; -import { assertActivityMatchesUrls } from '@/core/activitypub/misc/check-against-url.js'; import type { IObject } from './type.js'; type Request = { @@ -182,6 +181,7 @@ export class ApRequestService { * Get AP object with http-signature * @param user http-signature user * @param url URL to fetch + * @param followAlternate If true, follow alternate link tag in HTML */ @bindThis public async signedGet(url: string, user: { id: MiUser['id'] }, followAlternate?: boolean): Promise { @@ -220,7 +220,7 @@ export class ApRequestService { const alternate = fragment.querySelector('head > link[rel="alternate"][type="application/activity+json"]'); if (alternate) { const href = alternate.getAttribute('href'); - if (href && this.utilityService.extractHost(url) === this.utilityService.extractHost(href)) { + if (href && this.utilityService.isRelatedUris(url, href)) { return await this.signedGet(href, user, false); } } @@ -234,7 +234,7 @@ export class ApRequestService { const finalUrl = res.url; // redirects may have been involved const activity = await res.json() as IObject; - assertActivityMatchesUrls(activity, [finalUrl]); + this.utilityService.assertActivityRelatedToUrl(activity, finalUrl); return activity; } diff --git a/packages/backend/src/core/activitypub/ApResolverService.ts b/packages/backend/src/core/activitypub/ApResolverService.ts index 2e00d98d1..e61cc90b4 100644 --- a/packages/backend/src/core/activitypub/ApResolverService.ts +++ b/packages/backend/src/core/activitypub/ApResolverService.ts @@ -128,8 +128,8 @@ export class Resolver { throw new Error('invalid AP object: missing id'); } - if (this.utilityService.extractHost(object.id) !== this.utilityService.extractHost(value)) { - throw new Error(`invalid AP object ${value}: id ${object.id} has different host`); + if (!this.utilityService.isRelatedUris(object.id, value)) { + throw new Error(`invalid AP object ${value}: id ${object.id} has unrelated host`); } return object; diff --git a/packages/backend/src/core/activitypub/misc/check-against-url.ts b/packages/backend/src/core/activitypub/misc/check-against-url.ts deleted file mode 100644 index 78ba891a2..000000000 --- a/packages/backend/src/core/activitypub/misc/check-against-url.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* - * SPDX-FileCopyrightText: dakkar and sharkey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ -import type { IObject } from '../type.js'; - -export function assertActivityMatchesUrls(activity: IObject, urls: string[]) { - const idOk = activity.id !== undefined && urls.includes(activity.id); - - // technically `activity.url` could be an `ApObject = IObject | - // string | (IObject | string)[]`, but if it's a complicated thing - // and the `activity.id` doesn't match, I think we're fine - // rejecting the activity - const urlOk = typeof(activity.url) === 'string' && urls.includes(activity.url); - - if (!idOk && !urlOk) { - throw new Error(`bad Activity: neither id(${activity?.id}) nor url(${activity?.url}) match location(${urls})`); - } -} diff --git a/packages/backend/src/core/activitypub/models/ApNoteService.ts b/packages/backend/src/core/activitypub/models/ApNoteService.ts index 8bba1994e..03c91b3c4 100644 --- a/packages/backend/src/core/activitypub/models/ApNoteService.ts +++ b/packages/backend/src/core/activitypub/models/ApNoteService.ts @@ -89,24 +89,31 @@ export class ApNoteService { } let actualHost = object.id && this.utilityService.extractHost(object.id); - if (actualHost && expectedHost !== actualHost) { - return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: id has different host. expected: ${expectedHost}, actual: ${actualHost}`); + if (actualHost && !this.utilityService.isRelatedHosts(expectedHost, actualHost)) { + return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: id has unrelated host. expected: ${expectedHost}, actual: ${actualHost}`); } actualHost = object.attributedTo && this.utilityService.extractHost(getOneApId(object.attributedTo)); - if (actualHost && expectedHost !== actualHost) { - return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: attributedTo has different host. expected: ${expectedHost}, actual: ${actualHost}`); + if (actualHost && !this.utilityService.isRelatedHosts(expectedHost, actualHost)) { + return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: attributedTo has unrelated host. expected: ${expectedHost}, actual: ${actualHost}`); } if (object.published && !this.idService.isSafeT(new Date(object.published).valueOf())) { return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', 'invalid Note: published timestamp is malformed'); } - if (actor) { - const attribution = (object.attributedTo) ? getOneApId(object.attributedTo) : actor.uri; + if (actor?.uri) { + if (!this.utilityService.isRelatedUris(uri, actor.uri)) { + return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: object has unrelated host to actor. actor: ${actor.uri}, object: ${uri}`); + } - if (attribution !== actor.uri) { - return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: attribution does not match the actor that send it. attribution: ${attribution}, actor: ${actor.uri}`); + if (object.id && !this.utilityService.isRelatedUris(object.id, actor.uri)) { + return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: id has unrelated host to actor. actor: ${actor.uri}, id: ${object.id}`); + } + + const attributedTo = object.attributedTo && getOneApId(object.attributedTo); + if (attributedTo && !this.utilityService.isRelatedUris(attributedTo, actor.uri)) { + return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: attributedTo has unrelated host to actor. actor: ${actor.uri}, attributedTo: ${attributedTo}`); } } @@ -166,8 +173,8 @@ export class ApNoteService { throw new Error('unexpected schema of note url: ' + url); } - if (this.utilityService.extractHost(note.id) !== this.utilityService.extractHost(url)) { - throw new Error(`note id and url have different host: ${note.id} - ${url}`); + if (!this.utilityService.isRelatedUris(note.id, url)) { + throw new Error(`note id and url has unrelated host: ${note.id} - ${url}`); } } diff --git a/packages/backend/src/core/activitypub/models/ApPersonService.ts b/packages/backend/src/core/activitypub/models/ApPersonService.ts index cbebdfebd..7016dfa03 100644 --- a/packages/backend/src/core/activitypub/models/ApPersonService.ts +++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts @@ -152,8 +152,8 @@ export class ApPersonService implements OnModuleInit { } let actualHost = this.utilityService.extractHost(x.inbox); - if (expectedHost !== actualHost) { - throw new Error(`invalid Actor: inbox has different host. expected: ${expectedHost}, actual: ${actualHost}`); + if (!this.utilityService.isRelatedHosts(expectedHost, actualHost)) { + throw new Error(`invalid Actor: inbox has unrelated host. expected: ${expectedHost}, actual: ${actualHost}`); } const sharedInboxObject = x.sharedInbox ?? (x.endpoints ? x.endpoints.sharedInbox : undefined); @@ -161,8 +161,8 @@ export class ApPersonService implements OnModuleInit { const sharedInbox = getApId(sharedInboxObject); if (!sharedInbox) throw new Error('invalid Actor: wrong shared inbox'); actualHost = this.utilityService.extractHost(sharedInbox); - if (expectedHost !== actualHost) { - throw new Error(`invalid Actor: shared inbox has different host. expected: ${expectedHost}, actual: ${actualHost}`); + if (!this.utilityService.isRelatedHosts(expectedHost, actualHost)) { + throw new Error(`invalid Actor: shared inbox has unrelated host. expected: ${expectedHost}, actual: ${actualHost}`); } } @@ -172,8 +172,8 @@ export class ApPersonService implements OnModuleInit { const collectionUri = getApId(xCollection); if (!collectionUri) throw new Error(`invalid Actor: wrong ${collection}`); actualHost = this.utilityService.extractHost(collectionUri); - if (expectedHost !== actualHost) { - throw new Error(`invalid Actor: ${collection} has different host. expected: ${expectedHost}, actual: ${actualHost}`); + if (!this.utilityService.isRelatedHosts(expectedHost, actualHost)) { + throw new Error(`invalid Actor: ${collection} has unrelated host. expected: ${expectedHost}, actual: ${actualHost}`); } } } @@ -202,8 +202,8 @@ export class ApPersonService implements OnModuleInit { } actualHost = this.utilityService.extractHost(x.id); - if (expectedHost !== actualHost) { - throw new Error(`invalid Actor: id has different host. expected: ${expectedHost}, actual: ${actualHost}`); + if (!this.utilityService.isRelatedHosts(expectedHost, actualHost)) { + throw new Error(`invalid Actor: id has unrelated host. expected: ${expectedHost}, actual: ${actualHost}`); } if (x.publicKey) { @@ -212,8 +212,8 @@ export class ApPersonService implements OnModuleInit { } actualHost = this.utilityService.extractHost(x.publicKey.id); - if (expectedHost !== actualHost) { - throw new Error(`invalid Actor: publicKey.id has different host. expected: ${expectedHost}, actual: ${actualHost}`); + if (!this.utilityService.isRelatedHosts(expectedHost, actualHost)) { + throw new Error(`invalid Actor: publicKey.id has unrelated host. expected: ${expectedHost}, actual: ${actualHost}`); } } @@ -345,8 +345,8 @@ export class ApPersonService implements OnModuleInit { throw new Error('unexpected schema of person url: ' + url); } - if (this.utilityService.extractHost(person.id) !== this.utilityService.extractHost(url)) { - throw new Error(`person id and url have different host: ${person.id} - ${url}`); + if (!this.utilityService.isRelatedUris(person.id, url)) { + throw new Error(`person id and url has unrelated host: ${person.id} - ${url}`); } } @@ -543,8 +543,8 @@ export class ApPersonService implements OnModuleInit { throw new Error('unexpected schema of person url: ' + url); } - if (this.utilityService.extractHost(person.id) !== this.utilityService.extractHost(url)) { - throw new Error(`person id and url have different host: ${person.id} - ${url}`); + if (!this.utilityService.isRelatedUris(person.id, url)) { + throw new Error(`person id and url has unrelated host: ${person.id} - ${url}`); } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7644a0b23..edcf33cc0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -354,6 +354,9 @@ importers: promise-limit: specifier: 2.7.0 version: 2.7.0 + psl: + specifier: 1.15.0 + version: 1.15.0 pug: specifier: 3.0.3 version: 3.0.3 @@ -611,6 +614,9 @@ importers: '@types/pg': specifier: 8.11.10 version: 8.11.10 + '@types/psl': + specifier: 1.1.3 + version: 1.1.3 '@types/pug': specifier: 2.0.10 version: 2.0.10 @@ -3759,6 +3765,9 @@ packages: '@types/pg@8.11.10': resolution: {integrity: sha512-LczQUW4dbOQzsH2RQ5qoeJ6qJPdrcM/DcMLoqWQkMLMsq83J5lAX3LXjdkWdpscFy67JSOWDnh7Ny/sPFykmkg==} + '@types/psl@1.1.3': + resolution: {integrity: sha512-Iu174JHfLd7i/XkXY6VDrqSlPvTDQOtQI7wNAXKKOAADJ9TduRLkNdMgjGiMxSttUIZnomv81JAbAbC0DhggxA==} + '@types/pug@2.0.10': resolution: {integrity: sha512-Sk/uYFOBAB7mb74XcpizmH0KOR2Pv3D2Hmrh1Dmy5BmK3MpdSa5kqZcg6EKBdklU0bFXX9gCfzvpnyUehrPIuA==} @@ -8120,6 +8129,9 @@ packages: engines: {node: '>= 0.10'} hasBin: true + psl@1.15.0: + resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} + pstree.remy@1.1.8: resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==} @@ -13189,6 +13201,8 @@ snapshots: pg-protocol: 1.7.0 pg-types: 4.0.2 + '@types/psl@1.1.3': {} + '@types/pug@2.0.10': {} '@types/punycode@2.1.4': {} @@ -18414,6 +18428,10 @@ snapshots: dependencies: event-stream: 3.3.4 + psl@1.15.0: + dependencies: + punycode: 2.3.1 + pstree.remy@1.1.8: {} pug-attrs@3.0.0: