fix(ActivityPub): URIとURLが一致しない場合、同じドメイン内のサブドメインの1階層の違いまでは許容する (MisskeyIO#859)

This commit is contained in:
あわわわとーにゅ 2024-12-28 18:49:13 +09:00 committed by GitHub
parent 7bcc254fd4
commit 543325582c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 161 additions and 50 deletions

View file

@ -156,6 +156,7 @@
"pkce-challenge": "4.1.0", "pkce-challenge": "4.1.0",
"probe-image-size": "7.2.3", "probe-image-size": "7.2.3",
"promise-limit": "2.7.0", "promise-limit": "2.7.0",
"psl": "1.15.0",
"pug": "3.0.3", "pug": "3.0.3",
"punycode.js": "2.3.1", "punycode.js": "2.3.1",
"qrcode": "1.5.4", "qrcode": "1.5.4",
@ -216,6 +217,7 @@
"@types/oauth2orize": "1.11.5", "@types/oauth2orize": "1.11.5",
"@types/oauth2orize-pkce": "0.1.2", "@types/oauth2orize-pkce": "0.1.2",
"@types/pg": "8.11.10", "@types/pg": "8.11.10",
"@types/psl": "1.1.3",
"@types/pug": "2.0.10", "@types/pug": "2.0.10",
"@types/punycode.js": "npm:@types/punycode@2.1.4", "@types/punycode.js": "npm:@types/punycode@2.1.4",
"@types/qrcode": "1.5.5", "@types/qrcode": "1.5.5",

View file

@ -13,10 +13,10 @@ import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
import { UtilityService } from '@/core/UtilityService.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 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';
@ -145,6 +145,8 @@ export class HttpRequestService {
constructor( constructor(
@Inject(DI.config) @Inject(DI.config)
private config: Config, private config: Config,
private utilityService: UtilityService,
) { ) {
const cache = new CacheableLookup({ const cache = new CacheableLookup({
maxTtl: 3600, // 1hours maxTtl: 3600, // 1hours
@ -232,7 +234,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(activity, [finalUrl]); this.utilityService.assertActivityRelatedToUrl(activity, finalUrl);
return activity; return activity;
} }

View file

@ -4,12 +4,15 @@
*/ */
import { URL } from 'node:url'; import { URL } from 'node:url';
import { isIP } from 'node:net';
import punycode from 'punycode.js'; import punycode from 'punycode.js';
import psl from 'psl';
import RE2 from 're2'; import RE2 from 're2';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import type { IObject } from '@/core/activitypub/type.js';
@Injectable() @Injectable()
export class UtilityService { export class UtilityService {
@ -93,4 +96,102 @@ export class UtilityService {
// ref: https://url.spec.whatwg.org/#host-serializing // ref: https://url.spec.whatwg.org/#host-serializing
return new URL(uri).host; 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}`);
}
} }

View file

@ -17,7 +17,6 @@ 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 type { IObject } from './type.js'; import type { IObject } from './type.js';
type Request = { type Request = {
@ -182,6 +181,7 @@ 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 If true, follow alternate link tag in HTML
*/ */
@bindThis @bindThis
public async signedGet(url: string, user: { id: MiUser['id'] }, followAlternate?: boolean): Promise<unknown> { public async signedGet(url: string, user: { id: MiUser['id'] }, followAlternate?: boolean): Promise<unknown> {
@ -220,7 +220,7 @@ export class ApRequestService {
const alternate = fragment.querySelector('head > link[rel="alternate"][type="application/activity+json"]'); const alternate = fragment.querySelector('head > link[rel="alternate"][type="application/activity+json"]');
if (alternate) { if (alternate) {
const href = alternate.getAttribute('href'); 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); return await this.signedGet(href, user, false);
} }
} }
@ -234,7 +234,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(activity, [finalUrl]); this.utilityService.assertActivityRelatedToUrl(activity, finalUrl);
return activity; return activity;
} }

View file

@ -128,8 +128,8 @@ export class Resolver {
throw new Error('invalid AP object: missing id'); throw new Error('invalid AP object: missing id');
} }
if (this.utilityService.extractHost(object.id) !== this.utilityService.extractHost(value)) { if (!this.utilityService.isRelatedUris(object.id, value)) {
throw new Error(`invalid AP object ${value}: id ${object.id} has different host`); throw new Error(`invalid AP object ${value}: id ${object.id} has unrelated host`);
} }
return object; return object;

View file

@ -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})`);
}
}

View file

@ -89,24 +89,31 @@ export class ApNoteService {
} }
let actualHost = object.id && this.utilityService.extractHost(object.id); let actualHost = object.id && this.utilityService.extractHost(object.id);
if (actualHost && expectedHost !== actualHost) { if (actualHost && !this.utilityService.isRelatedHosts(expectedHost, actualHost)) {
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: id has different host. expected: ${expectedHost}, actual: ${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)); actualHost = object.attributedTo && this.utilityService.extractHost(getOneApId(object.attributedTo));
if (actualHost && expectedHost !== actualHost) { if (actualHost && !this.utilityService.isRelatedHosts(expectedHost, actualHost)) {
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: attributedTo has different host. expected: ${expectedHost}, actual: ${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())) { 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'); return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', 'invalid Note: published timestamp is malformed');
} }
if (actor) { if (actor?.uri) {
const attribution = (object.attributedTo) ? getOneApId(object.attributedTo) : 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) { if (object.id && !this.utilityService.isRelatedUris(object.id, 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}`); 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); throw new Error('unexpected schema of note url: ' + url);
} }
if (this.utilityService.extractHost(note.id) !== this.utilityService.extractHost(url)) { if (!this.utilityService.isRelatedUris(note.id, url)) {
throw new Error(`note id and url have different host: ${note.id} - ${url}`); throw new Error(`note id and url has unrelated host: ${note.id} - ${url}`);
} }
} }

View file

@ -152,8 +152,8 @@ export class ApPersonService implements OnModuleInit {
} }
let actualHost = this.utilityService.extractHost(x.inbox); let actualHost = this.utilityService.extractHost(x.inbox);
if (expectedHost !== actualHost) { if (!this.utilityService.isRelatedHosts(expectedHost, actualHost)) {
throw new Error(`invalid Actor: inbox has different host. expected: ${expectedHost}, actual: ${actualHost}`); throw new Error(`invalid Actor: inbox has unrelated host. expected: ${expectedHost}, actual: ${actualHost}`);
} }
const sharedInboxObject = x.sharedInbox ?? (x.endpoints ? x.endpoints.sharedInbox : undefined); const sharedInboxObject = x.sharedInbox ?? (x.endpoints ? x.endpoints.sharedInbox : undefined);
@ -161,8 +161,8 @@ export class ApPersonService implements OnModuleInit {
const sharedInbox = getApId(sharedInboxObject); const sharedInbox = getApId(sharedInboxObject);
if (!sharedInbox) throw new Error('invalid Actor: wrong shared inbox'); if (!sharedInbox) throw new Error('invalid Actor: wrong shared inbox');
actualHost = this.utilityService.extractHost(sharedInbox); actualHost = this.utilityService.extractHost(sharedInbox);
if (expectedHost !== actualHost) { if (!this.utilityService.isRelatedHosts(expectedHost, actualHost)) {
throw new Error(`invalid Actor: shared inbox has different host. expected: ${expectedHost}, actual: ${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); const collectionUri = getApId(xCollection);
if (!collectionUri) throw new Error(`invalid Actor: wrong ${collection}`); if (!collectionUri) throw new Error(`invalid Actor: wrong ${collection}`);
actualHost = this.utilityService.extractHost(collectionUri); actualHost = this.utilityService.extractHost(collectionUri);
if (expectedHost !== actualHost) { if (!this.utilityService.isRelatedHosts(expectedHost, actualHost)) {
throw new Error(`invalid Actor: ${collection} has different host. expected: ${expectedHost}, actual: ${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); actualHost = this.utilityService.extractHost(x.id);
if (expectedHost !== actualHost) { if (!this.utilityService.isRelatedHosts(expectedHost, actualHost)) {
throw new Error(`invalid Actor: id has different host. expected: ${expectedHost}, actual: ${actualHost}`); throw new Error(`invalid Actor: id has unrelated host. expected: ${expectedHost}, actual: ${actualHost}`);
} }
if (x.publicKey) { if (x.publicKey) {
@ -212,8 +212,8 @@ export class ApPersonService implements OnModuleInit {
} }
actualHost = this.utilityService.extractHost(x.publicKey.id); actualHost = this.utilityService.extractHost(x.publicKey.id);
if (expectedHost !== actualHost) { if (!this.utilityService.isRelatedHosts(expectedHost, actualHost)) {
throw new Error(`invalid Actor: publicKey.id has different host. expected: ${expectedHost}, actual: ${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); throw new Error('unexpected schema of person url: ' + url);
} }
if (this.utilityService.extractHost(person.id) !== this.utilityService.extractHost(url)) { if (!this.utilityService.isRelatedUris(person.id, url)) {
throw new Error(`person id and url have different host: ${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); throw new Error('unexpected schema of person url: ' + url);
} }
if (this.utilityService.extractHost(person.id) !== this.utilityService.extractHost(url)) { if (!this.utilityService.isRelatedUris(person.id, url)) {
throw new Error(`person id and url have different host: ${person.id} - ${url}`); throw new Error(`person id and url has unrelated host: ${person.id} - ${url}`);
} }
} }

View file

@ -354,6 +354,9 @@ importers:
promise-limit: promise-limit:
specifier: 2.7.0 specifier: 2.7.0
version: 2.7.0 version: 2.7.0
psl:
specifier: 1.15.0
version: 1.15.0
pug: pug:
specifier: 3.0.3 specifier: 3.0.3
version: 3.0.3 version: 3.0.3
@ -611,6 +614,9 @@ importers:
'@types/pg': '@types/pg':
specifier: 8.11.10 specifier: 8.11.10
version: 8.11.10 version: 8.11.10
'@types/psl':
specifier: 1.1.3
version: 1.1.3
'@types/pug': '@types/pug':
specifier: 2.0.10 specifier: 2.0.10
version: 2.0.10 version: 2.0.10
@ -3759,6 +3765,9 @@ packages:
'@types/pg@8.11.10': '@types/pg@8.11.10':
resolution: {integrity: sha512-LczQUW4dbOQzsH2RQ5qoeJ6qJPdrcM/DcMLoqWQkMLMsq83J5lAX3LXjdkWdpscFy67JSOWDnh7Ny/sPFykmkg==} resolution: {integrity: sha512-LczQUW4dbOQzsH2RQ5qoeJ6qJPdrcM/DcMLoqWQkMLMsq83J5lAX3LXjdkWdpscFy67JSOWDnh7Ny/sPFykmkg==}
'@types/psl@1.1.3':
resolution: {integrity: sha512-Iu174JHfLd7i/XkXY6VDrqSlPvTDQOtQI7wNAXKKOAADJ9TduRLkNdMgjGiMxSttUIZnomv81JAbAbC0DhggxA==}
'@types/pug@2.0.10': '@types/pug@2.0.10':
resolution: {integrity: sha512-Sk/uYFOBAB7mb74XcpizmH0KOR2Pv3D2Hmrh1Dmy5BmK3MpdSa5kqZcg6EKBdklU0bFXX9gCfzvpnyUehrPIuA==} resolution: {integrity: sha512-Sk/uYFOBAB7mb74XcpizmH0KOR2Pv3D2Hmrh1Dmy5BmK3MpdSa5kqZcg6EKBdklU0bFXX9gCfzvpnyUehrPIuA==}
@ -8120,6 +8129,9 @@ packages:
engines: {node: '>= 0.10'} engines: {node: '>= 0.10'}
hasBin: true hasBin: true
psl@1.15.0:
resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==}
pstree.remy@1.1.8: pstree.remy@1.1.8:
resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==} resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==}
@ -13189,6 +13201,8 @@ snapshots:
pg-protocol: 1.7.0 pg-protocol: 1.7.0
pg-types: 4.0.2 pg-types: 4.0.2
'@types/psl@1.1.3': {}
'@types/pug@2.0.10': {} '@types/pug@2.0.10': {}
'@types/punycode@2.1.4': {} '@types/punycode@2.1.4': {}
@ -18414,6 +18428,10 @@ snapshots:
dependencies: dependencies:
event-stream: 3.3.4 event-stream: 3.3.4
psl@1.15.0:
dependencies:
punycode: 2.3.1
pstree.remy@1.1.8: {} pstree.remy@1.1.8: {}
pug-attrs@3.0.0: pug-attrs@3.0.0: