Allowlist implementation

This commit is contained in:
jaina heartles 2024-02-24 14:33:12 -08:00
parent d1e8653449
commit 9dcc7645ea
22 changed files with 158 additions and 5 deletions

View file

@ -142,6 +142,7 @@ unmute: "Unmute"
renoteMute: "Mute Boosts"
renoteUnmute: "Unmute Boosts"
block: "Block"
allow: "Allow"
unblock: "Unblock"
markAsNSFW: "Mark all media from user as NSFW"
suspend: "Suspend"
@ -210,6 +211,7 @@ perHour: "Per Hour"
perDay: "Per Day"
stopActivityDelivery: "Stop sending activities"
blockThisInstance: "Block this instance"
allowThisInstance: "Allow this instance"
silenceThisInstance: "Silence this instance"
operations: "Operations"
software: "Software"
@ -230,6 +232,9 @@ clearCachedFiles: "Clear cache"
clearCachedFilesConfirm: "Are you sure that you want to delete all cached remote files?"
blockedInstances: "Blocked Instances"
blockedInstancesDescription: "List the hostnames of the instances you want to block separated by linebreaks. Listed instances will no longer be able to communicate with this instance."
allowedInstances: "Allowed instances"
allowedInstancesDescription: "List the hostnames of the instances you want to allow separated by linebreaks. Listed instances will be able to communicate with this instance if they are not already blocked."
allowlistModeDescription: "Enable allowlist mode, which will reject federation with any instances not on a preapproved list."
silencedInstances: "Silenced instances"
silencedInstancesDescription: "List the hostnames of the instances that you want to silence. All accounts of the listed instances will be treated as silenced, can only make follow requests, and cannot mention local accounts if not followed. This will not affect blocked instances."
muteAndBlock: "Mutes and Blocks"
@ -249,6 +254,7 @@ noCustomEmojis: "There are no emoji"
noJobs: "There are no jobs"
federating: "Federating"
blocked: "Blocked"
allowed: "Allowed"
suspended: "Suspended"
all: "All"
subscribing: "Subscribing"

View file

