summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorHazelnoot <acomputerdog@gmail.com>2025-02-21 22:04:36 -0500
committerHazelnoot <acomputerdog@gmail.com>2025-03-16 10:07:57 -0400
commita35c2f214b1b1054229f31569f6df4090a7375a5 (patch)
treea9e8c42026eeb6a4dcd0e582eefe506943afa0a5
parentmerge: fetch linked notes manually, unless we have them in DB - fixes 1006 (!... (diff)
downloadsharkey-a35c2f214b1b1054229f31569f6df4090a7375a5.tar.gz
sharkey-a35c2f214b1b1054229f31569f6df4090a7375a5.tar.bz2
sharkey-a35c2f214b1b1054229f31569f6df4090a7375a5.zip
convert Authorized Fetch to a setting and add support for hybrid mode (essential metadata only)
-rw-r--r--.config/ci.yml2
-rw-r--r--.config/docker_example.yml2
-rw-r--r--.config/example.yml2
-rw-r--r--UPGRADE_NOTES.md9
-rw-r--r--locales/index.d.ts52
-rw-r--r--packages/backend/migration/1740162088574-add_unsignedFetch.js35
-rw-r--r--packages/backend/src/config.ts1
-rw-r--r--packages/backend/src/const.ts6
-rw-r--r--packages/backend/src/core/CacheService.ts8
-rw-r--r--packages/backend/src/core/CreateSystemUserService.ts3
-rw-r--r--packages/backend/src/core/InstanceActorService.ts10
-rw-r--r--packages/backend/src/core/activitypub/ApRendererService.ts32
-rw-r--r--packages/backend/src/core/entities/MetaEntityService.ts1
-rw-r--r--packages/backend/src/core/entities/UserEntityService.ts1
-rw-r--r--packages/backend/src/models/Meta.ts11
-rw-r--r--packages/backend/src/models/User.ts14
-rw-r--r--packages/backend/src/models/json-schema/meta.ts7
-rw-r--r--packages/backend/src/models/json-schema/user.ts7
-rw-r--r--packages/backend/src/server/ActivityPubServerService.ts220
-rw-r--r--packages/backend/src/server/api/endpoints/admin/meta.ts12
-rw-r--r--packages/backend/src/server/api/endpoints/admin/update-meta.ts10
-rw-r--r--packages/backend/src/server/api/endpoints/i/update.ts10
-rw-r--r--packages/backend/test/unit/activitypub.ts84
-rw-r--r--packages/frontend/src/pages/admin/index.vue7
-rw-r--r--packages/frontend/src/pages/admin/security.vue25
-rw-r--r--packages/frontend/src/pages/settings/privacy.vue23
-rw-r--r--packages/misskey-js/src/autogen/types.ts11
-rw-r--r--sharkey-locales/en-US.yml15
28 files changed, 517 insertions, 103 deletions
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';
@@ -180,6 +181,13 @@ export class CacheService implements OnApplicationShutdown {
}
@bindThis
+ public async findLocalUserById(userId: MiUser['id']): Promise<MiLocalUser | null> {
+ 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);
this.userByIdCache.dispose();
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<MiUser> {
+ public async createSystemUser(username: string, data?: Partial<MiUser>): Promise<MiUser> {
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
@@ -572,6 +572,38 @@ export class ApRendererService {
}
@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 {
type: 'Question',
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<MiUser>) {
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<any> {
+ private async packActivity(note: MiNote, author: MiUser): Promise<ICreate | IAnnounce> {
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<boolean> {
- 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<string | null> {
/* 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<typeof meta, typeof paramDef> { // 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<typeof meta, typeof paramDef> { // 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<typeof meta, typeof paramDef> { // 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>(LoggerService));
idService = app.get<IdService>(IdService);
userPublickeysRepository = app.get<UserPublickeysRepository>(DI.userPublickeysRepository);
+ userKeypairService = app.get<UserKeypairService>(UserKeypairService);
// Prevent ApPersonService from fetching instance, as it causes Jest import-after-test error
const federatedInstanceService = app.get<FederatedInstanceService>(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<MiUserKeypair> }).cache as unknown as { memoryCache: MemoryKVCache<MiUserKeypair> }).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
<MkInfo v-if="noBotProtection" warn class="info">{{ i18n.ts.noBotProtectionWarning }} <MkA to="/admin/security" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo>
<MkInfo v-if="noEmailServer" warn class="info">{{ i18n.ts.noEmailServerWarning }} <MkA to="/admin/email-settings" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo>
<MkInfo v-if="pendingUserApprovals" warn class="info">{{ i18n.ts.pendingUserApprovals }} <MkA to="/admin/approvals" class="_link">{{ i18n.ts.check }}</MkA></MkInfo>
+ <MkInfo v-if="hasLegacyAuthFetchSetting" warn class="info">{{ i18n.ts.authorizedFetchLegacyWarning }}</MkInfo>
</div>
<MkSuperMenu :def="menuDef" :grid="narrow"></MkSuperMenu>
@@ -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
<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :contentMax="700" :marginMin="16" :marginMax="32">
<div class="_gaps_m">
+ <MkFolder v-if="meta.federation !== 'none'">
+ <template #label>{{ i18n.ts.authorizedFetchSection }}</template>
+ <template #suffix>{{ meta.allowUnsignedFetch !== 'always' ? i18n.ts.enabled : i18n.ts.disabled }}</template>
+ <template v-if="authFetchForm.modified.value" #footer>
+ <MkFormFooter :form="authFetchForm"/>
+ </template>
+
+ <MkRadios v-model="authFetchForm.state.allowUnsignedFetch">
+ <template #label>{{ i18n.ts.authorizedFetchLabel }}</template>
+ <template #caption>{{ i18n.ts.authorizedFetchDescription }}</template>
+ <option value="never">{{ i18n.ts._authorizedFetchValue.never }} - {{ i18n.ts._authorizedFetchValueDescription.never }}</option>
+ <option value="always">{{ i18n.ts._authorizedFetchValue.always }} - {{ i18n.ts._authorizedFetchValueDescription.always }}</option>
+ <option value="essential">{{ i18n.ts._authorizedFetchValue.essential }} - {{ i18n.ts._authorizedFetchValueDescription.essential }}</option>
+ </MkRadios>
+ </MkFolder>
+
<XBotProtection/>
<MkFolder>
@@ -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
<div v-if="instance.federation !== 'none'"><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.ts._accountSettings.mayNotEffectForFederatedNotes }}</div>
</template>
</FormSlot>
+
+ <MkFolder v-if="instance.federation !== 'none'">
+ <template #label>{{ i18n.ts.authorizedFetchSection }}</template>
+ <template #suffix>{{ computedAllowUnsignedFetch !== 'always' ? i18n.ts.enabled : i18n.ts.disabled }}</template>
+
+ <MkRadios v-model="allowUnsignedFetch" @update:modelValue="save()">
+ <template #label>{{ i18n.ts.authorizedFetchLabel }}</template>
+ <template #caption>{{ i18n.ts.authorizedFetchDescription }}</template>
+ <option value="never">{{ i18n.ts._authorizedFetchValue.never }} - {{ i18n.ts._authorizedFetchValueDescription.never }}</option>
+ <option value="always">{{ i18n.ts._authorizedFetchValue.always }} - {{ i18n.ts._authorizedFetchValueDescription.always }}</option>
+ <option value="essential">{{ i18n.ts._authorizedFetchValue.essential }} - {{ i18n.ts._authorizedFetchValueDescription.essential }}</option>
+ <option value="staff">{{ i18n.ts._authorizedFetchValue.staff }} - {{ i18n.tsx._authorizedFetchValueDescription.staff({ value: i18n.ts._authorizedFetchValue[instance.allowUnsignedFetch] }) }}</option>
+ </MkRadios>
+ </MkFolder>
</div>
</FormSection>
@@ -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."