diff --git a/.config/example.yml b/.config/example.yml index 4b0d07ae85..28fe5b359d 100644 --- a/.config/example.yml +++ b/.config/example.yml @@ -212,6 +212,8 @@ proxyRemoteFiles: true # Sign to ActivityPub GET request (default: true) signToActivityPubGet: true +# check that inbound ActivityPub GET requests are signed ("authorized fetch") +checkActivityPubGetSignature: false # For security reasons, uploading attachments from the intranet is prohibited, # but exceptions can be made from the following settings. Default value is "undefined". diff --git a/chart/files/default.yml b/chart/files/default.yml index 4cc291e80a..9c81964736 100644 --- a/chart/files/default.yml +++ b/chart/files/default.yml @@ -194,6 +194,8 @@ id: "aidx" # Sign to ActivityPub GET request (default: true) signToActivityPubGet: true +# check that inbound ActivityPub GET requests are signed ("authorized fetch") +checkActivityPubGetSignature: false #allowedPrivateNetworks: [ # '127.0.0.1/32' diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index dceeac4691..4a4d7e8311 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -88,6 +88,7 @@ type Source = { customMOTD?: string[]; signToActivityPubGet?: boolean; + checkActivityPubGetSignature?: boolean; perChannelMaxNoteCacheCount?: number; perUserNotificationsMaxCount?: number; @@ -146,6 +147,7 @@ export type Config = { proxyRemoteFiles: boolean | undefined; customMOTD: string[] | undefined; signToActivityPubGet: boolean | undefined; + checkActivityPubGetSignature: boolean | undefined; version: string; host: string; @@ -253,6 +255,7 @@ export function loadConfig(): Config { proxyRemoteFiles: config.proxyRemoteFiles, customMOTD: config.customMOTD, signToActivityPubGet: config.signToActivityPubGet, + checkActivityPubGetSignature: config.checkActivityPubGetSignature, mediaProxy: externalMediaProxy ?? internalMediaProxy, externalMediaProxyEnabled: externalMediaProxy !== null && externalMediaProxy !== internalMediaProxy, videoThumbnailGenerator: config.videoThumbnailGenerator ? diff --git a/packages/backend/src/server/ActivityPubServerService.ts b/packages/backend/src/server/ActivityPubServerService.ts index 68e426b5bc..68de738238 100644 --- a/packages/backend/src/server/ActivityPubServerService.ts +++ b/packages/backend/src/server/ActivityPubServerService.ts @@ -5,6 +5,7 @@ import * as crypto from 'node:crypto'; import { IncomingMessage } from 'node:http'; +import { format as formatURL } from 'node:url'; import { Inject, Injectable } from '@nestjs/common'; import fastifyAccepts from '@fastify/accepts'; import httpSignature from '@peertube/http-signature'; @@ -17,9 +18,13 @@ import type { FollowingsRepository, NotesRepository, EmojisRepository, NoteReact import * as url from '@/misc/prelude/url.js'; import type { Config } from '@/config.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; +import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js'; import { QueueService } from '@/core/QueueService.js'; import type { MiLocalUser, MiRemoteUser, MiUser } from '@/models/User.js'; +import { MetaService } from '@/core/MetaService.js'; import { UserKeypairService } from '@/core/UserKeypairService.js'; +import { InstanceActorService } from '@/core/InstanceActorService.js'; +import type { MiUserPublickey } from '@/models/UserPublickey.js'; import type { MiFollowing } from '@/models/Following.js'; import { countIf } from '@/misc/prelude/array.js'; import type { MiNote } from '@/models/Note.js'; @@ -31,12 +36,17 @@ import { IActivity } from '@/core/activitypub/type.js'; import { isPureRenote } from '@/misc/is-pure-renote.js'; import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions, FastifyBodyParser } from 'fastify'; import type { FindOptionsWhere } from 'typeorm'; +import type Logger from '@/logger.js'; +import { LoggerService } from '@/core/LoggerService.js'; const ACTIVITY_JSON = 'application/activity+json; charset=utf-8'; const LD_JSON = 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"; charset=utf-8'; @Injectable() export class ActivityPubServerService { + private logger: Logger; + private authlogger: Logger; + constructor( @Inject(DI.config) private config: Config, @@ -65,14 +75,20 @@ export class ActivityPubServerService { @Inject(DI.followRequestsRepository) private followRequestsRepository: FollowRequestsRepository, + private metaService: MetaService, private utilityService: UtilityService, private userEntityService: UserEntityService, + private instanceActorService: InstanceActorService, private apRendererService: ApRendererService, + private apDbResolverService: ApDbResolverService, private queueService: QueueService, private userKeypairService: UserKeypairService, private queryService: QueryService, + private loggerService: LoggerService, ) { //this.createServer = this.createServer.bind(this); + this.logger = this.loggerService.getLogger('apserv', 'pink'); + this.authlogger = this.logger.createSubLogger('sigcheck'); } @bindThis @@ -99,6 +115,113 @@ export class ActivityPubServerService { return this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, false), note); } + @bindThis + private async shouldRefuseGetRequest(request: FastifyRequest, reply: FastifyReply, userId: string | undefined = undefined): Promise { + if (!this.config.checkActivityPubGetSignature) return false; + + /* this code is inspired from the `inbox` function below, and + `queue/processors/InboxProcessorService` + + those pieces of code also check `digest`, and various bits from the + request body, but that only makes sense for requests with a body: + here we're validating GET requests + + this is also inspired by FireFish's `checkFetch` + */ + + /* we always allow requests about our instance actor, because when + a remote instance needs to check our signature on a request we + sent, it will need to fetch information about the user that + signed it (which is our instance actor), and if we try to check + their signature on *that* request, we'll fetch *their* instance + actor... leading to an infinite recursion */ + if (userId) { + const instanceActor = await this.instanceActorService.getInstanceActor(); + + if (userId === instanceActor.id || userId === instanceActor.username) { + this.authlogger.debug(`${request.id} ${request.url} request to instance.actor, letting through`); + return false; + } + } + + let signature; + + try { + signature = httpSignature.parseRequest(request.raw, { 'headers': [] }); + } catch (e) { + // not signed, or malformed signature: refuse + this.authlogger.warn(`${request.id} ${request.url} not signed, or malformed signature: refuse`); + reply.code(401); + return true; + } + + if (signature.params.headers.indexOf('host') === -1 + || request.headers.host !== this.config.host) { + // no destination host, or not us: refuse + this.authlogger.warn(`${request.id} ${request.url} no destination host, or not us: refuse`); + reply.code(401); + return true; + } + + const keyId = new URL(signature.keyId); + const keyHost = this.utilityService.toPuny(keyId.hostname); + + const meta = await this.metaService.fetch(); + if (this.utilityService.isBlockedHost(meta.blockedHosts, keyHost)) { + /* blocked instance: refuse (we don't care if the signature is + good, if they even pretend to be from a blocked instance, + they're out) */ + this.authlogger.warn(`${request.id} ${request.url} instance ${keyHost} is blocked: refuse`); + reply.code(401); + return true; + } + + // do we know the signer already? + let authUser: { + user: MiRemoteUser; + key: MiUserPublickey | null; + } | null = await this.apDbResolverService.getAuthUserFromKeyId(signature.keyId); + + if (authUser == null) { + /* keyId is often in the shape `${user.uri}#${keyname}`, try + fetching information about the remote user */ + const candidate = formatURL(keyId, { fragment: false }); + this.authlogger.info(`${request.id} ${request.url} we don't know the user for keyId ${keyId}, trying to fetch via ${candidate}`); + authUser = await this.apDbResolverService.getAuthUserFromApId(candidate); + } + + if (authUser?.key == null) { + // we can't figure out who the signer is, or we can't get their key: refuse + this.authlogger.warn(`${request.id} ${request.url} we can't figure out who the signer is, or we can't get their key: refuse`); + reply.code(401); + return true; + } + + let httpSignatureValidated = httpSignature.verifySignature(signature, authUser.key.keyPem); + + if (!httpSignatureValidated) { + this.authlogger.info(`${request.id} ${request.url} failed to validate signature, re-fetching the key for ${authUser.user.uri}`); + // maybe they changed their key? refetch it + authUser.key = await this.apDbResolverService.refetchPublicKeyForApId(authUser.user); + + if (authUser.key != null) { + httpSignatureValidated = httpSignature.verifySignature(signature, authUser.key.keyPem); + } else { + this.authlogger.warn(`${request.id} ${request.url} failed to re-fetch key for ${authUser.user}`); + } + } + + if (!httpSignatureValidated) { + // bad signature: refuse + this.authlogger.info(`${request.id} ${request.url} failed to validate signature: refuse`); + reply.code(401); + return true; + } + + // all good, don't refuse + return false; + } + @bindThis private inbox(request: FastifyRequest, reply: FastifyReply) { let signature; @@ -172,6 +295,8 @@ export class ActivityPubServerService { request: FastifyRequest<{ Params: { user: string; }; Querystring: { cursor?: string; page?: string; }; }>, reply: FastifyReply, ) { + if (await this.shouldRefuseGetRequest(request, reply, request.params.user)) return; + const userId = request.params.user; const cursor = request.query.cursor; @@ -264,6 +389,8 @@ export class ActivityPubServerService { request: FastifyRequest<{ Params: { user: string; }; Querystring: { cursor?: string; page?: string; }; }>, reply: FastifyReply, ) { + if (await this.shouldRefuseGetRequest(request, reply, request.params.user)) return; + const userId = request.params.user; const cursor = request.query.cursor; @@ -353,6 +480,8 @@ export class ActivityPubServerService { @bindThis private async featured(request: FastifyRequest<{ Params: { user: string; }; }>, reply: FastifyReply) { + if (await this.shouldRefuseGetRequest(request, reply, request.params.user)) return; + const userId = request.params.user; const user = await this.usersRepository.findOneBy({ @@ -397,6 +526,8 @@ export class ActivityPubServerService { }>, reply: FastifyReply, ) { + if (await this.shouldRefuseGetRequest(request, reply, request.params.user)) return; + const userId = request.params.user; const sinceId = request.query.since_id; @@ -551,6 +682,8 @@ export class ActivityPubServerService { // note fastify.get<{ Params: { note: string; } }>('/notes/:note', { constraints: { apOrHtml: 'ap' } }, async (request, reply) => { + if (await this.shouldRefuseGetRequest(request, reply)) return; + vary(reply.raw, 'Accept'); const note = await this.notesRepository.findOneBy({ @@ -581,6 +714,8 @@ export class ActivityPubServerService { // note activity fastify.get<{ Params: { note: string; } }>('/notes/:note/activity', async (request, reply) => { + if (await this.shouldRefuseGetRequest(request, reply)) return; + vary(reply.raw, 'Accept'); const note = await this.notesRepository.findOneBy({ @@ -623,6 +758,8 @@ export class ActivityPubServerService { // publickey fastify.get<{ Params: { user: string; } }>('/users/:user/publickey', async (request, reply) => { + if (await this.shouldRefuseGetRequest(request, reply, request.params.user)) return; + const userId = request.params.user; const user = await this.usersRepository.findOneBy({ @@ -648,6 +785,8 @@ export class ActivityPubServerService { }); fastify.get<{ Params: { user: string; } }>('/users/:user', { constraints: { apOrHtml: 'ap' } }, async (request, reply) => { + if (await this.shouldRefuseGetRequest(request, reply, request.params.user)) return; + const userId = request.params.user; const user = await this.usersRepository.findOneBy({ @@ -660,6 +799,8 @@ export class ActivityPubServerService { }); fastify.get<{ Params: { user: string; } }>('/@:user', { constraints: { apOrHtml: 'ap' } }, async (request, reply) => { + if (await this.shouldRefuseGetRequest(request, reply, request.params.user)) return; + const user = await this.usersRepository.findOneBy({ usernameLower: request.params.user.toLowerCase(), host: IsNull(), @@ -672,6 +813,8 @@ export class ActivityPubServerService { // emoji fastify.get<{ Params: { emoji: string; } }>('/emojis/:emoji', async (request, reply) => { + if (await this.shouldRefuseGetRequest(request, reply)) return; + const emoji = await this.emojisRepository.findOneBy({ host: IsNull(), name: request.params.emoji, @@ -689,6 +832,8 @@ export class ActivityPubServerService { // like fastify.get<{ Params: { like: string; } }>('/likes/:like', async (request, reply) => { + if (await this.shouldRefuseGetRequest(request, reply)) return; + const reaction = await this.noteReactionsRepository.findOneBy({ id: request.params.like }); if (reaction == null) { @@ -710,6 +855,8 @@ export class ActivityPubServerService { // follow fastify.get<{ Params: { follower: string; followee: string; } }>('/follows/:follower/:followee', async (request, reply) => { + if (await this.shouldRefuseGetRequest(request, reply)) return; + // This may be used before the follow is completed, so we do not // check if the following exists. @@ -736,6 +883,8 @@ export class ActivityPubServerService { // follow fastify.get<{ Params: { followRequestId: string ; } }>('/follows/:followRequestId', async (request, reply) => { + if (await this.shouldRefuseGetRequest(request, reply)) return; + // This may be used before the follow is completed, so we do not // check if the following exists and only check if the follow request exists.