From 2bf2eac76513ede93190503e4f52b6086292c7d4 Mon Sep 17 00:00:00 2001 From: nullobsi Date: Tue, 20 Jul 2021 09:45:41 -0700 Subject: [PATCH] Add Secure Mode and Private Mode - Add instance actor - Add private mode, which uses an allowlist - Add Secure Mode, restricts access to blocked instances --- packages/backend/src/models/entities/meta.ts | 15 +++ .../backend/src/queue/processors/deliver.ts | 4 + .../backend/src/queue/processors/inbox.ts | 5 + .../src/remote/activitypub/check-fetch.ts | 73 ++++++++++ .../src/remote/activitypub/resolver.ts | 4 + packages/backend/src/server/activitypub.ts | 127 ++++++++++++++++-- .../src/server/activitypub/featured.ts | 16 ++- .../src/server/activitypub/followers.ts | 15 ++- .../src/server/activitypub/following.ts | 15 ++- .../backend/src/server/activitypub/outbox.ts | 16 ++- .../backend/src/server/api/endpoints/meta.ts | 12 +- 11 files changed, 289 insertions(+), 13 deletions(-) create mode 100644 packages/backend/src/remote/activitypub/check-fetch.ts diff --git a/packages/backend/src/models/entities/meta.ts b/packages/backend/src/models/entities/meta.ts index d33ff2519e..42c763d27d 100644 --- a/packages/backend/src/models/entities/meta.ts +++ b/packages/backend/src/models/entities/meta.ts @@ -77,6 +77,21 @@ export class Meta { }) public blockedHosts: string[]; + @Column('boolean', { + default: false + }) + public secureMode: boolean; + + @Column('boolean', { + default: false + }) + public privateMode: boolean; + + @Column('varchar', { + length: 256, array: true, default: '{}' + }) + public allowedHosts: string[]; + @Column('varchar', { length: 512, array: true, default: '{/featured,/channels,/explore,/pages,/about-misskey}', }) diff --git a/packages/backend/src/queue/processors/deliver.ts b/packages/backend/src/queue/processors/deliver.ts index 291c05766e..01ba4381a2 100644 --- a/packages/backend/src/queue/processors/deliver.ts +++ b/packages/backend/src/queue/processors/deliver.ts @@ -28,6 +28,10 @@ export default async (job: Bull.Job) => { return 'skip (blocked)'; } + if (meta.privateMode && !meta.allowedHosts.includes(toPuny(host))) { + return 'skip (not allowed)'; + } + // isSuspendedなら中断 let suspendedHosts = suspendedHostsCache.get(null); if (suspendedHosts == null) { diff --git a/packages/backend/src/queue/processors/inbox.ts b/packages/backend/src/queue/processors/inbox.ts index 198dde6050..422632059a 100644 --- a/packages/backend/src/queue/processors/inbox.ts +++ b/packages/backend/src/queue/processors/inbox.ts @@ -39,6 +39,11 @@ export default async (job: Bull.Job): Promise => { return `Blocked request: ${host}`; } + // 非公開モードなら許可なインスタンスのみ + if (meta.privateMode && !meta.allowedHosts.includes(host)) { + return `Blocked request: ${host}`; + } + const keyIdLower = signature.keyId.toLowerCase(); if (keyIdLower.startsWith('acct:')) { return `Old keyId is no longer supported. ${keyIdLower}`; diff --git a/packages/backend/src/remote/activitypub/check-fetch.ts b/packages/backend/src/remote/activitypub/check-fetch.ts new file mode 100644 index 0000000000..8a53396b6d --- /dev/null +++ b/packages/backend/src/remote/activitypub/check-fetch.ts @@ -0,0 +1,73 @@ +import config from '@/config/index.js'; +import { IncomingMessage } from 'http'; +import { fetchMeta } from '@/misc/fetch-meta.js'; +import httpSignature from '@peertube/http-signature'; +import { URL } from 'url'; +import { toPuny } from '@/misc/convert-host.js'; +import DbResolver from '@/remote/activitypub/db-resolver.js'; +import { getApId } from '@/remote/activitypub/type.js'; + + +export default async function checkFetch(req: IncomingMessage): Promise { + const meta = await fetchMeta(); + if (meta.secureMode || meta.privateMode) { + let signature; + + try { + signature = httpSignature.parseRequest(req, { 'headers': [] }); + } catch (e) { + return 401; + } + + const keyId = new URL(signature.keyId); + const host = toPuny(keyId.hostname); + + if (meta.blockedHosts.includes(host)) { + return 403; + } + + if (meta.privateMode && host !== config.host && !meta.allowedHosts.includes(host)) { + return 403; + } + + const keyIdLower = signature.keyId.toLowerCase(); + if (keyIdLower.startsWith('acct:')) { + // Old keyId is no longer supported. + return 401; + } + + const dbResolver = new DbResolver(); + + // HTTP-Signature keyIdを元にDBから取得 + let authUser = await dbResolver.getAuthUserFromKeyId(signature.keyId); + + // keyIdでわからなければ、resolveしてみる + if (authUser == null) { + try { + keyId.hash = ''; + authUser = await dbResolver.getAuthUserFromApId(getApId(keyId.toString())); + } catch (e) { + // できなければ駄目 + return 403; + } + } + + // publicKey がなくても終了 + if (authUser?.key == null) { + return 403; + } + + // もう一回チェック + if (authUser.user.host !== host) { + return 403; + } + + // HTTP-Signatureの検証 + const httpSignatureValidated = httpSignature.verifySignature(signature, authUser.key.keyPem); + + if (!httpSignatureValidated) { + return 403; + } + } + return 200; +} diff --git a/packages/backend/src/remote/activitypub/resolver.ts b/packages/backend/src/remote/activitypub/resolver.ts index 2f9af43c0c..e89a7b21a7 100644 --- a/packages/backend/src/remote/activitypub/resolver.ts +++ b/packages/backend/src/remote/activitypub/resolver.ts @@ -72,6 +72,10 @@ export default class Resolver { throw new Error('Instance is blocked'); } + if (meta.privateMode && config.host !== host && !meta.allowedHosts.includes(host)) { + throw new Error('Instance is not allowed'); + } + if (config.signToActivityPubGet && !this.user) { this.user = await getInstanceActor(); } diff --git a/packages/backend/src/server/activitypub.ts b/packages/backend/src/server/activitypub.ts index cd5f917c40..250a39bf04 100644 --- a/packages/backend/src/server/activitypub.ts +++ b/packages/backend/src/server/activitypub.ts @@ -12,12 +12,15 @@ import Followers from './activitypub/followers.js'; import Following from './activitypub/following.js'; import Featured from './activitypub/featured.js'; import { inbox as processInbox } from '@/queue/index.js'; -import { isSelfHost } from '@/misc/convert-host.js'; +import { isSelfHost, toPuny } from '@/misc/convert-host.js'; import { Notes, Users, Emojis, NoteReactions } from '@/models/index.js'; import { ILocalUser, User } from '@/models/entities/user.js'; import { In, IsNull, Not } from 'typeorm'; import { renderLike } from '@/remote/activitypub/renderer/like.js'; import { getUserKeypair } from '@/misc/keypair-store.js'; +import checkFetch from '@/remote/activitypub/check-fetch.js'; +import { getInstanceActor } from '@/services/instance-actor.js'; +import { fetchMeta } from '@/misc/fetch-meta.js'; import renderFollow from '@/remote/activitypub/renderer/follow.js'; // Init router @@ -66,6 +69,12 @@ router.post('/users/:user/inbox', json(), inbox); router.get('/notes/:note', async (ctx, next) => { if (!isActivityPubReq(ctx)) return await next(); + const verify = await checkFetch(ctx.req); + if (verify != 200) { + ctx.status = verify; + return; + } + const note = await Notes.findOneBy({ id: ctx.params.note, visibility: In(['public' as const, 'home' as const]), @@ -88,12 +97,24 @@ router.get('/notes/:note', async (ctx, next) => { } ctx.body = renderActivity(await renderNote(note, false)); - ctx.set('Cache-Control', 'public, max-age=180'); + + const meta = await fetchMeta(); + if (meta.secureMode || meta.privateMode) { + ctx.set('Cache-Control', 'private, max-age=0, must-revalidate'); + } else { + ctx.set('Cache-Control', 'public, max-age=180'); + } setResponseType(ctx); }); // note activity router.get('/notes/:note/activity', async ctx => { + const verify = await checkFetch(ctx.req); + if (verify != 200) { + ctx.status = verify; + return; + } + const note = await Notes.findOneBy({ id: ctx.params.note, userHost: IsNull(), @@ -107,7 +128,12 @@ router.get('/notes/:note/activity', async ctx => { } ctx.body = renderActivity(await packActivity(note)); - ctx.set('Cache-Control', 'public, max-age=180'); + const meta = await fetchMeta(); + if (meta.secureMode || meta.privateMode) { + ctx.set('Cache-Control', 'private, max-age=0, must-revalidate'); + } else { + ctx.set('Cache-Control', 'public, max-age=180'); + } setResponseType(ctx); }); @@ -125,6 +151,20 @@ router.get('/users/:user/collections/featured', Featured); // publickey router.get('/users/:user/publickey', async ctx => { + const instanceActor = await getInstanceActor(); + if (ctx.params.user === instanceActor.id) { + ctx.body = renderActivity(renderKey(instanceActor, await getUserKeypair(instanceActor.id))); + ctx.set('Cache-Control', 'public, max-age=180'); + setResponseType(ctx); + return; + } + + const verify = await checkFetch(ctx.req); + if (verify != 200) { + ctx.status = verify; + return; + } + const userId = ctx.params.user; const user = await Users.findOneBy({ @@ -141,7 +181,12 @@ router.get('/users/:user/publickey', async ctx => { if (Users.isLocalUser(user)) { ctx.body = renderActivity(renderKey(user, keypair)); - ctx.set('Cache-Control', 'public, max-age=180'); + const meta = await fetchMeta(); + if (meta.secureMode || meta.privateMode) { + ctx.set('Cache-Control', 'private, max-age=0, must-revalidate'); + } else { + ctx.set('Cache-Control', 'public, max-age=180'); + } setResponseType(ctx); } else { ctx.status = 400; @@ -156,13 +201,30 @@ async function userInfo(ctx: Router.RouterContext, user: User | null) { } ctx.body = renderActivity(await renderPerson(user as ILocalUser)); - ctx.set('Cache-Control', 'public, max-age=180'); + const meta = await fetchMeta(); + if (meta.secureMode || meta.privateMode) { + ctx.set('Cache-Control', 'private, max-age=0, must-revalidate'); + } else { + ctx.set('Cache-Control', 'public, max-age=180'); + } setResponseType(ctx); } router.get('/users/:user', async (ctx, next) => { if (!isActivityPubReq(ctx)) return await next(); + const instanceActor = await getInstanceActor(); + if (ctx.params.user === instanceActor.id) { + await userInfo(ctx, instanceActor); + return; + } + + const verify = await checkFetch(ctx.req); + if (verify != 200) { + ctx.status = verify; + return; + } + const userId = ctx.params.user; const user = await Users.findOneBy({ @@ -177,6 +239,18 @@ router.get('/users/:user', async (ctx, next) => { router.get('/@:user', async (ctx, next) => { if (!isActivityPubReq(ctx)) return await next(); + if (ctx.params.user === 'instance.actor') { + const instanceActor = await getInstanceActor(); + await userInfo(ctx, instanceActor); + return; + } + + const verify = await checkFetch(ctx.req); + if (verify != 200) { + ctx.status = verify; + return; + } + const user = await Users.findOneBy({ usernameLower: ctx.params.user.toLowerCase(), host: IsNull(), @@ -185,10 +259,21 @@ router.get('/@:user', async (ctx, next) => { await userInfo(ctx, user); }); + +router.get('/actor', async (ctx, next) => { + const instanceActor = await getInstanceActor(); + await userInfo(ctx, instanceActor); +}); //#endregion // emoji router.get('/emojis/:emoji', async ctx => { + const verify = await checkFetch(ctx.req); + if (verify != 200) { + ctx.status = verify; + return; + } + const emoji = await Emojis.findOneBy({ host: IsNull(), name: ctx.params.emoji, @@ -200,12 +285,23 @@ router.get('/emojis/:emoji', async ctx => { } ctx.body = renderActivity(await renderEmoji(emoji)); - ctx.set('Cache-Control', 'public, max-age=180'); + const meta = await fetchMeta(); + if (meta.secureMode || meta.privateMode) { + ctx.set('Cache-Control', 'private, max-age=0, must-revalidate'); + } else { + ctx.set('Cache-Control', 'public, max-age=180'); + } setResponseType(ctx); }); // like router.get('/likes/:like', async ctx => { + const verify = await checkFetch(ctx.req); + if (verify != 200) { + ctx.status = verify; + return; + } + const reaction = await NoteReactions.findOneBy({ id: ctx.params.like }); if (reaction == null) { @@ -221,12 +317,22 @@ router.get('/likes/:like', async ctx => { } ctx.body = renderActivity(await renderLike(reaction, note)); - ctx.set('Cache-Control', 'public, max-age=180'); + const meta = await fetchMeta(); + if (meta.secureMode || meta.privateMode) { + ctx.set('Cache-Control', 'private, max-age=0, must-revalidate'); + } else { + ctx.set('Cache-Control', 'public, max-age=180'); + } setResponseType(ctx); }); // follow router.get('/follows/:follower/:followee', async ctx => { + const verify = await checkFetch(ctx.req); + if (verify != 200) { + ctx.status = verify; + return; + } // This may be used before the follow is completed, so we do not // check if the following exists. @@ -247,7 +353,12 @@ router.get('/follows/:follower/:followee', async ctx => { } ctx.body = renderActivity(renderFollow(follower, followee)); - ctx.set('Cache-Control', 'public, max-age=180'); + const meta = await fetchMeta(); + if (meta.secureMode || meta.privateMode) { + ctx.set('Cache-Control', 'private, max-age=0, must-revalidate'); + } else { + ctx.set('Cache-Control', 'public, max-age=180'); + } setResponseType(ctx); }); diff --git a/packages/backend/src/server/activitypub/featured.ts b/packages/backend/src/server/activitypub/featured.ts index c03fd1049f..87e4f83203 100644 --- a/packages/backend/src/server/activitypub/featured.ts +++ b/packages/backend/src/server/activitypub/featured.ts @@ -6,8 +6,16 @@ import { setResponseType } from '../activitypub.js'; import renderNote from '@/remote/activitypub/renderer/note.js'; import { Users, Notes, UserNotePinings } from '@/models/index.js'; import { IsNull } from 'typeorm'; +import checkFetch from '@/remote/activitypub/check-fetch.js'; +import { fetchMeta } from '@/misc/fetch-meta.js'; export default async (ctx: Router.RouterContext) => { + const verify = await checkFetch(ctx.req); + if (verify != 200) { + ctx.status = verify; + return; + } + const userId = ctx.params.user; const user = await Users.findOneBy({ @@ -36,6 +44,12 @@ export default async (ctx: Router.RouterContext) => { ); ctx.body = renderActivity(rendered); - ctx.set('Cache-Control', 'public, max-age=180'); + + const meta = await fetchMeta(); + if (meta.secureMode || meta.privateMode) { + ctx.set('Cache-Control', 'private, max-age=0, must-revalidate'); + } else { + ctx.set('Cache-Control', 'public, max-age=180'); + } setResponseType(ctx); }; diff --git a/packages/backend/src/server/activitypub/followers.ts b/packages/backend/src/server/activitypub/followers.ts index beb48713a6..833f0e77f6 100644 --- a/packages/backend/src/server/activitypub/followers.ts +++ b/packages/backend/src/server/activitypub/followers.ts @@ -9,8 +9,16 @@ import renderFollowUser from '@/remote/activitypub/renderer/follow-user.js'; import { Users, Followings, UserProfiles } from '@/models/index.js'; import { Following } from '@/models/entities/following.js'; import { setResponseType } from '../activitypub.js'; +import checkFetch from '@/remote/activitypub/check-fetch.js'; +import { fetchMeta } from '@/misc/fetch-meta.js'; export default async (ctx: Router.RouterContext) => { + const verify = await checkFetch(ctx.req); + if (verify != 200) { + ctx.status = verify; + return; + } + const userId = ctx.params.user; const cursor = ctx.request.query.cursor; @@ -89,7 +97,12 @@ export default async (ctx: Router.RouterContext) => { // index page const rendered = renderOrderedCollection(partOf, user.followersCount, `${partOf}?page=true`); ctx.body = renderActivity(rendered); - ctx.set('Cache-Control', 'public, max-age=180'); setResponseType(ctx); } + const meta = await fetchMeta(); + if (meta.secureMode || meta.privateMode) { + ctx.set('Cache-Control', 'private, max-age=0, must-revalidate'); + } else { + ctx.set('Cache-Control', 'public, max-age=180'); + } }; diff --git a/packages/backend/src/server/activitypub/following.ts b/packages/backend/src/server/activitypub/following.ts index 3a25a6316c..f843d79f09 100644 --- a/packages/backend/src/server/activitypub/following.ts +++ b/packages/backend/src/server/activitypub/following.ts @@ -9,8 +9,16 @@ import renderFollowUser from '@/remote/activitypub/renderer/follow-user.js'; import { Users, Followings, UserProfiles } from '@/models/index.js'; import { Following } from '@/models/entities/following.js'; import { setResponseType } from '../activitypub.js'; +import checkFetch from '@/remote/activitypub/check-fetch.js'; +import { fetchMeta } from '@/misc/fetch-meta.js'; export default async (ctx: Router.RouterContext) => { + const verify = await checkFetch(ctx.req); + if (verify != 200) { + ctx.status = verify; + return; + } + const userId = ctx.params.user; const cursor = ctx.request.query.cursor; @@ -89,7 +97,12 @@ export default async (ctx: Router.RouterContext) => { // index page const rendered = renderOrderedCollection(partOf, user.followingCount, `${partOf}?page=true`); ctx.body = renderActivity(rendered); - ctx.set('Cache-Control', 'public, max-age=180'); setResponseType(ctx); } + const meta = await fetchMeta(); + if (meta.secureMode || meta.privateMode) { + ctx.set('Cache-Control', 'private, max-age=0, must-revalidate'); + } else { + ctx.set('Cache-Control', 'public, max-age=180'); + } }; diff --git a/packages/backend/src/server/activitypub/outbox.ts b/packages/backend/src/server/activitypub/outbox.ts index 7a2586998a..4a6b5caff6 100644 --- a/packages/backend/src/server/activitypub/outbox.ts +++ b/packages/backend/src/server/activitypub/outbox.ts @@ -13,8 +13,16 @@ import { Users, Notes } from '@/models/index.js'; import { Note } from '@/models/entities/note.js'; import { makePaginationQuery } from '../api/common/make-pagination-query.js'; import { setResponseType } from '../activitypub.js'; +import checkFetch from '@/remote/activitypub/check-fetch.js'; +import { fetchMeta } from '@/misc/fetch-meta.js'; export default async (ctx: Router.RouterContext) => { + const verify = await checkFetch(ctx.req); + if (verify != 200) { + ctx.status = verify; + return; + } + const userId = ctx.params.user; const sinceId = ctx.request.query.since_id; @@ -89,9 +97,15 @@ export default async (ctx: Router.RouterContext) => { `${partOf}?page=true&since_id=000000000000000000000000`, ); ctx.body = renderActivity(rendered); - ctx.set('Cache-Control', 'public, max-age=180'); + setResponseType(ctx); } + const meta = await fetchMeta(); + if (meta.secureMode || meta.privateMode) { + ctx.set('Cache-Control', 'private, max-age=0, must-revalidate'); + } else { + ctx.set('Cache-Control', 'public, max-age=180'); + } }; /** diff --git a/packages/backend/src/server/api/endpoints/meta.ts b/packages/backend/src/server/api/endpoints/meta.ts index 5b624842c3..ca6b471d59 100644 --- a/packages/backend/src/server/api/endpoints/meta.ts +++ b/packages/backend/src/server/api/endpoints/meta.ts @@ -291,6 +291,16 @@ export const meta = { }, }, }, + secureMode: { + type: 'boolean', + optional: true, nullable: false, + default: false, + }, + privateMode: { + type: 'boolean', + optional: true, nullable: false, + default: false, + }, }, }, } as const; @@ -326,7 +336,7 @@ export default define(meta, paramDef, async (ps, me) => { expiresAt: MoreThan(new Date()), }, }); - + // TODO: add secure mode, etc const response: any = { maintainerName: instance.maintainerName, maintainerEmail: instance.maintainerEmail,