From e5d117dc98b725f908402638754f8643bbbeef90 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=A5=BA=E5=AD=90w=20=28Yumechi=29?=
 <35571479+eternal-flame-AD@users.noreply.github.com>
Date: Wed, 12 Mar 2025 12:39:24 +0000
Subject: [PATCH 1/3] fix(backend): tighten an overly relaxed criteria and
 remove capability of matching multiple final URLs in URL authority checking
 (#15655)

Signed-off-by: eternal-flame-AD <yume@yumechi.jp>
---
 CHANGELOG.md                                  |  2 +-
 .../backend/src/core/HttpRequestService.ts    |  4 +-
 .../src/core/activitypub/ApRequestService.ts  |  4 +-
 .../activitypub/misc/check-against-url.ts     | 22 ++++--
 packages/backend/test/unit/ap-request.ts      | 75 ++++++++-----------
 5 files changed, 49 insertions(+), 58 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index d02b37cbdb..a8b7f47a29 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,7 +15,7 @@
 
 ### Server
 - Fix: プロフィール追加情報で無効なURLに入力された場合に照会エラーを出るのを修正
-
+- Fix: ActivityPubリクエストURLチェック実装は仕様に従っていないのを修正
 
 ## 2025.3.1
 
diff --git a/packages/backend/src/core/HttpRequestService.ts b/packages/backend/src/core/HttpRequestService.ts
index 13d8f7f43b..3ddfe52045 100644
--- a/packages/backend/src/core/HttpRequestService.ts
+++ b/packages/backend/src/core/HttpRequestService.ts
@@ -16,7 +16,7 @@ import type { Config } from '@/config.js';
 import { StatusError } from '@/misc/status-error.js';
 import { bindThis } from '@/decorators.js';
 import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
-import { assertActivityMatchesUrls, FetchAllowSoftFailMask } from '@/core/activitypub/misc/check-against-url.js';
+import { assertActivityMatchesUrl, FetchAllowSoftFailMask } 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';
@@ -265,7 +265,7 @@ export class HttpRequestService {
 		const finalUrl = res.url; // redirects may have been involved
 		const activity = await res.json() as IObject;
 
-		assertActivityMatchesUrls(url, activity, [finalUrl], allowSoftfail);
+		assertActivityMatchesUrl(url, activity, finalUrl, allowSoftfail);
 
 		return activity;
 	}
diff --git a/packages/backend/src/core/activitypub/ApRequestService.ts b/packages/backend/src/core/activitypub/ApRequestService.ts
index 6c29cce325..61d328ccac 100644
--- a/packages/backend/src/core/activitypub/ApRequestService.ts
+++ b/packages/backend/src/core/activitypub/ApRequestService.ts
@@ -17,7 +17,7 @@ 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, FetchAllowSoftFailMask as FetchAllowSoftFailMask } from '@/core/activitypub/misc/check-against-url.js';
+import { assertActivityMatchesUrl, FetchAllowSoftFailMask as FetchAllowSoftFailMask } from '@/core/activitypub/misc/check-against-url.js';
 import type { IObject } from './type.js';
 
 type Request = {
@@ -258,7 +258,7 @@ export class ApRequestService {
 		const finalUrl = res.url; // redirects may have been involved
 		const activity = await res.json() as IObject;
 
-		assertActivityMatchesUrls(url, activity, [finalUrl], allowSoftfail);
+		assertActivityMatchesUrl(url, activity, finalUrl, allowSoftfail);
 
 		return activity;
 	}
diff --git a/packages/backend/src/core/activitypub/misc/check-against-url.ts b/packages/backend/src/core/activitypub/misc/check-against-url.ts
index dfcfb1943e..bbfe57f9fa 100644
--- a/packages/backend/src/core/activitypub/misc/check-against-url.ts
+++ b/packages/backend/src/core/activitypub/misc/check-against-url.ts
@@ -75,7 +75,7 @@ function normalizeSynonymousSubdomain(url: URL | string): URL {
 	return new URL(urlParsed.toString().replace(host, normalizedHost));
 }
 
-export function assertActivityMatchesUrls(requestUrl: string | URL, activity: IObject, candidateUrls: (string | URL)[], allowSoftfail: FetchAllowSoftFailMask): FetchAllowSoftFailMask {
+export function assertActivityMatchesUrl(requestUrl: string | URL, activity: IObject, finalUrl: string | URL, allowSoftfail: FetchAllowSoftFailMask): FetchAllowSoftFailMask {
 	// must have a unique identifier to verify authority
 	if (!activity.id) {
 		throw new Error('bad Activity: missing id field');
@@ -95,26 +95,32 @@ export function assertActivityMatchesUrls(requestUrl: string | URL, activity: IO
 	const requestUrlParsed = normalizeSynonymousSubdomain(requestUrl);
 	const idParsed = normalizeSynonymousSubdomain(activity.id);
 
-	const candidateUrlsParsed = candidateUrls.map(it => normalizeSynonymousSubdomain(it));
+	const finalUrlParsed = normalizeSynonymousSubdomain(finalUrl);
+
+	// mastodon sends activities with hash in the URL
+	// currently it only happens with likes, deletes etc.
+	// but object ID never has hash
+	requestUrlParsed.hash = '';
+	finalUrlParsed.hash = '';
 
 	const requestUrlSecure = requestUrlParsed.protocol === 'https:';
-	const finalUrlSecure = candidateUrlsParsed.every(it => it.protocol === 'https:');
+	const finalUrlSecure = finalUrlParsed.protocol === 'https:';
 	if (requestUrlSecure && !finalUrlSecure) {
 		throw new Error(`bad Activity: id(${activity.id}) is not allowed to have http:// in the url`);
 	}
 
 	// Compare final URL to the ID
-	if (!candidateUrlsParsed.some(it => it.href === idParsed.href)) {
-		requireSoftfail(FetchAllowSoftFailMask.NonCanonicalId, `bad Activity: id(${activity.id}) does not match response url(${candidateUrlsParsed.map(it => it.toString())})`);
+	if (finalUrlParsed.href !== idParsed.href) {
+		requireSoftfail(FetchAllowSoftFailMask.NonCanonicalId, `bad Activity: id(${activity.id}) does not match response url(${finalUrlParsed.toString()})`);
 
 		// at lease host need to match exactly (ActivityPub requirement)
-		if (!candidateUrlsParsed.some(it => idParsed.host === it.host)) {
-			throw new Error(`bad Activity: id(${activity.id}) does not match response host(${candidateUrlsParsed.map(it => it.host)})`);
+		if (idParsed.host !== finalUrlParsed.host) {
+			throw new Error(`bad Activity: id(${activity.id}) does not match response host(${finalUrlParsed.host})`);
 		}
 	}
 
 	// Compare request URL to the ID
-	if (!requestUrlParsed.href.includes(idParsed.href)) {
+	if (requestUrlParsed.href !== idParsed.href) {
 		requireSoftfail(FetchAllowSoftFailMask.NonCanonicalId, `bad Activity: id(${activity.id}) does not match request url(${requestUrlParsed.toString()})`);
 
 		// if cross-origin lookup is allowed, we can accept some variation between the original request URL to the final object ID (but not between the final URL and the object ID)
diff --git a/packages/backend/test/unit/ap-request.ts b/packages/backend/test/unit/ap-request.ts
index 0426de8e19..f8b2a697f2 100644
--- a/packages/backend/test/unit/ap-request.ts
+++ b/packages/backend/test/unit/ap-request.ts
@@ -8,7 +8,7 @@ import httpSignature from '@peertube/http-signature';
 
 import { genRsaKeyPair } from '@/misc/gen-key-pair.js';
 import { ApRequestCreator } from '@/core/activitypub/ApRequestService.js';
-import { assertActivityMatchesUrls, FetchAllowSoftFailMask } from '@/core/activitypub/misc/check-against-url.js';
+import { assertActivityMatchesUrl, FetchAllowSoftFailMask } from '@/core/activitypub/misc/check-against-url.js';
 import { IObject } from '@/core/activitypub/type.js';
 
 export const buildParsedSignature = (signingString: string, signature: string, algorithm: string) => {
@@ -66,23 +66,26 @@ describe('ap-request', () => {
 	});
 
 	test('rejects non matching domain', () => {
-		assert.doesNotThrow(() => assertActivityMatchesUrls(
+		assert.doesNotThrow(() => assertActivityMatchesUrl(
 			'https://alice.example.com/abc',
 			{ id: 'https://alice.example.com/abc' } as IObject,
-			[
-				'https://alice.example.com/abc',
-			],
+			'https://alice.example.com/abc',
 			FetchAllowSoftFailMask.Strict,
 		), 'validation should pass base case');
-		assert.throws(() => assertActivityMatchesUrls(
+		assert.throws(() => assertActivityMatchesUrl(
 			'https://alice.example.com/abc',
 			{ id: 'https://bob.example.com/abc' } as IObject,
-			[
-				'https://alice.example.com/abc',
-			],
+			'https://alice.example.com/abc',
 			FetchAllowSoftFailMask.Any,
 		), 'validation should fail no matter what if the response URL is inconsistent with the object ID');
 		
+		assert.doesNotThrow(() => assertActivityMatchesUrl(
+			'https://alice.example.com/abc#test',
+			{ id: 'https://alice.example.com/abc' } as IObject,
+			'https://alice.example.com/abc',
+			FetchAllowSoftFailMask.Strict,
+		), 'validation should pass with hash in request URL');
+
 		// fix issues like threads
 		// https://github.com/misskey-dev/misskey/issues/15039
 		const withOrWithoutWWW = [
@@ -97,89 +100,71 @@ describe('ap-request', () => {
 			),
 			withOrWithoutWWW,
 		).forEach(([[a, b], c]) => {
-			assert.doesNotThrow(() => assertActivityMatchesUrls(
+			assert.doesNotThrow(() => assertActivityMatchesUrl(
 				a,
 				{ id: b } as IObject,
-				[
-					c,
-				],
+				c,
 				FetchAllowSoftFailMask.Strict,
 			), 'validation should pass with or without www. subdomain');
 		});
 	});
 
 	test('cross origin lookup', () => {
-		assert.doesNotThrow(() => assertActivityMatchesUrls(
+		assert.doesNotThrow(() => assertActivityMatchesUrl(
 			'https://alice.example.com/abc',
 			{ id: 'https://bob.example.com/abc' } as IObject,
-			[
-				'https://bob.example.com/abc',
-			],
+			'https://bob.example.com/abc',
 			FetchAllowSoftFailMask.CrossOrigin | FetchAllowSoftFailMask.NonCanonicalId,
 		), 'validation should pass if the response is otherwise consistent and cross-origin is allowed');
-		assert.throws(() => assertActivityMatchesUrls(
+		assert.throws(() => assertActivityMatchesUrl(
 			'https://alice.example.com/abc',
 			{ id: 'https://bob.example.com/abc' } as IObject,
-			[
-				'https://bob.example.com/abc',
-			],
+			'https://bob.example.com/abc',
 			FetchAllowSoftFailMask.Strict,
 		), 'validation should fail if the response is otherwise consistent and cross-origin is not allowed');
 	});
 
 	test('rejects non-canonical ID', () => {
-		assert.throws(() => assertActivityMatchesUrls(
+		assert.throws(() => assertActivityMatchesUrl(
 			'https://alice.example.com/@alice',
 			{ id: 'https://alice.example.com/users/alice' } as IObject,
-			[
-				'https://alice.example.com/users/alice'
-			],
+			'https://alice.example.com/users/alice',
 			FetchAllowSoftFailMask.Strict,
 		), 'throws if the response ID did not exactly match the expected ID');
-		assert.doesNotThrow(() => assertActivityMatchesUrls(
+		assert.doesNotThrow(() => assertActivityMatchesUrl(
 			'https://alice.example.com/@alice',
 			{ id: 'https://alice.example.com/users/alice' } as IObject,
-			[
-				'https://alice.example.com/users/alice',
-			],
+			'https://alice.example.com/users/alice',
 			FetchAllowSoftFailMask.NonCanonicalId,
 		), 'does not throw if non-canonical ID is allowed');
 	});
 
 	test('origin relaxed alignment', () => {
-		assert.doesNotThrow(() => assertActivityMatchesUrls(
+		assert.doesNotThrow(() => assertActivityMatchesUrl(
 			'https://alice.example.com/abc',
 			{ id: 'https://ap.alice.example.com/abc' } as IObject,
-			[
-				'https://ap.alice.example.com/abc',
-			],
+			'https://ap.alice.example.com/abc',
 			FetchAllowSoftFailMask.MisalignedOrigin | FetchAllowSoftFailMask.NonCanonicalId,
 		), 'validation should pass if response is a subdomain of the expected origin');
-		assert.throws(() => assertActivityMatchesUrls(
+		assert.throws(() => assertActivityMatchesUrl(
 			'https://alice.multi-tenant.example.com/abc',
 			{ id: 'https://alice.multi-tenant.example.com/abc' } as IObject,
-			[
-				'https://bob.multi-tenant.example.com/abc',
-			],
+			'https://bob.multi-tenant.example.com/abc',
 			FetchAllowSoftFailMask.MisalignedOrigin | FetchAllowSoftFailMask.NonCanonicalId,
 		), 'validation should fail if response is a disjoint domain of the expected origin');
-		assert.throws(() => assertActivityMatchesUrls(
+		assert.throws(() => assertActivityMatchesUrl(
 			'https://alice.example.com/abc',
 			{ id: 'https://ap.alice.example.com/abc' } as IObject,
-			[
-				'https://ap.alice.example.com/abc',
-			],
+			'https://ap.alice.example.com/abc',
 			FetchAllowSoftFailMask.Strict,
 		), 'throws if relaxed origin is forbidden');
 	});
 
 	test('resist HTTP downgrade', () => {
-		assert.throws(() => assertActivityMatchesUrls(
+		assert.throws(() => assertActivityMatchesUrl(
 			'https://alice.example.com/abc',
 			{ id: 'https://alice.example.com/abc' } as IObject,
-			[
-				'http://alice.example.com/abc',
-			],
+			'http://alice.example.com/abc',
 			FetchAllowSoftFailMask.Strict,
 		), 'throws if HTTP downgrade is detected');
 	});

From c9fa95429aeb0a6b2570b3e67452f4e5b0b56e9f Mon Sep 17 00:00:00 2001
From: "github-actions[bot]" <github-actions[bot]@users.noreply.github.com>
Date: Wed, 12 Mar 2025 12:45:35 +0000
Subject: [PATCH 2/3] Bump version to 2025.3.2-alpha.9

---
 package.json                     | 2 +-
 packages/misskey-js/package.json | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/package.json b/package.json
index 8f89108665..3367c475cc 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
 	"name": "misskey",
-	"version": "2025.3.2-alpha.8",
+	"version": "2025.3.2-alpha.9",
 	"codename": "nasubi",
 	"repository": {
 		"type": "git",
diff --git a/packages/misskey-js/package.json b/packages/misskey-js/package.json
index 021c5a54bd..945ea588b4 100644
--- a/packages/misskey-js/package.json
+++ b/packages/misskey-js/package.json
@@ -1,7 +1,7 @@
 {
 	"type": "module",
 	"name": "misskey-js",
-	"version": "2025.3.2-alpha.8",
+	"version": "2025.3.2-alpha.9",
 	"description": "Misskey SDK for JavaScript",
 	"license": "MIT",
 	"main": "./built/index.js",

From 7b6ff19ea37c3dddf82a09419f070961bcb9a39d Mon Sep 17 00:00:00 2001
From: syuilo <4439005+syuilo@users.noreply.github.com>
Date: Wed, 12 Mar 2025 21:49:23 +0900
Subject: [PATCH 3/3] Update CHANGELOG.md

---
 CHANGELOG.md | 1 +
 1 file changed, 1 insertion(+)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index a8b7f47a29..33b6c20601 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,7 @@
   - 自動でバックアップされるように
 	- 任意の設定項目をデバイス間で同期できるように(実験的)
 - Enhance: プラグインの管理が強化されました
+  - インストール/アンインストール/設定の変更時にリロード不要になりました
 - Enhance: CWの注釈テキストが入力されていない場合, Postボタンを非アクティブに
 - Enhance: CWを無効にした場合, 注釈テキストが最大入力文字数を超えていても投稿できるように
 - Enhance: テーマ設定画面のデザインを改善