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:
nullobsi 2021-07-20 09:45:41 -07:00 committed by Francis Dinh
parent 8bd41f5c9e
commit 9acd4bc855
Signed by untrusted user: norm
GPG key ID: 7123E30E441E80DE
12 changed files with 287 additions and 13 deletions

View file

@ -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}',
})

View file

@ -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) {

View file

@ -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}`;

View 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;
}

View file

@ -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();
}

View file

@ -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);
});

View file

@ -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);
};

View file

@ -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');
}
};

View file

@ -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');
}
};

View file

@ -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');
}
};
/**

View file

@ -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;
/**
*
*

View file

@ -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;