From a35c2f214b1b1054229f31569f6df4090a7375a5 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Fri, 21 Feb 2025 22:04:36 -0500 Subject: [PATCH] convert Authorized Fetch to a setting and add support for hybrid mode (essential metadata only) --- .config/ci.yml | 2 - .config/docker_example.yml | 2 - .config/example.yml | 2 - UPGRADE_NOTES.md | 9 + locales/index.d.ts | 52 +++++ .../1740162088574-add_unsignedFetch.js | 35 +++ packages/backend/src/config.ts | 1 + packages/backend/src/const.ts | 6 + packages/backend/src/core/CacheService.ts | 8 + .../src/core/CreateSystemUserService.ts | 3 +- .../backend/src/core/InstanceActorService.ts | 10 +- .../src/core/activitypub/ApRendererService.ts | 32 +++ .../src/core/entities/MetaEntityService.ts | 1 + .../src/core/entities/UserEntityService.ts | 1 + packages/backend/src/models/Meta.ts | 11 + packages/backend/src/models/User.ts | 14 +- .../backend/src/models/json-schema/meta.ts | 7 + .../backend/src/models/json-schema/user.ts | 7 + .../src/server/ActivityPubServerService.ts | 220 +++++++++++------- .../src/server/api/endpoints/admin/meta.ts | 12 + .../server/api/endpoints/admin/update-meta.ts | 10 + .../src/server/api/endpoints/i/update.ts | 10 + packages/backend/test/unit/activitypub.ts | 84 ++++++- packages/frontend/src/pages/admin/index.vue | 7 + .../frontend/src/pages/admin/security.vue | 25 ++ .../frontend/src/pages/settings/privacy.vue | 23 ++ packages/misskey-js/src/autogen/types.ts | 11 + sharkey-locales/en-US.yml | 15 ++ 28 files changed, 517 insertions(+), 103 deletions(-) create mode 100644 packages/backend/migration/1740162088574-add_unsignedFetch.js diff --git a/.config/ci.yml b/.config/ci.yml index def276ca58..2126f76337 100644 --- a/.config/ci.yml +++ b/.config/ci.yml @@ -243,8 +243,6 @@ signToActivityPubGet: true # When using authorized fetch, this is often undesired as any signed activity can be forwarded to a blocked instance by relays and other instances. # This setting allows admins to disable LD signatures for increased privacy, at the expense of fewer relayed activities and additional inbound fetch (GET) requests. attachLdSignatureForRelays: 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/.config/docker_example.yml b/.config/docker_example.yml index f798fd8246..acbaec8023 100644 --- a/.config/docker_example.yml +++ b/.config/docker_example.yml @@ -326,8 +326,6 @@ signToActivityPubGet: true # When using authorized fetch, this is often undesired as any signed activity can be forwarded to a blocked instance by relays and other instances. # This setting allows admins to disable LD signatures for increased privacy, at the expense of fewer relayed activities and additional inbound fetch (GET) requests. attachLdSignatureForRelays: 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/.config/example.yml b/.config/example.yml index d199544589..e18afd615b 100644 --- a/.config/example.yml +++ b/.config/example.yml @@ -369,8 +369,6 @@ signToActivityPubGet: true # When using authorized fetch, this is often undesired as any signed activity can be forwarded to a blocked instance by relays and other instances. # This setting allows admins to disable LD signatures for increased privacy, at the expense of fewer relayed activities and additional inbound fetch (GET) requests. attachLdSignatureForRelays: 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/UPGRADE_NOTES.md b/UPGRADE_NOTES.md index c941de6643..47ac649c31 100644 --- a/UPGRADE_NOTES.md +++ b/UPGRADE_NOTES.md @@ -1,5 +1,14 @@ # Upgrade Notes +## 2025.X.X + +### Authorized Fetch + +This version retires the configuration entry `checkActivityPubGetSignature`, which is now replaced with the new "Authorized Fetch" settings under Control Panel/Security. +The database migrations will automatically import the value of this configuration file, but it will never be read again after upgrading. +To avoid confusion and possible mis-configuration, please remove the entry **after** completing the upgrade. +Do not remove it before migration, or else the setting will reset to default (disabled)! + ## 2024.10.0 ### Hellspawns diff --git a/locales/index.d.ts b/locales/index.d.ts index 998d5da5d0..2d51b994a6 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -12231,6 +12231,58 @@ export interface Locale extends ILocale { */ "quoteUnavailable": string; }; + /** + * Authorized Fetch + */ + "authorizedFetchSection": string; + /** + * Allow unsigned ActivityPub requests: + */ + "authorizedFetchLabel": string; + /** + * This setting controls the behavior when a remote instance or user attempts to access your content without verifying their identity. If disabled, any remote user can access your profile and posts - even one who has been blocked or defederated. + */ + "authorizedFetchDescription": string; + "_authorizedFetchValue": { + /** + * Never + */ + "never": string; + /** + * Always + */ + "always": string; + /** + * Only for essential metadata + */ + "essential": string; + /** + * Use staff recommendation + */ + "staff": string; + }; + "_authorizedFetchValueDescription": { + /** + * Block all unsigned requests. Improves privacy and makes blocks more effective, but is not compatible with some very old or uncommon instance software. + */ + "never": string; + /** + * Allow all unsigned requests. Provides the greatest compatibility with other instances, but reduces privacy and weakens blocks. + */ + "always": string; + /** + * Allow some limited unsigned requests. Provides a hybrid between "Never" and "Always" by exposing only the minimum profile metadata that is required for federation with older software. + */ + "essential": string; + /** + * Use the default value of "{value}" recommended by the instance staff. + */ + "staff": ParameterizedString<"value">; + }; + /** + * The configuration property 'checkActivityPubGetSignature' has been deprecated and replaced with the new Authorized Fetch setting. Please remove it from your configuration file. + */ + "authorizedFetchLegacyWarning": string; } declare const locales: { [lang: string]: Locale; diff --git a/packages/backend/migration/1740162088574-add_unsignedFetch.js b/packages/backend/migration/1740162088574-add_unsignedFetch.js new file mode 100644 index 0000000000..855a3796aa --- /dev/null +++ b/packages/backend/migration/1740162088574-add_unsignedFetch.js @@ -0,0 +1,35 @@ +import { loadConfig } from '../built/config.js'; + +export class AddUnsignedFetch1740162088574 { + name = 'AddUnsignedFetch1740162088574' + + async up(queryRunner) { + // meta.allowUnsignedFetch + await queryRunner.query(`CREATE TYPE "public"."meta_allowunsignedfetch_enum" AS ENUM('never', 'always', 'essential')`); + await queryRunner.query(`ALTER TABLE "meta" ADD "allowUnsignedFetch" "public"."meta_allowunsignedfetch_enum" NOT NULL DEFAULT 'always'`); + + // user.allowUnsignedFetch + await queryRunner.query(`CREATE TYPE "public"."user_allowunsignedfetch_enum" AS ENUM('never', 'always', 'essential', 'staff')`); + await queryRunner.query(`ALTER TABLE "user" ADD "allowUnsignedFetch" "public"."user_allowunsignedfetch_enum" NOT NULL DEFAULT 'staff'`); + + // Special one-time migration: allow unauthorized fetch for instance actor + await queryRunner.query(`UPDATE "user" SET "allowUnsignedFetch" = 'always' WHERE "username" = 'instance.actor' AND "host" IS null`); + + // Special one-time migration: convert legacy config "" to meta setting "" + const config = await loadConfig(); + if (config.checkActivityPubGetSignature) { + // noinspection SqlWithoutWhere + await queryRunner.query(`UPDATE "meta" SET "allowUnsignedFetch" = 'never'`); + } + } + + async down(queryRunner) { + // user.allowUnsignedFetch + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "allowUnsignedFetch"`); + await queryRunner.query(`DROP TYPE "public"."user_allowunsignedfetch_enum"`); + + // meta.allowUnsignedFetch + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "allowUnsignedFetch"`); + await queryRunner.query(`DROP TYPE "public"."meta_allowunsignedfetch_enum"`); + } +} diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index c571c227a1..61c7fcb6c7 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -200,6 +200,7 @@ export type Config = { customMOTD: string[] | undefined; signToActivityPubGet: boolean; attachLdSignatureForRelays: boolean; + /** @deprecated Use MiMeta.allowUnsignedFetch instead */ checkActivityPubGetSignature: boolean | undefined; logging?: { sql?: { diff --git a/packages/backend/src/const.ts b/packages/backend/src/const.ts index e2c492ff80..50ccecd571 100644 --- a/packages/backend/src/const.ts +++ b/packages/backend/src/const.ts @@ -70,3 +70,9 @@ https://github.com/sindresorhus/file-type/blob/main/supported.js https://github.com/sindresorhus/file-type/blob/main/core.js https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Containers */ + +export const instanceUnsignedFetchOptions = ['never', 'always', 'essential'] as const; +export type InstanceUnsignedFetchOption = (typeof instanceUnsignedFetchOptions)[number]; + +export const userUnsignedFetchOptions = ['never', 'always', 'essential', 'staff'] as const; +export type UserUnsignedFetchOption = (typeof userUnsignedFetchOptions)[number]; diff --git a/packages/backend/src/core/CacheService.ts b/packages/backend/src/core/CacheService.ts index 6725ebe75b..e9900373b4 100644 --- a/packages/backend/src/core/CacheService.ts +++ b/packages/backend/src/core/CacheService.ts @@ -5,6 +5,7 @@ import { Inject, Injectable } from '@nestjs/common'; import * as Redis from 'ioredis'; +import { IsNull } from 'typeorm'; import type { BlockingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository, MiFollowing } from '@/models/_.js'; import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js'; import type { MiLocalUser, MiUser } from '@/models/User.js'; @@ -179,6 +180,13 @@ export class CacheService implements OnApplicationShutdown { return this.userByIdCache.fetch(userId, () => this.usersRepository.findOneByOrFail({ id: userId })); } + @bindThis + public async findLocalUserById(userId: MiUser['id']): Promise { + return await this.localUserByIdCache.fetchMaybe(userId, async () => { + return await this.usersRepository.findOneBy({ id: userId, host: IsNull() }) as MiLocalUser | null ?? undefined; + }) ?? null; + } + @bindThis public dispose(): void { this.redisForSub.off('message', this.onMessage); diff --git a/packages/backend/src/core/CreateSystemUserService.ts b/packages/backend/src/core/CreateSystemUserService.ts index 14d814b0e6..d198707a42 100644 --- a/packages/backend/src/core/CreateSystemUserService.ts +++ b/packages/backend/src/core/CreateSystemUserService.ts @@ -29,7 +29,7 @@ export class CreateSystemUserService { } @bindThis - public async createSystemUser(username: string): Promise { + public async createSystemUser(username: string, data?: Partial): Promise { const password = randomUUID(); // Generate hash of password @@ -63,6 +63,7 @@ export class CreateSystemUserService { isExplorable: false, approved: true, isBot: true, + ...(data ?? {}), }).then(x => transactionalEntityManager.findOneByOrFail(MiUser, x.identifiers[0])); await transactionalEntityManager.insert(MiUserKeypair, { diff --git a/packages/backend/src/core/InstanceActorService.ts b/packages/backend/src/core/InstanceActorService.ts index 22c47297a3..6c0e360588 100644 --- a/packages/backend/src/core/InstanceActorService.ts +++ b/packages/backend/src/core/InstanceActorService.ts @@ -49,7 +49,15 @@ export class InstanceActorService { this.cache.set(user); return user; } else { - const created = await this.createSystemUserService.createSystemUser(ACTOR_USERNAME) as MiLocalUser; + const created = await this.createSystemUserService.createSystemUser(ACTOR_USERNAME, { + /* 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 */ + allowUnsignedFetch: 'always', + }) as MiLocalUser; this.cache.set(created); return created; } diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts index cb9b74f6d7..c7f8b97a5a 100644 --- a/packages/backend/src/core/activitypub/ApRendererService.ts +++ b/packages/backend/src/core/activitypub/ApRendererService.ts @@ -571,6 +571,38 @@ export class ApRendererService { return person; } + @bindThis + public async renderPersonRedacted(user: MiLocalUser) { + const id = this.userEntityService.genLocalUserUri(user.id); + const isSystem = user.username.includes('.'); + + const keypair = await this.userKeypairService.getUserKeypair(user.id); + + return { + // Basic federation metadata + type: isSystem ? 'Application' : user.isBot ? 'Service' : 'Person', + id, + inbox: `${id}/inbox`, + outbox: `${id}/outbox`, + sharedInbox: `${this.config.url}/inbox`, + endpoints: { sharedInbox: `${this.config.url}/inbox` }, + url: `${this.config.url}/@${user.username}`, + preferredUsername: user.username, + publicKey: this.renderKey(user, keypair, '#main-key'), + + // Privacy settings + _misskey_requireSigninToViewContents: user.requireSigninToViewContents, + _misskey_makeNotesFollowersOnlyBefore: user.makeNotesFollowersOnlyBefore, + _misskey_makeNotesHiddenBefore: user.makeNotesHiddenBefore, + manuallyApprovesFollowers: user.isLocked, + discoverable: user.isExplorable, + hideOnlineStatus: user.hideOnlineStatus, + noindex: user.noindex, + indexable: !user.noindex, + enableRss: user.enableRss, + }; + } + @bindThis public renderQuestion(user: { id: MiUser['id'] }, note: MiNote, poll: MiPoll): IQuestion { return { diff --git a/packages/backend/src/core/entities/MetaEntityService.ts b/packages/backend/src/core/entities/MetaEntityService.ts index a7679d06aa..3f3a1bad33 100644 --- a/packages/backend/src/core/entities/MetaEntityService.ts +++ b/packages/backend/src/core/entities/MetaEntityService.ts @@ -181,6 +181,7 @@ export class MetaEntityService { serviceWorker: instance.enableServiceWorker, miauth: true, }, + allowUnsignedFetch: instance.allowUnsignedFetch, }; return packDetailed; diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index 96fef863a0..f5452baaef 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -725,6 +725,7 @@ export class UserEntityService implements OnModuleInit { policies: this.roleService.getUserPolicies(user.id), defaultCW: profile!.defaultCW, defaultCWPriority: profile!.defaultCWPriority, + allowUnsignedFetch: user.allowUnsignedFetch, } : {}), ...(opts.includeSecrets ? { diff --git a/packages/backend/src/models/Meta.ts b/packages/backend/src/models/Meta.ts index 0f1f4069ff..f4bc2a8db7 100644 --- a/packages/backend/src/models/Meta.ts +++ b/packages/backend/src/models/Meta.ts @@ -4,6 +4,7 @@ */ import { Entity, Column, PrimaryColumn, ManyToOne, JoinColumn } from 'typeorm'; +import { type InstanceUnsignedFetchOption, instanceUnsignedFetchOptions } from '@/const.js'; import { id } from './util/id.js'; import { MiUser } from './User.js'; @@ -749,4 +750,14 @@ export class MiMeta { default: '{}', }) public federationHosts: string[]; + + /** + * In combination with user.allowUnsignedFetch, controls enforcement of HTTP signatures for inbound ActivityPub fetches (GET requests). + * TODO warning if config value is present + */ + @Column('enum', { + enum: instanceUnsignedFetchOptions, + default: 'always', + }) + public allowUnsignedFetch: InstanceUnsignedFetchOption; } diff --git a/packages/backend/src/models/User.ts b/packages/backend/src/models/User.ts index 5d87c7fa12..3bc2494577 100644 --- a/packages/backend/src/models/User.ts +++ b/packages/backend/src/models/User.ts @@ -4,6 +4,7 @@ */ import { Entity, Column, Index, OneToOne, JoinColumn, PrimaryColumn } from 'typeorm'; +import { type UserUnsignedFetchOption, userUnsignedFetchOptions } from '@/const.js'; import { id } from './util/id.js'; import { MiDriveFile } from './DriveFile.js'; @@ -125,7 +126,7 @@ export class MiUser { }) public backgroundId: MiDriveFile['id'] | null; - @OneToOne(type => MiDriveFile, { + @OneToOne(() => MiDriveFile, { onDelete: 'SET NULL', }) @JoinColumn() @@ -357,6 +358,15 @@ export class MiUser { }) public rejectQuotes: boolean; + /** + * In combination with meta.allowUnsignedFetch, controls enforcement of HTTP signatures for inbound ActivityPub fetches (GET requests). + */ + @Column('enum', { + enum: userUnsignedFetchOptions, + default: 'staff', + }) + public allowUnsignedFetch: UserUnsignedFetchOption; + constructor(data: Partial) { if (data == null) return; @@ -394,5 +404,5 @@ export const nameSchema = { type: 'string', minLength: 1, maxLength: 50 } as con export const descriptionSchema = { type: 'string', minLength: 1, maxLength: 1500 } as const; export const followedMessageSchema = { type: 'string', minLength: 1, maxLength: 256 } as const; export const locationSchema = { type: 'string', minLength: 1, maxLength: 50 } as const; -export const listenbrainzSchema = { type: "string", minLength: 1, maxLength: 128 } as const; +export const listenbrainzSchema = { type: 'string', minLength: 1, maxLength: 128 } as const; export const birthdaySchema = { type: 'string', pattern: /^([0-9]{4})-([0-9]{2})-([0-9]{2})$/.toString().slice(1, -1) } as const; diff --git a/packages/backend/src/models/json-schema/meta.ts b/packages/backend/src/models/json-schema/meta.ts index bf68208c37..fd735c1edd 100644 --- a/packages/backend/src/models/json-schema/meta.ts +++ b/packages/backend/src/models/json-schema/meta.ts @@ -3,6 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { instanceUnsignedFetchOptions } from '@/const.js'; + export const packedMetaLiteSchema = { type: 'object', optional: false, nullable: false, @@ -397,6 +399,11 @@ export const packedMetaDetailedOnlySchema = { type: 'boolean', optional: false, nullable: false, }, + allowUnsignedFetch: { + type: 'string', + enum: instanceUnsignedFetchOptions, + optional: false, nullable: false, + }, }, } as const; diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index 0f1601f138..83a456fc57 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -3,6 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { userUnsignedFetchOptions } from '@/const.js'; + export const notificationRecieveConfig = { type: 'object', oneOf: [ @@ -769,6 +771,11 @@ export const packedMeDetailedOnlySchema = { enum: ['default', 'parent', 'defaultParent', 'parentDefault'], nullable: false, optional: false, }, + allowUnsignedFetch: { + type: 'string', + enum: userUnsignedFetchOptions, + nullable: false, optional: false, + }, }, } as const; diff --git a/packages/backend/src/server/ActivityPubServerService.ts b/packages/backend/src/server/ActivityPubServerService.ts index 765d54bc71..ba112ca59a 100644 --- a/packages/backend/src/server/ActivityPubServerService.ts +++ b/packages/backend/src/server/ActivityPubServerService.ts @@ -14,7 +14,7 @@ import accepts from 'accepts'; import vary from 'vary'; import secureJson from 'secure-json-parse'; import { DI } from '@/di-symbols.js'; -import type { FollowingsRepository, NotesRepository, EmojisRepository, NoteReactionsRepository, UserProfilesRepository, UserNotePiningsRepository, UsersRepository, FollowRequestsRepository } from '@/models/_.js'; +import type { FollowingsRepository, NotesRepository, EmojisRepository, NoteReactionsRepository, UserProfilesRepository, UserNotePiningsRepository, UsersRepository, FollowRequestsRepository, MiMeta } from '@/models/_.js'; import * as url from '@/misc/prelude/url.js'; import type { Config } from '@/config.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; @@ -22,7 +22,6 @@ import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js'; import { QueueService } from '@/core/QueueService.js'; import type { MiLocalUser, MiRemoteUser, MiUser } from '@/models/User.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'; @@ -33,9 +32,10 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js'; import type Logger from '@/logger.js'; import { LoggerService } from '@/core/LoggerService.js'; import { bindThis } from '@/decorators.js'; -import { IActivity } from '@/core/activitypub/type.js'; +import { IActivity, IAnnounce, ICreate } from '@/core/activitypub/type.js'; import { isQuote, isRenote } from '@/misc/is-renote.js'; import * as Acct from '@/misc/acct.js'; +import { CacheService } from '@/core/CacheService.js'; import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions, FastifyBodyParser } from 'fastify'; import type { FindOptionsWhere } from 'typeorm'; @@ -51,6 +51,9 @@ export class ActivityPubServerService { @Inject(DI.config) private config: Config, + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -77,13 +80,13 @@ export class ActivityPubServerService { 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, + private readonly cacheService: CacheService, ) { //this.createServer = this.createServer.bind(this); this.logger = this.loggerService.getLogger('apserv', 'pink'); @@ -106,7 +109,7 @@ export class ActivityPubServerService { * @param author Author of the note */ @bindThis - private async packActivity(note: MiNote, author: MiUser): Promise { + private async packActivity(note: MiNote, author: MiUser): Promise { if (isRenote(note) && !isQuote(note)) { const renote = await this.notesRepository.findOneByOrFail({ id: note.renoteId }); return this.apRendererService.renderAnnounce(renote.uri ? renote.uri : `${this.config.url}/notes/${renote.id}`, note); @@ -115,10 +118,55 @@ export class ActivityPubServerService { return this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, author, false), note); } - @bindThis - private async shouldRefuseGetRequest(request: FastifyRequest, reply: FastifyReply, userId: string | undefined = undefined): Promise { - if (!this.config.checkActivityPubGetSignature) return false; + /** + * Checks Authorized Fetch. + * Returns an object with two properties: + * * reject - true if the request should be ignored by the caller, false if it should be processed. + * * redact - true if the caller should redact response data, false if it should return full data. + * When "reject" is true, the HTTP status code will be automatically set to 401 unauthorized. + */ + private async checkAuthorizedFetch( + request: FastifyRequest, + reply: FastifyReply, + userId?: string, + essential?: boolean, + ): Promise<{ reject: boolean, redact: boolean }> { + // Federation disabled => reject + if (this.meta.federation === 'none') { + reply.code(401); + return { reject: true, redact: true }; + } + // Auth fetch disabled => accept + const allowUnsignedFetch = await this.getUnsignedFetchAllowance(userId); + if (allowUnsignedFetch === 'always') { + return { reject: false, redact: false }; + } + + // Valid signature => accept + const error = await this.checkSignature(request); + if (!error) { + return { reject: false, redact: false }; + } + + // Unsigned, but essential => accept redacted + if (allowUnsignedFetch === 'essential' && essential) { + return { reject: false, redact: true }; + } + + // Unsigned, not essential => reject + this.authlogger.warn(error); + reply.code(401); + return { reject: true, redact: true }; + } + + /** + * Verifies HTTP Signatures for a request. + * Returns null of success (valid signature). + * Returns a string error on validation failure. + */ + @bindThis + private async checkSignature(request: FastifyRequest): Promise { /* this code is inspired from the `inbox` function below, and `queue/processors/InboxProcessorService` @@ -129,59 +177,33 @@ export class ActivityPubServerService { this is also inspired by FireFish's `checkFetch` */ - /* tell any caching proxy that they should not cache these - responses: we wouldn't want the proxy to return a 403 to - someone presenting a valid signature, or return a cached - response body to someone we've blocked! - */ - reply.header('Cache-Control', 'private, max-age=0, must-revalidate'); - - /* 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': ['(request-target)', 'host', 'date'], authorizationHeaderName: 'signature' }); + signature = httpSignature.parseRequest(request.raw, { + headers: ['(request-target)', 'host', 'date'], + authorizationHeaderName: 'signature', + }); } 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; + return `${request.id} ${request.url} not signed, or malformed signature: refuse`; } const keyId = new URL(signature.keyId); const keyHost = this.utilityService.toPuny(keyId.hostname); - const logPrefix = `${request.id} ${request.url} (by ${request.headers['user-agent']}) apparently from ${keyHost}:`; + const logPrefix = `${request.id} ${request.url} (by ${request.headers['user-agent']}) claims to be from ${keyHost}:`; - if (signature.params.headers.indexOf('host') === -1 - || request.headers.host !== this.config.host) { + if (signature.params.headers.indexOf('host') === -1 || request.headers.host !== this.config.host) { // no destination host, or not us: refuse - this.authlogger.warn(`${logPrefix} no destination host, or not us: refuse`); - reply.code(401); - return true; + return `${logPrefix} no destination host, or not us: refuse`; } if (!this.utilityService.isFederationAllowedHost(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(`${logPrefix} instance is blocked: refuse`); - reply.code(401); - return true; + return `${logPrefix} instance is blocked: refuse`; } // do we know the signer already? @@ -200,14 +222,18 @@ export class ActivityPubServerService { if (authUser?.key == null) { // we can't figure out who the signer is, or we can't get their key: refuse - this.authlogger.warn(`${logPrefix} we can't figure out who the signer is, or we can't get their key: refuse`); - reply.code(401); - return true; + return `${logPrefix} we can't figure out who the signer is, or we can't get their key: refuse`; + } + + if (authUser.user.isSuspended) { + // Signer is suspended locally + return `${logPrefix} signer is suspended: refuse`; } let httpSignatureValidated = httpSignature.verifySignature(signature, authUser.key.keyPem); // maybe they changed their key? refetch it + // TODO rate-limit this using lastFetchedAt if (!httpSignatureValidated) { authUser.key = await this.apDbResolverService.refetchPublicKeyForApId(authUser.user); if (authUser.key != null) { @@ -217,13 +243,11 @@ export class ActivityPubServerService { if (!httpSignatureValidated) { // bad signature: refuse - this.authlogger.info(`${logPrefix} failed to validate signature: refuse`); - reply.code(401); - return true; + return `${logPrefix} failed to validate signature: refuse`; } // all good, don't refuse - return false; + return null; } @bindThis @@ -299,7 +323,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 { reject } = await this.checkAuthorizedFetch(request, reply, request.params.user); + if (reject) return; const userId = request.params.user; @@ -326,11 +351,9 @@ export class ActivityPubServerService { if (profile.followersVisibility === 'private') { reply.code(403); - if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=30'); return; } else if (profile.followersVisibility === 'followers') { reply.code(403); - if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=30'); return; } //#endregion @@ -382,7 +405,6 @@ export class ActivityPubServerService { user.followersCount, `${partOf}?page=true`, ); - if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180'); this.setResponseType(request, reply); return (this.apRendererService.addContext(rendered)); } @@ -393,7 +415,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 { reject } = await this.checkAuthorizedFetch(request, reply, request.params.user); + if (reject) return; const userId = request.params.user; @@ -420,11 +443,9 @@ export class ActivityPubServerService { if (profile.followingVisibility === 'private') { reply.code(403); - if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=30'); return; } else if (profile.followingVisibility === 'followers') { reply.code(403); - if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=30'); return; } //#endregion @@ -476,7 +497,6 @@ export class ActivityPubServerService { user.followingCount, `${partOf}?page=true`, ); - if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180'); this.setResponseType(request, reply); return (this.apRendererService.addContext(rendered)); } @@ -484,7 +504,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 { reject } = await this.checkAuthorizedFetch(request, reply, request.params.user); + if (reject) return; const userId = request.params.user; @@ -517,7 +538,6 @@ export class ActivityPubServerService { renderedNotes, ); - if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180'); this.setResponseType(request, reply); return (this.apRendererService.addContext(rendered)); } @@ -530,7 +550,8 @@ export class ActivityPubServerService { }>, reply: FastifyReply, ) { - if (await this.shouldRefuseGetRequest(request, reply, request.params.user)) return; + const { reject } = await this.checkAuthorizedFetch(request, reply, request.params.user); + if (reject) return; const userId = request.params.user; @@ -608,14 +629,13 @@ export class ActivityPubServerService { `${partOf}?page=true`, `${partOf}?page=true&since_id=000000000000000000000000`, ); - if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180'); this.setResponseType(request, reply); return (this.apRendererService.addContext(rendered)); } } @bindThis - private async userInfo(request: FastifyRequest, reply: FastifyReply, user: MiUser | null) { + private async userInfo(request: FastifyRequest, reply: FastifyReply, user: MiUser | null, redact = false) { if (user == null) { reply.code(404); return; @@ -631,10 +651,12 @@ export class ActivityPubServerService { return; } - if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180'); - this.setResponseType(request, reply); - return (this.apRendererService.addContext(await this.apRendererService.renderPerson(user as MiLocalUser))); + + const person = redact + ? await this.apRendererService.renderPersonRedacted(user as MiLocalUser) + : await this.apRendererService.renderPerson(user as MiLocalUser); + return this.apRendererService.addContext(person); } @bindThis @@ -687,6 +709,13 @@ export class ActivityPubServerService { reply.header('Access-Control-Allow-Methods', 'GET, OPTIONS'); reply.header('Access-Control-Allow-Origin', '*'); reply.header('Access-Control-Expose-Headers', 'Vary'); + + /* tell any caching proxy that they should not cache these + responses: we wouldn't want the proxy to return a 403 to + someone presenting a valid signature, or return a cached + response body to someone we've blocked! + */ + reply.header('Cache-Control', 'private, max-age=0, must-revalidate'); done(); }); @@ -697,8 +726,6 @@ 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({ @@ -707,6 +734,9 @@ export class ActivityPubServerService { localOnly: false, }); + const { reject } = await this.checkAuthorizedFetch(request, reply, note?.userId); + if (reject) return; + if (note == null) { reply.code(404); return; @@ -722,7 +752,6 @@ export class ActivityPubServerService { return; } - if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180'); this.setResponseType(request, reply); const author = await this.usersRepository.findOneByOrFail({ id: note.userId }); @@ -731,8 +760,6 @@ 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({ @@ -742,12 +769,14 @@ export class ActivityPubServerService { localOnly: false, }); + const { reject } = await this.checkAuthorizedFetch(request, reply, note?.userId); + if (reject) return; + if (note == null) { reply.code(404); return; } - if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180'); this.setResponseType(request, reply); const author = await this.usersRepository.findOneByOrFail({ id: note.userId }); @@ -777,7 +806,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 { reject } = await this.checkAuthorizedFetch(request, reply, request.params.user, true); + if (reject) return; const userId = request.params.user; @@ -794,7 +824,6 @@ export class ActivityPubServerService { const keypair = await this.userKeypairService.getUserKeypair(user.id); if (this.userEntityService.isLocalUser(user)) { - if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180'); this.setResponseType(request, reply); return (this.apRendererService.addContext(this.apRendererService.renderKey(user, keypair))); } else { @@ -804,7 +833,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 { reject, redact } = await this.checkAuthorizedFetch(request, reply, request.params.user, true); + if (reject) return; vary(reply.raw, 'Accept'); @@ -815,12 +845,10 @@ export class ActivityPubServerService { isSuspended: false, }); - return await this.userInfo(request, reply, user); + return await this.userInfo(request, reply, user, redact); }); fastify.get<{ Params: { acct: string; } }>('/@:acct', { constraints: { apOrHtml: 'ap' } }, async (request, reply) => { - if (await this.shouldRefuseGetRequest(request, reply, request.params.acct)) return; - vary(reply.raw, 'Accept'); const acct = Acct.parse(request.params.acct); @@ -831,13 +859,17 @@ export class ActivityPubServerService { isSuspended: false, }); - return await this.userInfo(request, reply, user); + const { reject, redact } = await this.checkAuthorizedFetch(request, reply, user?.id, true); + if (reject) return; + + return await this.userInfo(request, reply, user, redact); }); //#endregion // emoji fastify.get<{ Params: { emoji: string; } }>('/emojis/:emoji', async (request, reply) => { - if (await this.shouldRefuseGetRequest(request, reply)) return; + const { reject } = await this.checkAuthorizedFetch(request, reply); + if (reject) return; const emoji = await this.emojisRepository.findOneBy({ host: IsNull(), @@ -849,17 +881,17 @@ export class ActivityPubServerService { return; } - if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180'); this.setResponseType(request, reply); return (this.apRendererService.addContext(await this.apRendererService.renderEmoji(emoji))); }); // 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 }); + const { reject } = await this.checkAuthorizedFetch(request, reply, reaction?.userId); + if (reject) return; + if (reaction == null) { reply.code(404); return; @@ -872,14 +904,14 @@ export class ActivityPubServerService { return; } - if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180'); this.setResponseType(request, reply); return (this.apRendererService.addContext(await this.apRendererService.renderLike(reaction, note))); }); // follow fastify.get<{ Params: { follower: string; followee: string; } }>('/follows/:follower/:followee', async (request, reply) => { - if (await this.shouldRefuseGetRequest(request, reply)) return; + const { reject } = await this.checkAuthorizedFetch(request, reply, request.params.follower); + if (reject) return; // This may be used before the follow is completed, so we do not // check if the following exists. @@ -900,15 +932,12 @@ export class ActivityPubServerService { return; } - if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180'); this.setResponseType(request, reply); return (this.apRendererService.addContext(this.apRendererService.renderFollow(follower, followee))); }); // 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. @@ -916,6 +945,9 @@ export class ActivityPubServerService { id: request.params.followRequestId, }); + const { reject } = await this.checkAuthorizedFetch(request, reply, followRequest?.followerId); + if (reject) return; + if (followRequest == null) { reply.code(404); return; @@ -937,11 +969,21 @@ export class ActivityPubServerService { return; } - if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180'); this.setResponseType(request, reply); return (this.apRendererService.addContext(this.apRendererService.renderFollow(follower, followee))); }); done(); } + + private async getUnsignedFetchAllowance(userId: string | undefined) { + const user = userId ? await this.cacheService.findLocalUserById(userId) : null; + + // User system value if there is no user, or if user has deferred the choice. + if (!user?.allowUnsignedFetch || user.allowUnsignedFetch === 'staff') { + return this.meta.allowUnsignedFetch; + } + + return user.allowUnsignedFetch; + } } diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index d581c07e8c..d3f24e07bb 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -9,6 +9,7 @@ import { MetaService } from '@/core/MetaService.js'; import type { Config } from '@/config.js'; import { DI } from '@/di-symbols.js'; import { DEFAULT_POLICIES } from '@/core/RoleService.js'; +import { instanceUnsignedFetchOptions } from '@/const.js'; export const meta = { tags: ['meta'], @@ -589,6 +590,15 @@ export const meta = { optional: false, nullable: false, }, }, + hasLegacyAuthFetchSetting: { + type: 'boolean', + optional: false, nullable: false, + }, + allowUnsignedFetch: { + type: 'string', + enum: instanceUnsignedFetchOptions, + optional: false, nullable: false, + }, }, }, } as const; @@ -745,6 +755,8 @@ export default class extends Endpoint { // eslint- trustedLinkUrlPatterns: instance.trustedLinkUrlPatterns, federation: instance.federation, federationHosts: instance.federationHosts, + hasLegacyAuthFetchSetting: config.checkActivityPubGetSignature != null, + allowUnsignedFetch: instance.allowUnsignedFetch, }; }); } diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts index f6ce86790a..33d4bbd00f 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -8,6 +8,7 @@ import type { MiMeta } from '@/models/Meta.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { MetaService } from '@/core/MetaService.js'; +import { instanceUnsignedFetchOptions } from '@/const.js'; export const meta = { tags: ['admin'], @@ -205,6 +206,11 @@ export const paramDef = { type: 'string', }, }, + allowUnsignedFetch: { + type: 'string', + enum: instanceUnsignedFetchOptions, + nullable: false, + }, }, required: [], } as const; @@ -753,6 +759,10 @@ export default class extends Endpoint { // eslint- set.federationHosts = ps.federationHosts.filter(Boolean).map(x => x.toLowerCase()); } + if (ps.allowUnsignedFetch !== undefined) { + set.allowUnsignedFetch = ps.allowUnsignedFetch; + } + const before = await this.metaService.fetch(true); await this.metaService.update(set); diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index f74452e2af..f1d201d081 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -33,6 +33,7 @@ import type { Config } from '@/config.js'; import { safeForSql } from '@/misc/safe-for-sql.js'; import { AvatarDecorationService } from '@/core/AvatarDecorationService.js'; import { notificationRecieveConfig } from '@/models/json-schema/user.js'; +import { userUnsignedFetchOptions } from '@/const.js'; import { ApiLoggerService } from '../../ApiLoggerService.js'; import { ApiError } from '../../error.js'; @@ -255,6 +256,11 @@ export const paramDef = { enum: ['default', 'parent', 'defaultParent', 'parentDefault'], nullable: false, }, + allowUnsignedFetch: { + type: 'string', + enum: userUnsignedFetchOptions, + nullable: false, + }, }, } as const; @@ -519,6 +525,10 @@ export default class extends Endpoint { // eslint- profileUpdates.defaultCWPriority = ps.defaultCWPriority; } + if (ps.allowUnsignedFetch !== undefined) { + updates.allowUnsignedFetch = ps.allowUnsignedFetch; + } + //#region emojis/tags let emojis = [] as string[]; diff --git a/packages/backend/test/unit/activitypub.ts b/packages/backend/test/unit/activitypub.ts index 553467499b..90649bfd8b 100644 --- a/packages/backend/test/unit/activitypub.ts +++ b/packages/backend/test/unit/activitypub.ts @@ -22,13 +22,16 @@ import { CoreModule } from '@/core/CoreModule.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { LoggerService } from '@/core/LoggerService.js'; import type { IActor, IApDocument, ICollection, IObject, IPost } from '@/core/activitypub/type.js'; -import { MiMeta, MiNote, MiUser, UserProfilesRepository, UserPublickeysRepository } from '@/models/_.js'; +import { MiMeta, MiNote, MiUser, MiUserKeypair, UserProfilesRepository, UserPublickeysRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import { secureRndstr } from '@/misc/secure-rndstr.js'; import { DownloadService } from '@/core/DownloadService.js'; -import type { MiRemoteUser } from '@/models/User.js'; +import type { MiLocalUser, MiRemoteUser } from '@/models/User.js'; import { genAidx } from '@/misc/id/aidx.js'; import { MockResolver } from '../misc/mock-resolver.js'; +import { UserKeypairService } from '@/core/UserKeypairService.js'; +import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js'; +import { generateKeyPair } from 'crypto'; const host = 'https://host1.test'; @@ -97,6 +100,7 @@ describe('ActivityPub', () => { let resolver: MockResolver; let idService: IdService; let userPublickeysRepository: UserPublickeysRepository; + let userKeypairService: UserKeypairService; const metaInitial = { cacheRemoteFiles: true, @@ -146,6 +150,7 @@ describe('ActivityPub', () => { resolver = new MockResolver(await app.resolve(LoggerService)); idService = app.get(IdService); userPublickeysRepository = app.get(DI.userPublickeysRepository); + userKeypairService = app.get(UserKeypairService); // Prevent ApPersonService from fetching instance, as it causes Jest import-after-test error const federatedInstanceService = app.get(FederatedInstanceService); @@ -486,15 +491,57 @@ describe('ActivityPub', () => { describe(ApRendererService, () => { let note: MiNote; - let author: MiUser; + let author: MiLocalUser; + let keypair: MiUserKeypair; - beforeEach(() => { + beforeEach(async () => { author = new MiUser({ id: idService.gen(), + host: null, + uri: null, + username: 'testAuthor', + usernameLower: 'testauthor', + name: 'Test Author', + isCat: true, + requireSigninToViewContents: true, + makeNotesFollowersOnlyBefore: new Date(2025, 2, 20).valueOf(), + makeNotesHiddenBefore: new Date(2025, 2, 21).valueOf(), + isLocked: true, + isExplorable: true, + hideOnlineStatus: true, + noindex: true, + enableRss: true, + + }) as MiLocalUser; + + const [publicKey, privateKey] = await new Promise<[string, string]>((res, rej) => + generateKeyPair('rsa', { + modulusLength: 2048, + publicKeyEncoding: { + type: 'spki', + format: 'pem', + }, + privateKeyEncoding: { + type: 'pkcs8', + format: 'pem', + cipher: undefined, + passphrase: undefined, + }, + }, (err, publicKey, privateKey) => + err ? rej(err) : res([publicKey, privateKey]), + )); + keypair = new MiUserKeypair({ + userId: author.id, + user: author, + publicKey, + privateKey, }); + ((userKeypairService as unknown as { cache: RedisKVCache }).cache as unknown as { memoryCache: MemoryKVCache }).memoryCache.set(author.id, keypair); + note = new MiNote({ id: idService.gen(), userId: author.id, + user: author, visibility: 'public', localOnly: false, text: 'Note text', @@ -621,6 +668,35 @@ describe('ActivityPub', () => { }); }); }); + + describe('renderPersonRedacted', () => { + it('should include minimal properties', async () => { + const result = await rendererService.renderPersonRedacted(author); + + expect(result.type).toBe('Person'); + expect(result.id).toBeTruthy(); + expect(result.inbox).toBeTruthy(); + expect(result.sharedInbox).toBeTruthy(); + expect(result.endpoints.sharedInbox).toBeTruthy(); + expect(result.url).toBeTruthy(); + expect(result.preferredUsername).toBe(author.username); + expect(result.publicKey.owner).toBe(result.id); + expect(result._misskey_requireSigninToViewContents).toBe(author.requireSigninToViewContents); + expect(result._misskey_makeNotesFollowersOnlyBefore).toBe(author.makeNotesFollowersOnlyBefore); + expect(result._misskey_makeNotesHiddenBefore).toBe(author.makeNotesHiddenBefore); + expect(result.discoverable).toBe(author.isExplorable); + expect(result.hideOnlineStatus).toBe(author.hideOnlineStatus); + expect(result.noindex).toBe(author.noindex); + expect(result.indexable).toBe(!author.noindex); + expect(result.enableRss).toBe(author.enableRss); + }); + + it('should not include sensitive properties', async () => { + const result = await rendererService.renderPersonRedacted(author) as IActor; + + expect(result.name).toBeUndefined(); + }); + }); }); describe(ApPersonService, () => { diff --git a/packages/frontend/src/pages/admin/index.vue b/packages/frontend/src/pages/admin/index.vue index cbd0d12dcc..3a95e0a5a6 100644 --- a/packages/frontend/src/pages/admin/index.vue +++ b/packages/frontend/src/pages/admin/index.vue @@ -19,6 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts.noBotProtectionWarning }} {{ i18n.ts.configure }} {{ i18n.ts.noEmailServerWarning }} {{ i18n.ts.configure }} {{ i18n.ts.pendingUserApprovals }} {{ i18n.ts.check }} + {{ i18n.ts.authorizedFetchLegacyWarning }} @@ -69,6 +70,7 @@ const noEmailServer = computed(() => !instance.enableEmail); const noInquiryUrl = computed(() => isEmpty(instance.inquiryUrl)); const thereIsUnresolvedAbuseReport = ref(false); const pendingUserApprovals = ref(false); +const hasLegacyAuthFetchSetting = ref(false); const currentPage = computed(() => router.currentRef.value.child); misskeyApi('admin/abuse-user-reports', { @@ -86,6 +88,11 @@ misskeyApi('admin/show-users', { if (approvals.length > 0) pendingUserApprovals.value = true; }); +misskeyApi('admin/meta') + .then(meta => { + hasLegacyAuthFetchSetting.value = meta.hasLegacyAuthFetchSetting; + }); + const NARROW_THRESHOLD = 600; const ro = new ResizeObserver((entries, observer) => { if (entries.length === 0) return; diff --git a/packages/frontend/src/pages/admin/security.vue b/packages/frontend/src/pages/admin/security.vue index 4358821092..38986dc977 100644 --- a/packages/frontend/src/pages/admin/security.vue +++ b/packages/frontend/src/pages/admin/security.vue @@ -8,6 +8,22 @@ SPDX-License-Identifier: AGPL-3.0-only
+ + + + + + + + + + + + + + @@ -96,6 +112,15 @@ import MkFormFooter from '@/components/MkFormFooter.vue'; const meta = await misskeyApi('admin/meta'); +const authFetchForm = useForm({ + allowUnsignedFetch: meta.allowUnsignedFetch, +}, async state => { + await os.apiWithDialog('admin/update-meta', { + allowUnsignedFetch: state.allowUnsignedFetch, + }); + fetchInstance(true); +}); + const ipLoggingForm = useForm({ enableIpLogging: meta.enableIpLogging, }, async (state) => { diff --git a/packages/frontend/src/pages/settings/privacy.vue b/packages/frontend/src/pages/settings/privacy.vue index 0b8e89a6a5..bcedb8b139 100644 --- a/packages/frontend/src/pages/settings/privacy.vue +++ b/packages/frontend/src/pages/settings/privacy.vue @@ -132,6 +132,20 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ i18n.ts._accountSettings.mayNotEffectForFederatedNotes }}
+ + + + + + + + + + + + + +
@@ -192,6 +206,7 @@ import FormSlot from '@/components/form/slot.vue'; import { formatDateTimeString } from '@/scripts/format-time-string.js'; import MkInput from '@/components/MkInput.vue'; import * as os from '@/os.js'; +import MkRadios from '@/components/MkRadios.vue'; const $i = signinRequired(); @@ -210,6 +225,13 @@ const followingVisibility = ref($i.followingVisibility); const followersVisibility = ref($i.followersVisibility); const defaultCW = ref($i.defaultCW); const defaultCWPriority = ref($i.defaultCWPriority); +const allowUnsignedFetch = ref($i.allowUnsignedFetch); +const computedAllowUnsignedFetch = computed(() => { + if (allowUnsignedFetch.value !== 'staff') { + return allowUnsignedFetch.value; + } + return instance.allowUnsignedFetch; +}); const defaultNoteVisibility = computed(defaultStore.makeGetterSetter('defaultNoteVisibility')); const defaultNoteLocalOnly = computed(defaultStore.makeGetterSetter('defaultNoteLocalOnly')); @@ -270,6 +292,7 @@ function save() { followersVisibility: followersVisibility.value, defaultCWPriority: defaultCWPriority.value, defaultCW: defaultCW.value, + allowUnsignedFetch: allowUnsignedFetch.value, }); } diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index c1156a7ffa..8ddd20ab63 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -4276,6 +4276,8 @@ export type components = { defaultCW: string | null; /** @enum {string} */ defaultCWPriority: 'default' | 'parent' | 'defaultParent' | 'parentDefault'; + /** @enum {string} */ + allowUnsignedFetch: 'never' | 'always' | 'essential' | 'staff'; }; UserDetailedNotMe: components['schemas']['UserLite'] & components['schemas']['UserDetailedNotMeOnly']; MeDetailed: components['schemas']['UserLite'] & components['schemas']['UserDetailedNotMeOnly'] & components['schemas']['MeDetailedOnly']; @@ -5385,6 +5387,8 @@ export type components = { requireSetup: boolean; cacheRemoteFiles: boolean; cacheRemoteSensitiveFiles: boolean; + /** @enum {string} */ + allowUnsignedFetch: 'never' | 'always' | 'essential'; }; MetaDetailed: components['schemas']['MetaLite'] & components['schemas']['MetaDetailedOnly']; SystemWebhook: { @@ -8860,6 +8864,9 @@ export type operations = { trustedLinkUrlPatterns: string[]; federation: string; federationHosts: string[]; + hasLegacyAuthFetchSetting: boolean; + /** @enum {string} */ + allowUnsignedFetch: 'never' | 'always' | 'essential'; }; }; }; @@ -11476,6 +11483,8 @@ export type operations = { /** @enum {string} */ federation?: 'all' | 'none' | 'specified'; federationHosts?: string[]; + /** @enum {string} */ + allowUnsignedFetch?: 'never' | 'always' | 'essential'; }; }; }; @@ -22971,6 +22980,8 @@ export type operations = { defaultCW?: string | null; /** @enum {string} */ defaultCWPriority?: 'default' | 'parent' | 'defaultParent' | 'parentDefault'; + /** @enum {string} */ + allowUnsignedFetch?: 'never' | 'always' | 'essential' | 'staff'; }; }; }; diff --git a/sharkey-locales/en-US.yml b/sharkey-locales/en-US.yml index e25020de8e..2e00f15b6f 100644 --- a/sharkey-locales/en-US.yml +++ b/sharkey-locales/en-US.yml @@ -514,3 +514,18 @@ fetchLinkedNote: "Fetch linked note" _processErrors: quoteUnavailable: "Unable to process quote. This post may be missing context." + +authorizedFetchSection: "Authorized Fetch" +authorizedFetchLabel: "Allow unsigned ActivityPub requests:" +authorizedFetchDescription: "This setting controls the behavior when a remote instance or user attempts to access your content without verifying their identity. If disabled, any remote user can access your profile and posts - even one who has been blocked or defederated." +_authorizedFetchValue: + never: "Never" + always: "Always" + essential: "Only for essential metadata" + staff: "Use staff recommendation" +_authorizedFetchValueDescription: + never: "Block all unsigned requests. Improves privacy and makes blocks more effective, but is not compatible with some very old or uncommon instance software." + always: "Allow all unsigned requests. Provides the greatest compatibility with other instances, but reduces privacy and weakens blocks." + essential: "Allow some limited unsigned requests. Provides a hybrid between \"Never\" and \"Always\" by exposing only the minimum profile metadata that is required for federation with older software." + staff: "Use the default value of \"{value}\" recommended by the instance staff." +authorizedFetchLegacyWarning: "The configuration property 'checkActivityPubGetSignature' has been deprecated and replaced with the new Authorized Fetch setting. Please remove it from your configuration file."