From 9954c054a7e9fa8148345c7ff3d0bfaaee05fcb1 Mon Sep 17 00:00:00 2001 From: Johann150 Date: Sat, 4 Jun 2022 04:29:20 +0200 Subject: [PATCH] fix: ensure resolver does not fetch local resources via HTTP(S) (#8733) * refactor: parseUri types and checks The type has been refined to better represent what it actually is. Uses of parseUri are now also checking the parsed object type before resolving. * cannot resolve URLs with fragments * also take remaining part of URL into account Needed for parsing the follows URIs. * Resolver uses DbResolver for local * remove unnecessary use of DbResolver Using DbResolver would mean that the URL is parsed and handled again. This duplicated processing can be avoided by querying the database directly. * fix missing property name --- .../src/remote/activitypub/db-resolver.ts | 101 +++++++++--------- .../src/remote/activitypub/resolver.ts | 64 ++++++++++- 2 files changed, 115 insertions(+), 50 deletions(-) diff --git a/packages/backend/src/remote/activitypub/db-resolver.ts b/packages/backend/src/remote/activitypub/db-resolver.ts index ef07966e4..a9ed1a4a8 100644 --- a/packages/backend/src/remote/activitypub/db-resolver.ts +++ b/packages/backend/src/remote/activitypub/db-resolver.ts @@ -13,6 +13,44 @@ import { uriPersonCache, userByIdCache } from '@/services/user-cache.js'; const publicKeyCache = new Cache(Infinity); const publicKeyByUserIdCache = new Cache(Infinity); +export type UriParseResult = { + /** wether the URI was generated by us */ + local: true; + /** id in DB */ + id: string; + /** hint of type, e.g. "notes", "users" */ + type: string; + /** any remaining text after type and id, not including the slash after id. undefined if empty */ + rest?: string; +} | { + /** wether the URI was generated by us */ + local: false; + /** uri in DB */ + uri: string; +}; + +export function parseUri(url: string) : UriParseResult { + const uri = getApId(value); + + // the host part of a URL is case insensitive, so use the 'i' flag. + const localRegex = new RegExp('^' + escapeRegexp(config.url) + '/(\\w+)/(\\w+)(?:\/(.+))?', 'i'); + const matchLocal = uri.match(localRegex); + + if (matchLocal) { + return { + local: true, + type: matchLocal[1], + id: matchLocal[2], + rest: matchLocal[3], + }; + } else { + return { + local: false, + uri, + }; + } +} + export default class DbResolver { constructor() { } @@ -21,60 +59,54 @@ export default class DbResolver { * AP Note => Misskey Note in DB */ public async getNoteFromApId(value: string | IObject): Promise { - const parsed = this.parseUri(value); + const parsed = parseUri(value); + + if (parsed.local) { + if (parsed.type !== 'notes') return null; - if (parsed.id) { return await Notes.findOneBy({ id: parsed.id, }); - } - - if (parsed.uri) { + } else { return await Notes.findOneBy({ uri: parsed.uri, }); } - - return null; } public async getMessageFromApId(value: string | IObject): Promise { - const parsed = this.parseUri(value); + const parsed = parseUri(value); + + if (parsed.local) { + if (parsed.type !== 'notes') return null; - if (parsed.id) { return await MessagingMessages.findOneBy({ id: parsed.id, }); - } - - if (parsed.uri) { + } else { return await MessagingMessages.findOneBy({ uri: parsed.uri, }); } - - return null; } /** * AP Person => Misskey User in DB */ public async getUserFromApId(value: string | IObject): Promise { - const parsed = this.parseUri(value); + const parsed = parseUri(value); + + if (parsed.local) { + if (parsed.type !== 'users') return null; - if (parsed.id) { return await userByIdCache.fetchMaybe(parsed.id, () => Users.findOneBy({ id: parsed.id, }).then(x => x ?? undefined)) ?? null; - } - - if (parsed.uri) { + } else { return await uriPersonCache.fetch(parsed.uri, () => Users.findOneBy({ uri: parsed.uri, })); } - - return null; } /** @@ -120,31 +152,4 @@ export default class DbResolver { key, }; } - - public parseUri(value: string | IObject): UriParseResult { - const uri = getApId(value); - - const localRegex = new RegExp('^' + escapeRegexp(config.url) + '/' + '(\\w+)' + '/' + '(\\w+)'); - const matchLocal = uri.match(localRegex); - - if (matchLocal) { - return { - type: matchLocal[1], - id: matchLocal[2], - }; - } else { - return { - uri, - }; - } - } } - -type UriParseResult = { - /** id in DB (local object only) */ - id?: string; - /** uri in DB (remote object only) */ - uri?: string; - /** hint of type (local object only, ex: notes, users) */ - type?: string -}; diff --git a/packages/backend/src/remote/activitypub/resolver.ts b/packages/backend/src/remote/activitypub/resolver.ts index 334eae984..2f9af43c0 100644 --- a/packages/backend/src/remote/activitypub/resolver.ts +++ b/packages/backend/src/remote/activitypub/resolver.ts @@ -3,9 +3,18 @@ import { getJson } from '@/misc/fetch.js'; import { ILocalUser } from '@/models/entities/user.js'; import { getInstanceActor } from '@/services/instance-actor.js'; import { fetchMeta } from '@/misc/fetch-meta.js'; -import { extractDbHost } from '@/misc/convert-host.js'; +import { extractDbHost, isSelfHost } from '@/misc/convert-host.js'; import { signedGet } from './request.js'; import { IObject, isCollectionOrOrderedCollection, ICollection, IOrderedCollection } from './type.js'; +import { FollowRequests, Notes, NoteReactions, Polls, Users } from '@/models/index.js'; +import { parseUri } from './db-resolver.js'; +import renderNote from '@/remote/activitypub/renderer/note.js'; +import { renderLike } from '@/remote/activitypub/renderer/like.js'; +import { renderPerson } from '@/remote/activitypub/renderer/person.js'; +import renderQuestion from '@/remote/activitypub/renderer/question.js'; +import renderCreate from '@/remote/activitypub/renderer/create.js'; +import { renderActivity } from '@/remote/activitypub/renderer/index.js'; +import renderFollow from '@/remote/activitypub/renderer/follow.js'; export default class Resolver { private history: Set; @@ -40,14 +49,25 @@ export default class Resolver { return value; } + if (value.includes('#')) { + // URLs with fragment parts cannot be resolved correctly because + // the fragment part does not get transmitted over HTTP(S). + // Avoid strange behaviour by not trying to resolve these at all. + throw new Error(`cannot resolve URL with fragment: ${value}`); + } + if (this.history.has(value)) { throw new Error('cannot resolve already resolved one'); } this.history.add(value); - const meta = await fetchMeta(); const host = extractDbHost(value); + if (isSelfHost(host)) { + return await this.resolveLocal(value); + } + + const meta = await fetchMeta(); if (meta.blockedHosts.includes(host)) { throw new Error('Instance is blocked'); } @@ -70,4 +90,44 @@ export default class Resolver { return object; } + + private resolveLocal(url: string): Promise { + const parsed = parseUri(url); + if (!parsed.local) throw new Error('resolveLocal: not local'); + + switch (parsed.type) { + case 'notes': + return Notes.findOneByOrFail({ id: parsed.id }) + .then(note => { + if (parsed.rest === 'activity') { + // this refers to the create activity and not the note itself + return renderActivity(renderCreate(renderNote(note))); + } else { + return renderNote(note); + } + }); + case 'users': + return Users.findOneByOrFail({ id: parsed.id }) + .then(user => renderPerson(user as ILocalUser)); + case 'questions': + // Polls are indexed by the note they are attached to. + return Promise.all([ + Notes.findOneByOrFail({ id: parsed.id }), + Polls.findOneByOrFail({ noteId: parsed.id }), + ]) + .then(([note, poll]) => renderQuestion({ id: note.userId }, note, poll)); + case 'likes': + return NoteReactions.findOneByOrFail({ id: parsed.id }).then(reaction => renderActivity(renderLike(reaction, { uri: null }))); + case 'follows': + // rest should be + if (parsed.rest == null || !/^\w+$/.test(parsed.rest)) throw new Error('resolveLocal: invalid follow URI'); + + return Promise.all( + [parsed.id, parsed.rest].map(id => Users.findOneByOrFail({ id })) + ) + .then(([follower, followee]) => renderActivity(renderFollow(follower, followee, url))); + default: + throw new Error(`resolveLocal: type ${type} unhandled`); + } + } }