forked from FoundKeyGang/FoundKey
Add Secure Mode and Private Mode
- Add instance actor - Add private mode, which uses an allowlist - Add Secure Mode, restricts access to blocked instances Co-authored-by: Francis Dinh <normandy@biribiri.dev>
This commit is contained in:
parent
8bd41f5c9e
commit
9acd4bc855
12 changed files with 287 additions and 13 deletions
|
@ -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}',
|
||||
})
|
||||
|
|
|
@ -28,6 +28,10 @@ export default async (job: Bull.Job<DeliverJobData>) => {
|
|||
return 'skip (blocked)';
|
||||
}
|
||||
|
||||
if (meta.privateMode && !meta.allowedHosts.includes(toPuny(host))) {
|
||||
return 'skip (not allowed)';
|
||||
}
|
||||
|
||||
// isSuspendedなら中断
|
||||
let suspendedHosts = suspendedHostsCache.get(null);
|
||||
if (suspendedHosts == null) {
|
||||
|
|
|
@ -39,6 +39,11 @@ export default async (job: Bull.Job<InboxJobData>): Promise<string> => {
|
|||
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}`;
|
||||
|
|
70
packages/backend/src/remote/activitypub/check-fetch.ts
Normal file
70
packages/backend/src/remote/activitypub/check-fetch.ts
Normal file
|
@ -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<number> {
|
||||
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;
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
@ -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;
|
||||
|
||||
/**
|
||||
* エンドポイントの種類
|
||||
* パーミッションの実現に利用されます。
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Reference in a new issue