diff --git a/packages/backend/src/models/entities/meta.ts b/packages/backend/src/models/entities/meta.ts index 0afb7c7fc..e026f04f7 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-foundkey}', }) diff --git a/packages/backend/src/queue/processors/deliver.ts b/packages/backend/src/queue/processors/deliver.ts index 108379aa8..b937b18d5 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 426713358..44b9e3f4e 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}`; } + // Only permitted instances if in private mode. + 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 000000000..63103aa64 --- /dev/null +++ b/packages/backend/src/remote/activitypub/check-fetch.ts @@ -0,0 +1,70 @@ +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(); + + // Get user from database based on HTTP-Signature keyId + let authUser = await dbResolver.getAuthUserFromKeyId(signature.keyId); + + // If keyid is unknown, try resolving it + if (authUser == null) { + try { + keyId.hash = ''; + authUser = await dbResolver.getAuthUserFromApId(getApId(keyId.toString())); + } catch (e) { + return 403; + } + } + + if (authUser?.key == null) { + return 403; + } + + if (authUser.user.host !== host) { + return 403; + } + + // HTTP-Signature validation + 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 3cea4c44e..26cf4c6df 100644 --- a/packages/backend/src/remote/activitypub/resolver.ts +++ b/packages/backend/src/remote/activitypub/resolver.ts @@ -72,7 +72,11 @@ export default class Resolver { throw new Error('Instance is blocked'); } - if (!this.user) { + 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 f1a8f4914..dd66f29c4 100644 --- a/packages/backend/src/server/activitypub.ts +++ b/packages/backend/src/server/activitypub.ts @@ -9,11 +9,14 @@ import renderKey from '@/remote/activitypub/renderer/key.js'; import { renderPerson } from '@/remote/activitypub/renderer/person.js'; import renderEmoji from '@/remote/activitypub/renderer/emoji.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 { 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'; import Outbox, { packActivity } from './activitypub/outbox.js'; import Followers from './activitypub/followers.js'; @@ -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', 'no-store'); + } 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', 'no-store'); + } 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', 'no-store'); + } 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', 'no-store'); + } 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,6 +259,11 @@ 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 @@ -200,12 +279,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', 'no-store'); + } 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 note = await Notes.findOneBy({ id: reaction.noteId, visibility: In(['public' as const, 'home' as const]), @@ -224,12 +314,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', 'no-store'); + } 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. @@ -250,7 +350,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', 'no-store'); + } 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 09906250f..6d3680798 100644 --- a/packages/backend/src/server/activitypub/featured.ts +++ b/packages/backend/src/server/activitypub/featured.ts @@ -6,8 +6,17 @@ import renderOrderedCollection from '@/remote/activitypub/renderer/ordered-colle import renderNote from '@/remote/activitypub/renderer/note.js'; import { Users, Notes, UserNotePinings } from '@/models/index.js'; import { setResponseType } from '../activitypub.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 +45,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', 'no-store'); + } 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 beb48713a..3c8ea9458 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', 'no-store'); + } 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 3a25a6316..836cd4d26 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', 'no-store'); + } 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 d88b6c899..5dfaff366 100644 --- a/packages/backend/src/server/activitypub/outbox.ts +++ b/packages/backend/src/server/activitypub/outbox.ts @@ -14,8 +14,16 @@ import { Note } from '@/models/entities/note.js'; import { isPureRenote } from '@/misc/renote.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; @@ -90,9 +98,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', 'no-store'); + } else { + ctx.set('Cache-Control', 'public, max-age=180'); + } }; /** diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index e7563555b..c9052ed9a 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -692,6 +692,12 @@ export interface IEndpointMeta { */ readonly secure?: boolean; + /** + * If in private mode, whether credentials are required when making a request to this endpoint. + * If omitted, this is interpreted as false. + */ + readonly requireCredentialPrivateMode?: boolean; + /** * エンドポイントの種類 * パーミッションの実現に利用されます。 diff --git a/packages/backend/src/server/api/endpoints/meta.ts b/packages/backend/src/server/api/endpoints/meta.ts index cbf74ef04..205d0916f 100644 --- a/packages/backend/src/server/api/endpoints/meta.ts +++ b/packages/backend/src/server/api/endpoints/meta.ts @@ -249,6 +249,16 @@ export const meta = { }, }, }, + secureMode: { + type: 'boolean', + optional: true, nullable: false, + default: false, + }, + privateMode: { + type: 'boolean', + optional: true, nullable: false, + default: false, + }, }, }, } as const;