Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 9 commits into from
Dec 28, 2024
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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/punycode@2.1.4",
"@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 = hostA.split(':')[0];
if (hostB.includes(':')) hostB = hostB.split(':')[0];
u1-liquid marked this conversation as resolved.
Show resolved Hide resolved

// ホストが完全一致している場合は 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.

23 changes: 13 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,27 @@ 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 (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)) {
u1-liquid marked this conversation as resolved.
Show resolved Hide resolved
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 +169,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
Loading