Compare commits
7 commits
d01374055a
...
5c017af2fe
Author | SHA1 | Date | |
---|---|---|---|
5c017af2fe | |||
a2c3a75f0f | |||
|
62d35a56c1 | ||
|
bb3d710927 | ||
|
97da78b46c | ||
|
e0f3780885 | ||
|
a35c2f214b |
29 changed files with 540 additions and 129 deletions
|
@ -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".
|
||||
|
|
|
@ -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".
|
||||
|
|
|
@ -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".
|
||||
|
|
|
@ -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
|
||||
|
|
52
locales/index.d.ts
vendored
52
locales/index.d.ts
vendored
|
@ -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;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "quollkey",
|
||||
"version": "2025.3.2-dev",
|
||||
"version": "2025.3.3-dev",
|
||||
"codename": "quoll",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
|
@ -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 system accounts
|
||||
await queryRunner.query(`UPDATE "user" SET "allowUnsignedFetch" = 'always' WHERE "username" LIKE '%.%' 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"`);
|
||||
}
|
||||
}
|
|
@ -200,6 +200,7 @@ export type Config = {
|
|||
customMOTD: string[] | undefined;
|
||||
signToActivityPubGet: boolean;
|
||||
attachLdSignatureForRelays: boolean;
|
||||
/** @deprecated Use MiMeta.allowUnsignedFetch instead */
|
||||
checkActivityPubGetSignature: boolean | undefined;
|
||||
logging?: {
|
||||
sql?: {
|
||||
|
|
|
@ -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];
|
||||
|
|
|
@ -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<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);
|
||||
|
|
|
@ -63,6 +63,13 @@ export class CreateSystemUserService {
|
|||
isExplorable: false,
|
||||
approved: true,
|
||||
isBot: true,
|
||||
/* 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',
|
||||
}).then(x => transactionalEntityManager.findOneByOrFail(MiUser, x.identifiers[0]));
|
||||
|
||||
await transactionalEntityManager.insert(MiUserKeypair, {
|
||||
|
|
|
@ -101,6 +101,7 @@ function generateDummyUser(override?: Partial<MiUser>): MiUser {
|
|||
enableRss: true,
|
||||
mandatoryCW: null,
|
||||
rejectQuotes: false,
|
||||
allowUnsignedFetch: 'staff',
|
||||
...override,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -191,6 +191,7 @@ export class MetaEntityService {
|
|||
serviceWorker: instance.enableServiceWorker,
|
||||
miauth: true,
|
||||
},
|
||||
allowUnsignedFetch: instance.allowUnsignedFetch,
|
||||
};
|
||||
|
||||
return packDetailed;
|
||||
|
|
|
@ -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 ? {
|
||||
|
|
|
@ -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,64 +750,74 @@ export class MiMeta {
|
|||
default: '{}',
|
||||
})
|
||||
public federationHosts: string[];
|
||||
|
||||
@Column('varchar', {
|
||||
length: 1024,
|
||||
nullable: true,
|
||||
})
|
||||
public link1Url: string | null;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 255,
|
||||
nullable: true,
|
||||
length: 1024,
|
||||
nullable: true,
|
||||
})
|
||||
public link1Url: string | null;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 255,
|
||||
nullable: true,
|
||||
})
|
||||
public link1Label: string | null;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 1024,
|
||||
nullable: true,
|
||||
length: 1024,
|
||||
nullable: true,
|
||||
})
|
||||
public link2Url: string | null;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 255,
|
||||
nullable: true,
|
||||
length: 255,
|
||||
nullable: true,
|
||||
})
|
||||
public link2Label: string | null;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 1024,
|
||||
nullable: true,
|
||||
length: 1024,
|
||||
nullable: true,
|
||||
})
|
||||
public link3Url: string | null;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 255,
|
||||
nullable: true,
|
||||
length: 255,
|
||||
nullable: true,
|
||||
})
|
||||
public link3Label: string | null;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 1024,
|
||||
nullable: true,
|
||||
length: 1024,
|
||||
nullable: true,
|
||||
})
|
||||
public link4Url: string | null;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 255,
|
||||
nullable: true,
|
||||
length: 255,
|
||||
nullable: true,
|
||||
})
|
||||
public link4Label: string | null;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 1024,
|
||||
nullable: true,
|
||||
length: 1024,
|
||||
nullable: true,
|
||||
})
|
||||
public link5Url: string | null;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 255,
|
||||
nullable: true,
|
||||
length: 255,
|
||||
nullable: true,
|
||||
})
|
||||
public link5Label: string | null;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
@ -437,6 +439,11 @@ export const packedMetaDetailedOnlySchema = {
|
|||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
allowUnsignedFetch: {
|
||||
type: 'string',
|
||||
enum: instanceUnsignedFetchOptions,
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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'],
|
||||
|
@ -629,6 +630,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;
|
||||
|
@ -795,6 +805,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
link4Label: instance.link4Label,
|
||||
link5Url: instance.link5Url,
|
||||
link5Label: instance.link5Label,
|
||||
hasLegacyAuthFetchSetting: config.checkActivityPubGetSignature != null,
|
||||
allowUnsignedFetch: instance.allowUnsignedFetch,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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'],
|
||||
|
@ -215,6 +216,11 @@ export const paramDef = {
|
|||
type: 'string',
|
||||
},
|
||||
},
|
||||
allowUnsignedFetch: {
|
||||
type: 'string',
|
||||
enum: instanceUnsignedFetchOptions,
|
||||
nullable: false,
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
} as const;
|
||||
|
@ -794,6 +800,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
set.link5Label = ps.link5Label;
|
||||
}
|
||||
|
||||
if (ps.allowUnsignedFetch !== undefined) {
|
||||
set.allowUnsignedFetch = ps.allowUnsignedFetch;
|
||||
}
|
||||
|
||||
const before = await this.metaService.fetch(true);
|
||||
|
||||
await this.metaService.update(set);
|
||||
|
|
|
@ -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[];
|
||||
|
|
|
@ -2,15 +2,14 @@
|
|||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
import * as assert from 'assert';
|
||||
import { generateKeyPair } from 'crypto';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { jest } from '@jest/globals';
|
||||
|
||||
import type { MiLocalUser, MiRemoteUser } from '@/models/User.js';
|
||||
import { ApImageService } from '@/core/activitypub/models/ApImageService.js';
|
||||
import { ApNoteService } from '@/core/activitypub/models/ApNoteService.js';
|
||||
import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js';
|
||||
|
@ -22,13 +21,15 @@ 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 { 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 { IdService } from '@/core/IdService.js';
|
||||
|
||||
const host = 'https://host1.test';
|
||||
|
||||
|
@ -97,6 +98,7 @@ describe('ActivityPub', () => {
|
|||
let resolver: MockResolver;
|
||||
let idService: IdService;
|
||||
let userPublickeysRepository: UserPublickeysRepository;
|
||||
let userKeypairService: UserKeypairService;
|
||||
|
||||
const metaInitial = {
|
||||
cacheRemoteFiles: true,
|
||||
|
@ -146,6 +148,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 +489,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 +666,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, () => {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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'];
|
||||
|
@ -5395,6 +5397,8 @@ export type components = {
|
|||
requireSetup: boolean;
|
||||
cacheRemoteFiles: boolean;
|
||||
cacheRemoteSensitiveFiles: boolean;
|
||||
/** @enum {string} */
|
||||
allowUnsignedFetch: 'never' | 'always' | 'essential';
|
||||
};
|
||||
MetaDetailed: components['schemas']['MetaLite'] & components['schemas']['MetaDetailedOnly'];
|
||||
SystemWebhook: {
|
||||
|
@ -8870,6 +8874,9 @@ export type operations = {
|
|||
trustedLinkUrlPatterns: string[];
|
||||
federation: string;
|
||||
federationHosts: string[];
|
||||
hasLegacyAuthFetchSetting: boolean;
|
||||
/** @enum {string} */
|
||||
allowUnsignedFetch: 'never' | 'always' | 'essential';
|
||||
};
|
||||
};
|
||||
};
|
||||
|
@ -11486,6 +11493,8 @@ export type operations = {
|
|||
/** @enum {string} */
|
||||
federation?: 'all' | 'none' | 'specified';
|
||||
federationHosts?: string[];
|
||||
/** @enum {string} */
|
||||
allowUnsignedFetch?: 'never' | 'always' | 'essential';
|
||||
};
|
||||
};
|
||||
};
|
||||
|
@ -22981,6 +22990,8 @@ export type operations = {
|
|||
defaultCW?: string | null;
|
||||
/** @enum {string} */
|
||||
defaultCWPriority?: 'default' | 'parent' | 'defaultParent' | 'parentDefault';
|
||||
/** @enum {string} */
|
||||
allowUnsignedFetch?: 'never' | 'always' | 'essential' | 'staff';
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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."
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue