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>
This commit is contained in:
饺子w (Yumechi) 2025-03-12 12:39:24 +00:00 committed by GitHub
parent 4a73feb041
commit e5d117dc98
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 49 additions and 58 deletions

View File

@ -15,7 +15,7 @@
### Server ### Server
- Fix: プロフィール追加情報で無効なURLに入力された場合に照会エラーを出るのを修正 - Fix: プロフィール追加情報で無効なURLに入力された場合に照会エラーを出るのを修正
- Fix: ActivityPubリクエストURLチェック実装は仕様に従っていないのを修正
## 2025.3.1 ## 2025.3.1

View File

@ -16,7 +16,7 @@ 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, 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 { IObject } from '@/core/activitypub/type.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';
@ -265,7 +265,7 @@ export class HttpRequestService {
const finalUrl = res.url; // redirects may have been involved const finalUrl = res.url; // redirects may have been involved
const activity = await res.json() as IObject; const activity = await res.json() as IObject;
assertActivityMatchesUrls(url, activity, [finalUrl], allowSoftfail); assertActivityMatchesUrl(url, activity, finalUrl, allowSoftfail);
return activity; return activity;
} }

View File

@ -17,7 +17,7 @@ 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, 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'; import type { IObject } from './type.js';
type Request = { type Request = {
@ -258,7 +258,7 @@ export class ApRequestService {
const finalUrl = res.url; // redirects may have been involved const finalUrl = res.url; // redirects may have been involved
const activity = await res.json() as IObject; const activity = await res.json() as IObject;
assertActivityMatchesUrls(url, activity, [finalUrl], allowSoftfail); assertActivityMatchesUrl(url, activity, finalUrl, allowSoftfail);
return activity; return activity;
} }

View File

@ -75,7 +75,7 @@ function normalizeSynonymousSubdomain(url: URL | string): URL {
return new URL(urlParsed.toString().replace(host, normalizedHost)); 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 // must have a unique identifier to verify authority
if (!activity.id) { if (!activity.id) {
throw new Error('bad Activity: missing id field'); 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 requestUrlParsed = normalizeSynonymousSubdomain(requestUrl);
const idParsed = normalizeSynonymousSubdomain(activity.id); 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 requestUrlSecure = requestUrlParsed.protocol === 'https:';
const finalUrlSecure = candidateUrlsParsed.every(it => it.protocol === 'https:'); const finalUrlSecure = finalUrlParsed.protocol === 'https:';
if (requestUrlSecure && !finalUrlSecure) { if (requestUrlSecure && !finalUrlSecure) {
throw new Error(`bad Activity: id(${activity.id}) is not allowed to have http:// in the url`); throw new Error(`bad Activity: id(${activity.id}) is not allowed to have http:// in the url`);
} }
// Compare final URL to the ID // Compare final URL to the ID
if (!candidateUrlsParsed.some(it => it.href === idParsed.href)) { if (finalUrlParsed.href !== idParsed.href) {
requireSoftfail(FetchAllowSoftFailMask.NonCanonicalId, `bad Activity: id(${activity.id}) does not match response url(${candidateUrlsParsed.map(it => it.toString())})`); requireSoftfail(FetchAllowSoftFailMask.NonCanonicalId, `bad Activity: id(${activity.id}) does not match response url(${finalUrlParsed.toString()})`);
// at lease host need to match exactly (ActivityPub requirement) // at lease host need to match exactly (ActivityPub requirement)
if (!candidateUrlsParsed.some(it => idParsed.host === it.host)) { if (idParsed.host !== finalUrlParsed.host) {
throw new Error(`bad Activity: id(${activity.id}) does not match response host(${candidateUrlsParsed.map(it => it.host)})`); throw new Error(`bad Activity: id(${activity.id}) does not match response host(${finalUrlParsed.host})`);
} }
} }
// Compare request URL to the ID // 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()})`); 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) // 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)

View File

@ -8,7 +8,7 @@ import httpSignature from '@peertube/http-signature';
import { genRsaKeyPair } from '@/misc/gen-key-pair.js'; import { genRsaKeyPair } from '@/misc/gen-key-pair.js';
import { ApRequestCreator } from '@/core/activitypub/ApRequestService.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'; import { IObject } from '@/core/activitypub/type.js';
export const buildParsedSignature = (signingString: string, signature: string, algorithm: string) => { export const buildParsedSignature = (signingString: string, signature: string, algorithm: string) => {
@ -66,23 +66,26 @@ describe('ap-request', () => {
}); });
test('rejects non matching domain', () => { test('rejects non matching domain', () => {
assert.doesNotThrow(() => assertActivityMatchesUrls( assert.doesNotThrow(() => assertActivityMatchesUrl(
'https://alice.example.com/abc', 'https://alice.example.com/abc',
{ id: 'https://alice.example.com/abc' } as IObject, { id: 'https://alice.example.com/abc' } as IObject,
[ 'https://alice.example.com/abc',
'https://alice.example.com/abc',
],
FetchAllowSoftFailMask.Strict, FetchAllowSoftFailMask.Strict,
), 'validation should pass base case'); ), 'validation should pass base case');
assert.throws(() => assertActivityMatchesUrls( assert.throws(() => assertActivityMatchesUrl(
'https://alice.example.com/abc', 'https://alice.example.com/abc',
{ id: 'https://bob.example.com/abc' } as IObject, { id: 'https://bob.example.com/abc' } as IObject,
[ 'https://alice.example.com/abc',
'https://alice.example.com/abc',
],
FetchAllowSoftFailMask.Any, FetchAllowSoftFailMask.Any,
), 'validation should fail no matter what if the response URL is inconsistent with the object ID'); ), '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 // fix issues like threads
// https://github.com/misskey-dev/misskey/issues/15039 // https://github.com/misskey-dev/misskey/issues/15039
const withOrWithoutWWW = [ const withOrWithoutWWW = [
@ -97,89 +100,71 @@ describe('ap-request', () => {
), ),
withOrWithoutWWW, withOrWithoutWWW,
).forEach(([[a, b], c]) => { ).forEach(([[a, b], c]) => {
assert.doesNotThrow(() => assertActivityMatchesUrls( assert.doesNotThrow(() => assertActivityMatchesUrl(
a, a,
{ id: b } as IObject, { id: b } as IObject,
[ c,
c,
],
FetchAllowSoftFailMask.Strict, FetchAllowSoftFailMask.Strict,
), 'validation should pass with or without www. subdomain'); ), 'validation should pass with or without www. subdomain');
}); });
}); });
test('cross origin lookup', () => { test('cross origin lookup', () => {
assert.doesNotThrow(() => assertActivityMatchesUrls( assert.doesNotThrow(() => assertActivityMatchesUrl(
'https://alice.example.com/abc', 'https://alice.example.com/abc',
{ id: 'https://bob.example.com/abc' } as IObject, { id: 'https://bob.example.com/abc' } as IObject,
[ 'https://bob.example.com/abc',
'https://bob.example.com/abc',
],
FetchAllowSoftFailMask.CrossOrigin | FetchAllowSoftFailMask.NonCanonicalId, FetchAllowSoftFailMask.CrossOrigin | FetchAllowSoftFailMask.NonCanonicalId,
), 'validation should pass if the response is otherwise consistent and cross-origin is allowed'); ), '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', 'https://alice.example.com/abc',
{ id: 'https://bob.example.com/abc' } as IObject, { id: 'https://bob.example.com/abc' } as IObject,
[ 'https://bob.example.com/abc',
'https://bob.example.com/abc',
],
FetchAllowSoftFailMask.Strict, FetchAllowSoftFailMask.Strict,
), 'validation should fail if the response is otherwise consistent and cross-origin is not allowed'); ), 'validation should fail if the response is otherwise consistent and cross-origin is not allowed');
}); });
test('rejects non-canonical ID', () => { test('rejects non-canonical ID', () => {
assert.throws(() => assertActivityMatchesUrls( assert.throws(() => assertActivityMatchesUrl(
'https://alice.example.com/@alice', 'https://alice.example.com/@alice',
{ id: 'https://alice.example.com/users/alice' } as IObject, { id: 'https://alice.example.com/users/alice' } as IObject,
[ 'https://alice.example.com/users/alice',
'https://alice.example.com/users/alice'
],
FetchAllowSoftFailMask.Strict, FetchAllowSoftFailMask.Strict,
), 'throws if the response ID did not exactly match the expected ID'); ), 'throws if the response ID did not exactly match the expected ID');
assert.doesNotThrow(() => assertActivityMatchesUrls( assert.doesNotThrow(() => assertActivityMatchesUrl(
'https://alice.example.com/@alice', 'https://alice.example.com/@alice',
{ id: 'https://alice.example.com/users/alice' } as IObject, { id: 'https://alice.example.com/users/alice' } as IObject,
[ 'https://alice.example.com/users/alice',
'https://alice.example.com/users/alice',
],
FetchAllowSoftFailMask.NonCanonicalId, FetchAllowSoftFailMask.NonCanonicalId,
), 'does not throw if non-canonical ID is allowed'); ), 'does not throw if non-canonical ID is allowed');
}); });
test('origin relaxed alignment', () => { test('origin relaxed alignment', () => {
assert.doesNotThrow(() => assertActivityMatchesUrls( assert.doesNotThrow(() => assertActivityMatchesUrl(
'https://alice.example.com/abc', 'https://alice.example.com/abc',
{ id: 'https://ap.alice.example.com/abc' } as IObject, { 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, FetchAllowSoftFailMask.MisalignedOrigin | FetchAllowSoftFailMask.NonCanonicalId,
), 'validation should pass if response is a subdomain of the expected origin'); ), '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', 'https://alice.multi-tenant.example.com/abc',
{ id: 'https://alice.multi-tenant.example.com/abc' } as IObject, { 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, FetchAllowSoftFailMask.MisalignedOrigin | FetchAllowSoftFailMask.NonCanonicalId,
), 'validation should fail if response is a disjoint domain of the expected origin'); ), 'validation should fail if response is a disjoint domain of the expected origin');
assert.throws(() => assertActivityMatchesUrls( assert.throws(() => assertActivityMatchesUrl(
'https://alice.example.com/abc', 'https://alice.example.com/abc',
{ id: 'https://ap.alice.example.com/abc' } as IObject, { id: 'https://ap.alice.example.com/abc' } as IObject,
[ 'https://ap.alice.example.com/abc',
'https://ap.alice.example.com/abc',
],
FetchAllowSoftFailMask.Strict, FetchAllowSoftFailMask.Strict,
), 'throws if relaxed origin is forbidden'); ), 'throws if relaxed origin is forbidden');
}); });
test('resist HTTP downgrade', () => { test('resist HTTP downgrade', () => {
assert.throws(() => assertActivityMatchesUrls( assert.throws(() => assertActivityMatchesUrl(
'https://alice.example.com/abc', 'https://alice.example.com/abc',
{ id: 'https://alice.example.com/abc' } as IObject, { id: 'https://alice.example.com/abc' } as IObject,
[ 'http://alice.example.com/abc',
'http://alice.example.com/abc',
],
FetchAllowSoftFailMask.Strict, FetchAllowSoftFailMask.Strict,
), 'throws if HTTP downgrade is detected'); ), 'throws if HTTP downgrade is detected');
}); });