Skip to content

Commit

Permalink
fix(ActivityPub): URIとURLが一致しない場合、同じドメイン内のサブドメインの1階層の違いまでは許容する (#859)
Browse files Browse the repository at this point in the history
  • Loading branch information
u1-liquid authored Dec 28, 2024
1 parent 7bcc254 commit 5433255
Show file tree
Hide file tree
Showing 9 changed files with 161 additions and 50 deletions.
2 changes: 2 additions & 0 deletions packages/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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/[email protected]",
"@types/qrcode": "1.5.5",
Expand Down
6 changes: 4 additions & 2 deletions packages/backend/src/core/HttpRequestService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -145,6 +145,8 @@ export class HttpRequestService {
constructor(
@Inject(DI.config)
private config: Config,

private utilityService: UtilityService,
) {
const cache = new CacheableLookup({
maxTtl: 3600, // 1hours
Expand Down Expand Up @@ -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;
}
Expand Down
101 changes: 101 additions & 0 deletions packages/backend/src/core/UtilityService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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}`);
}
}
6 changes: 3 additions & 3 deletions packages/backend/src/core/activitypub/ApRequestService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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<unknown> {
Expand Down Expand Up @@ -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);
}
}
Expand All @@ -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;
}
Expand Down
4 changes: 2 additions & 2 deletions packages/backend/src/core/activitypub/ApResolverService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
19 changes: 0 additions & 19 deletions packages/backend/src/core/activitypub/misc/check-against-url.ts

This file was deleted.

27 changes: 17 additions & 10 deletions packages/backend/src/core/activitypub/models/ApNoteService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (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}`);
}

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

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

Expand Down
28 changes: 14 additions & 14 deletions packages/backend/src/core/activitypub/models/ApPersonService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,17 +152,17 @@ 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);
if (sharedInboxObject != null) {
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}`);
}
}

Expand All @@ -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}`);
}
}
}
Expand Down Expand Up @@ -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) {
Expand All @@ -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}`);
}
}

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

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

Expand Down
Loading

0 comments on commit 5433255

Please sign in to comment.