forked from woem.men/forkey
fix(ActivityPub): URIとURLが一致しない場合、同じドメイン内のサブドメインの1階層の違いまでは許容する (MisskeyIO#859)
This commit is contained in:
parent
7bcc254fd4
commit
543325582c
9 changed files with 161 additions and 50 deletions
|
@ -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",
|
||||
|
@ -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",
|
||||
|
|
|
@ -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';
|
||||
|
@ -145,6 +145,8 @@ export class HttpRequestService {
|
|||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
private utilityService: UtilityService,
|
||||
) {
|
||||
const cache = new CacheableLookup({
|
||||
maxTtl: 3600, // 1hours
|
||||
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 = {
|
||||
|
@ -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> {
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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})`);
|
||||
}
|
||||
}
|
|
@ -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 (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}`);
|
||||
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}`);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -152,8 +152,8 @@ 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);
|
||||
|
@ -161,8 +161,8 @@ export class ApPersonService implements OnModuleInit {
|
|||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -354,6 +354,9 @@ importers:
|
|||
promise-limit:
|
||||
specifier: 2.7.0
|
||||
version: 2.7.0
|
||||
psl:
|
||||
specifier: 1.15.0
|
||||
version: 1.15.0
|
||||
pug:
|
||||
specifier: 3.0.3
|
||||
version: 3.0.3
|
||||
|
@ -611,6 +614,9 @@ importers:
|
|||
'@types/pg':
|
||||
specifier: 8.11.10
|
||||
version: 8.11.10
|
||||
'@types/psl':
|
||||
specifier: 1.1.3
|
||||
version: 1.1.3
|
||||
'@types/pug':
|
||||
specifier: 2.0.10
|
||||
version: 2.0.10
|
||||
|
@ -3759,6 +3765,9 @@ packages:
|
|||
'@types/pg@8.11.10':
|
||||
resolution: {integrity: sha512-LczQUW4dbOQzsH2RQ5qoeJ6qJPdrcM/DcMLoqWQkMLMsq83J5lAX3LXjdkWdpscFy67JSOWDnh7Ny/sPFykmkg==}
|
||||
|
||||
'@types/psl@1.1.3':
|
||||
resolution: {integrity: sha512-Iu174JHfLd7i/XkXY6VDrqSlPvTDQOtQI7wNAXKKOAADJ9TduRLkNdMgjGiMxSttUIZnomv81JAbAbC0DhggxA==}
|
||||
|
||||
'@types/pug@2.0.10':
|
||||
resolution: {integrity: sha512-Sk/uYFOBAB7mb74XcpizmH0KOR2Pv3D2Hmrh1Dmy5BmK3MpdSa5kqZcg6EKBdklU0bFXX9gCfzvpnyUehrPIuA==}
|
||||
|
||||
|
@ -8120,6 +8129,9 @@ packages:
|
|||
engines: {node: '>= 0.10'}
|
||||
hasBin: true
|
||||
|
||||
psl@1.15.0:
|
||||
resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==}
|
||||
|
||||
pstree.remy@1.1.8:
|
||||
resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==}
|
||||
|
||||
|
@ -13189,6 +13201,8 @@ snapshots:
|
|||
pg-protocol: 1.7.0
|
||||
pg-types: 4.0.2
|
||||
|
||||
'@types/psl@1.1.3': {}
|
||||
|
||||
'@types/pug@2.0.10': {}
|
||||
|
||||
'@types/punycode@2.1.4': {}
|
||||
|
@ -18414,6 +18428,10 @@ snapshots:
|
|||
dependencies:
|
||||
event-stream: 3.3.4
|
||||
|
||||
psl@1.15.0:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
|
||||
pstree.remy@1.1.8: {}
|
||||
|
||||
pug-attrs@3.0.0:
|
||||
|
|
Loading…
Reference in a new issue