@ -0,0 +1,17 @@
export class allowlist1635065559969 {
constructor() {
this.name = 'allowlist1635065559969';
}
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ADD "allowedHosts" character varying(256) array NOT NULL DEFAULT '{}'::varchar[]`);
await queryRunner.query(`ALTER TABLE "meta" ADD "allowlistMode" boolean NOT NULL DEFAULT false`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "allowedHosts"`);
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "allowlistMode"`);
}
}

View file

@ -42,6 +42,12 @@ export class UtilityService {
return silencedHosts.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`));
}
@bindThis
public isAllowedHost(allowedHosts: string[], host: string | null): boolean {
if (host == null) return false;
return allowedHosts.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`));
}
@bindThis
public isSensitiveWordIncluded(text: string, sensitiveWords: string[]): boolean {
if (sensitiveWords.length === 0) return false;

View file

@ -269,7 +269,9 @@ export class ApInboxService {
// アナウンス先をブロックしてたら中断
const meta = await this.metaService.fetch();
if (this.utilityService.isBlockedHost(meta.blockedHosts, this.utilityService.extractDbHost(uri))) return;
const dbHost = this.utilityService.extractDbHost(uri);
if (this.utilityService.isBlockedHost(meta.blockedHosts, dbHost)) return;
if (meta.allowlistMode && !this.utilityService.isAllowedHost(meta.allowedHosts, dbHost)) return;
const unlock = await this.appLockService.getApLock(uri);

View file

@ -99,6 +99,10 @@ export class Resolver {
throw new Error('Instance is blocked');
}
if (meta.allowlistMode && !this.utilityService.isAllowedHost(meta.allowedHosts, host)) {
throw new Error('Instance is blocked');
}
if (this.config.signToActivityPubGet && !this.user) {
this.user = await this.instanceActorService.getInstanceActor();
}

View file

@ -532,7 +532,12 @@ export class ApNoteService {
// ブロックしていたら中断
const meta = await this.metaService.fetch();
if (this.utilityService.isBlockedHost(meta.blockedHosts, this.utilityService.extractDbHost(uri))) {
const dbHost = this.utilityService.extractDbHost(uri);
if (this.utilityService.isBlockedHost(meta.blockedHosts, dbHost)) {
throw new StatusError('blocked host', 451);
}
if (meta.allowlistMode && !this.utilityService.isAllowedHost(meta.allowedHosts, dbHost)) {
throw new StatusError('blocked host', 451);
}

View file

@ -61,11 +61,13 @@ export default class FederationChart extends Chart<typeof schema> { // eslint-di
.select('f.followerHost')
.where('f.followerHost IS NOT NULL');
const skipAllowlistQuery = !meta.allowlistMode || meta.allowedHosts.length === 0;
const [sub, pub, pubsub, subActive, pubActive] = await Promise.all([
this.followingsRepository.createQueryBuilder('following')
.select('COUNT(DISTINCT following.followeeHost)')
.where('following.followeeHost IS NOT NULL')
.andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'following.followeeHost NOT ILIKE ANY(ARRAY[:...blocked])', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) })
.andWhere(skipAllowlistQuery ? '1=1' : 'following.followeeHost ILIKE ANY(ARRAY[:...allowed])', { allowed: meta.allowedHosts.flatMap(x => [x, `%.${x}`]) })
.andWhere(`following.followeeHost NOT IN (${ suspendedInstancesQuery.getQuery() })`)
.getRawOne()
.then(x => parseInt(x.count, 10)),
@ -73,6 +75,7 @@ export default class FederationChart extends Chart<typeof schema> { // eslint-di
.select('COUNT(DISTINCT following.followerHost)')
.where('following.followerHost IS NOT NULL')
.andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'following.followerHost NOT ILIKE ANY(ARRAY[:...blocked])', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) })
.andWhere(skipAllowlistQuery ? '1=1' : 'following.followerHost ILIKE ANY(ARRAY[:...allowed])', { allowed: meta.allowedHosts.flatMap(x => [x, `%.${x}`]) })
.andWhere(`following.followerHost NOT IN (${ suspendedInstancesQuery.getQuery() })`)
.getRawOne()
.then(x => parseInt(x.count, 10)),
@ -80,6 +83,7 @@ export default class FederationChart extends Chart<typeof schema> { // eslint-di
.select('COUNT(DISTINCT following.followeeHost)')
.where('following.followeeHost IS NOT NULL')
.andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'following.followeeHost NOT ILIKE ANY(ARRAY[:...blocked])', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) })
.andWhere(skipAllowlistQuery ? '1=1' : 'following.followeeHost ILIKE ANY(ARRAY[:...allowed])', { allowed: meta.allowedHosts.flatMap(x => [x, `%.${x}`]) })
.andWhere(`following.followeeHost NOT IN (${ suspendedInstancesQuery.getQuery() })`)
.andWhere(`following.followeeHost IN (${ pubsubSubQuery.getQuery() })`)
.setParameters(pubsubSubQuery.getParameters())
@ -89,6 +93,7 @@ export default class FederationChart extends Chart<typeof schema> { // eslint-di
.select('COUNT(instance.id)')
.where(`instance.host IN (${ subInstancesQuery.getQuery() })`)
.andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'instance.host NOT ILIKE ANY(ARRAY[:...blocked])', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) })
.andWhere(skipAllowlistQuery ? '1=1' : 'instance.host ILIKE ANY(ARRAY[:...allowed])', { allowed: meta.allowedHosts.flatMap(x => [x, `%.${x}`]) })
.andWhere('instance.isSuspended = false')
.andWhere('instance.isNotResponding = false')
.getRawOne()
@ -97,6 +102,7 @@ export default class FederationChart extends Chart<typeof schema> { // eslint-di
.select('COUNT(instance.id)')
.where(`instance.host IN (${ pubInstancesQuery.getQuery() })`)
.andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'instance.host NOT ILIKE ANY(ARRAY[:...blocked])', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) })
.andWhere(skipAllowlistQuery ? '1=1' : 'instance.host ILIKE ANY(ARRAY[:...allowed])', { allowed: meta.allowedHosts.flatMap(x => [x, `%.${x}`]) })
.andWhere('instance.isSuspended = false')
.andWhere('instance.isNotResponding = false')
.getRawOne()

View file

@ -35,6 +35,7 @@ export class InstanceEntityService {
isNotResponding: instance.isNotResponding,
isSuspended: instance.isSuspended,
isBlocked: this.utilityService.isBlockedHost(meta.blockedHosts, instance.host),
isAllowed: meta.allowlistMode ? this.utilityService.isAllowedHost(meta.allowedHosts, instance.host) : null,
softwareName: instance.softwareName,
softwareVersion: instance.softwareVersion,
openRegistrations: instance.openRegistrations,

View file

@ -71,6 +71,16 @@ export class MiMeta {
})
public blockedHosts: string[];
@Column('varchar', {
length: 1024, array: true, default: '{}',
})
public allowedHosts: string[];
@Column('boolean', {
default: false,
})
public allowlistMode: boolean;
@Column('varchar', {
length: 1024, array: true, default: '{}',
})

View file

@ -49,6 +49,10 @@ export const packedFederationInstanceSchema = {
type: 'boolean',
optional: false, nullable: false,
},
isAllowed: {
type: 'boolean',
optional: false, nullable: true,
},
softwareName: {
type: 'string',
optional: false, nullable: true,

View file

@ -53,9 +53,13 @@ export class DeliverProcessorService {
// ブロックしてたら中断
const meta = await this.metaService.fetch();
if (this.utilityService.isBlockedHost(meta.blockedHosts, this.utilityService.toPuny(host))) {
const punyHost = this.utilityService.toPuny(host);
if (this.utilityService.isBlockedHost(meta.blockedHosts, punyHost)) {
return 'skip (blocked)';
}
if (meta.allowlistMode && !this.utilityService.isAllowedHost(meta.allowedHosts, punyHost)) {
return 'skip (not allowed)';
}
// isSuspendedなら中断
let suspendedHosts = this.suspendedHostsCache.get();

View file

@ -67,6 +67,10 @@ export class InboxProcessorService {
return `Blocked request: ${host}`;
}
if (meta.allowlistMode && !this.utilityService.isAllowedHost(meta.allowedHosts, host)) {
return `Blocked request: ${host}`;
}
const keyIdLower = signature.keyId.toLowerCase();
if (keyIdLower.startsWith('acct:')) {
return `Old keyId is no longer supported. ${keyIdLower}`;
@ -159,6 +163,9 @@ export class InboxProcessorService {
if (this.utilityService.isBlockedHost(meta.blockedHosts, ldHost)) {
throw new Bull.UnrecoverableError(`Blocked request: ${ldHost}`);
}
if (meta.allowlistMode && !this.utilityService.isAllowedHost(meta.allowedHosts, ldHost)) {
throw new Bull.UnrecoverableError(`Blocked request: ${ldHost}`);
}
} else {
throw new Bull.UnrecoverableError(`skip: http-signature verification failed and no LD-Signature. keyId=${signature.keyId}`);
}

View file

@ -181,6 +181,11 @@ export class ActivityPubServerService {
this.authlogger.warn(`${request.id} ${request.url} instance ${keyHost} is blocked: refuse`);
reply.code(401);
return true;
} else if (meta.allowlistMode && !this.utilityService.isAllowedHost(meta.allowedHosts, keyHost)) {
/* allowlist mode enabled and instance not on allowlist: refuse */
this.authLogger.warn(`${request.id} ${request.url} instance ${keyHost} is not on allowlist: refuse`);
reply.code(401);
return true;
}
// do we know the signer already?

View file

@ -141,6 +141,17 @@ export const meta = {
type: 'string',
},
},
allowedHosts: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'string',
},
},
allowlistMode: {
type: 'boolean',
optional: false, nullable: false,
},
sensitiveWords: {
type: 'array',
optional: false, nullable: false,
@ -503,6 +514,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
pinnedUsers: instance.pinnedUsers,
hiddenTags: instance.hiddenTags,
blockedHosts: instance.blockedHosts,
allowedHosts: instance.allowedHosts,
allowlistMode: instance.allowlistMode,
silencedHosts: instance.silencedHosts,
sensitiveWords: instance.sensitiveWords,
preservedUsernames: instance.preservedUsernames,

View file

@ -36,6 +36,12 @@ export const paramDef = {
type: 'string',
},
},
allowedHosts: {
type: 'array', nullable: true, items: {
type: 'string',
},
},
allowlistMode: { type: 'boolean', nullable: true },
sensitiveWords: {
type: 'array', nullable: true, items: {
type: 'string',
@ -172,6 +178,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
set.blockedHosts = ps.blockedHosts.filter(Boolean).map(x => x.toLowerCase());
}
if (Array.isArray(ps.allowedHosts)) {
set.allowedHosts = ps.allowedHosts.filter(Boolean).map(x => x.toLowerCase());
}
if (ps.allowlistMode !== undefined) {
set.allowlistMode = ps.allowlistMode;
}
if (Array.isArray(ps.sensitiveWords)) {
set.sensitiveWords = ps.sensitiveWords.filter(Boolean);
}

View file

@ -114,7 +114,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private async fetchAny(uri: string, me: MiLocalUser | null | undefined): Promise<SchemaType<typeof meta['res']> | null> {
// ブロックしてたら中断
const fetchedMeta = await this.metaService.fetch();
if (this.utilityService.isBlockedHost(fetchedMeta.blockedHosts, this.utilityService.extractDbHost(uri))) return null;
const dbHost = this.utilityService.extractDbHost(uri);
if (this.utilityService.isBlockedHost(fetchedMeta.blockedHosts, dbHost)) return null;
if (fetchedMeta.allowlistMode && !this.utilityService.isAllowedHost(fetchedMeta.allowedHosts, dbHost)) return null;
let local = await this.mergePack(me, ...await Promise.all([
this.apDbResolverService.getUserFromApId(uri),

View file

@ -34,6 +34,7 @@ export const paramDef = {
properties: {
host: { type: 'string', nullable: true, description: 'Omit or use `null` to not filter by host.' },
blocked: { type: 'boolean', nullable: true },
allowed: { type: 'boolean', nullable: true },
notResponding: { type: 'boolean', nullable: true },
suspended: { type: 'boolean', nullable: true },
silenced: { type: 'boolean', nullable: true },
@ -107,6 +108,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}
}
if (typeof ps.allowed === 'boolean') {
const meta = await this.metaService.fetch(true);
if (ps.allowed) {
query.andWhere(meta.allowedHosts.length === 0 ? '1=0' : 'instance.host IN (:...allows)', { allows: meta.allowedHosts });
} else {
query.andWhere(meta.allowedHosts.length === 0 ? '1=1' : 'instance.host NOT IN (:...allows)', { allows: meta.allowedHosts });
}
}
if (typeof ps.notResponding === 'boolean') {
if (ps.notResponding) {
query.andWhere('instance.isNotResponding = TRUE');

View file

@ -157,6 +157,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (note.user?.isSilenced && me && followings && note.userId !== me.id && !followings[note.userId]) return false;
if (note.user?.isSuspended) return false;
if (this.utilityService.isBlockedHost(meta.blockedHosts, note.userHost)) return false;
if (meta.allowlistMode && !this.utilityService.isAllowedHost(meta.allowedHosts, note.userHost)) return false;
if (this.utilityService.isSilencedHost(meta.silencedHosts, note.userHost)) return false;
return true;
});

View file

@ -21,6 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<option value="suspended">{{ i18n.ts.suspended }}</option>
<option value="silenced">{{ i18n.ts.silence }}</option>
<option value="blocked">{{ i18n.ts.blocked }}</option>
<option value="allowed">{{ i18n.ts.allowed }}</option>
<option value="notResponding">{{ i18n.ts.notResponding }}</option>
</MkSelect>
<MkSelect v-model="sort">
@ -77,6 +78,7 @@ const pagination = {
state.value === 'publishing' ? { publishing: true } :
state.value === 'suspended' ? { suspended: true } :
state.value === 'blocked' ? { blocked: true } :
state.value === 'allowed' ? { allowed: true } :
state.value === 'silenced' ? { silenced: true } :
state.value === 'notResponding' ? { notResponding: true } :
state.value === 'nsfw' ? { nsfw: true } :

View file

@ -24,6 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<option value="nsfw">NSFW</option>
<option value="suspended">{{ i18n.ts.suspended }}</option>
<option value="blocked">{{ i18n.ts.blocked }}</option>
<option value="allowed">{{ i18n.ts.allowed }}</option>
<option value="silenced">{{ i18n.ts.silence }}</option>
<option value="notResponding">{{ i18n.ts.notResponding }}</option>
</MkSelect>
@ -85,6 +86,7 @@ const pagination = {
state.value === 'publishing' ? { publishing: true } :
state.value === 'suspended' ? { suspended: true } :
state.value === 'blocked' ? { blocked: true } :
state.value === 'allowed' ? { allowed: true } :
state.value === 'silenced' ? { silenced: true } :
state.value === 'notResponding' ? { notResponding: true } :
state.value === 'nsfw' ? { nsfw: true } :
@ -98,6 +100,7 @@ function getStatus(instance) {
if (instance.isSilenced) return 'Silenced';
if (instance.isNotResponding) return 'Error';
if (instance.isNSFW) return 'NSFW';
if (instance.isAllowed) return 'Allowed';
return 'Alive';
}

View file

@ -16,6 +16,14 @@ SPDX-License-Identifier: AGPL-3.0-only
<span>{{ i18n.ts.silencedInstances }}</span>
<template #caption>{{ i18n.ts.silencedInstancesDescription }}</template>
</MkTextarea>
<template v-else-if="tab === 'allow'">
<MkSwitch v-model="allowlistMode">{{ i18n.ts.allowlistModeDescription }}</MkSwitch>
<br />
<MkTextarea v-model="allowedHosts" class="_formBlock">
<span>{{ i18n.ts.allowedInstances }}</span>
<template #caption>{{ i18n.ts.allowedInstancesDescription }}</template>
</MkTextarea>
</template>
<MkButton primary @click="save"><i class="ph-floppy-disk ph-bold ph-lg"></i> {{ i18n.ts.save }}</MkButton>
</FormSuspense>
</MkSpacer>
@ -27,6 +35,7 @@ import { ref, computed } from 'vue';
import XHeader from './_header_.vue';
import MkButton from '@/components/MkButton.vue';
import MkTextarea from '@/components/MkTextarea.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import FormSuspense from '@/components/form/suspense.vue';
import * as os from '@/os.js';
import { fetchInstance } from '@/instance.js';
@ -35,18 +44,24 @@ import { definePageMetadata } from '@/scripts/page-metadata.js';
const blockedHosts = ref<string>('');
const silencedHosts = ref<string>('');
const tab = ref('block');
const allowedHosts = ref<string>('');
const allowlistMode = ref<boolean>(false);
const tab = ref('allow');
async function init() {
const meta = await os.api('admin/meta');
blockedHosts.value = meta.blockedHosts.join('\n');
silencedHosts.value = meta.silencedHosts.join('\n');
allowedHosts.value = meta.allowedHosts.join('\n');
allowlistMode.value = meta.allowlistMode;
}
function save() {
os.apiWithDialog('admin/update-meta', {
blockedHosts: blockedHosts.value.split('\n') || [],
silencedHosts: silencedHosts.value.split('\n') || [],
allowedHosts: allowedHosts.value.split('\n') || [],
allowlistMode: allowlistMode.value || false,
}).then(() => {
fetchInstance();
@ -63,6 +78,10 @@ const headerTabs = computed(() => [{
key: 'silence',
title: i18n.ts.silence,
icon: 'ph-eye-closed ph-bold ph-lg',
}, {
key: 'allow',
title: i18n.ts.allow,
icon: 'ph-prohibit ph-bold ph-lg',
}]);
definePageMetadata({

View file

@ -36,6 +36,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_gaps_s">
<MkSwitch v-model="suspended" :disabled="!instance" @update:modelValue="toggleSuspend">{{ i18n.ts.stopActivityDelivery }}</MkSwitch>
<MkSwitch v-model="isBlocked" :disabled="!meta || !instance" @update:modelValue="toggleBlock">{{ i18n.ts.blockThisInstance }}</MkSwitch>
<MkSwitch v-model="isAllowed" :disabled="!meta || !instance" @update:modelValue="toggleAllow">{{ i18n.ts.allowThisInstance }}</MkSwitch>
<MkSwitch v-model="isSilenced" :disabled="!meta || !instance" @update:modelValue="toggleSilenced">{{ i18n.ts.silenceThisInstance }}</MkSwitch>
<MkSwitch v-model="isNSFW" :disabled="!instance" @update:modelValue="toggleNSFW">Mark as NSFW</MkSwitch>
<MkButton @click="refreshMetadata"><i class="ph-arrows-counter-clockwise ph-bold ph-lg"></i> Refresh metadata</MkButton>
@ -149,6 +150,7 @@ const meta = ref<Misskey.entities.AdminMetaResponse | null>(null);
const instance = ref<Misskey.entities.FederationInstance | null>(null);
const suspended = ref(false);
const isBlocked = ref(false);
const isAllowed = ref(false);
const isSilenced = ref(false);
const isNSFW = ref(false);
const faviconUrl = ref<string | null>(null);
@ -173,6 +175,7 @@ async function fetch(): Promise<void> {
});
suspended.value = instance.value?.isSuspended ?? false;
isBlocked.value = instance.value?.isBlocked ?? false;
isAllowed.value = instance.value?.isAllowed ?? false;
isSilenced.value = instance.value?.isSilenced ?? false;
isNSFW.value = instance.value?.isNSFW ?? false;
faviconUrl.value = getProxiedImageUrlNullable(instance.value?.faviconUrl, 'preview') ?? getProxiedImageUrlNullable(instance.value?.iconUrl, 'preview');
@ -187,6 +190,15 @@ async function toggleBlock(): Promise<void> {
});
}
async function toggleAllow(): Promise<void> {
if (!meta.value) throw new Error('No meta?');
if (!instance.value) throw new Error('No instance?');
const { host } = instance.value;
await os.api('admin/update-meta', {
allowedHosts: isAllowed.value ? meta.value.allowedHosts.concat([host]) : meta.value.allowedHosts.filter(x => x !== host),
});
}
async function toggleSilenced(): Promise<void> {
if (!meta.value) throw new Error('No meta?');
if (!instance.value) throw new Error('No instance?');