CVE-2023-49095: Improper Input Validation (CWE-20)
value: nexkey is a microblogging platform. Insufficient validation of ActivityPub requests received in inbox could allow any user to impersonate another user in certain circumstances. This issue has been patched in version 12.122.2.
CWE description: The product receives input or data, but it does
        not validate or incorrectly validates that the input has the
        properties that are required to process the data safely and
        correctly.
CVSS v3 base score: 7.5
Published: 2023-11-30T07:15Z
Repository: https://github.com/nexryai/nexkey
Language: TypeScript
Files changed: packages/backend/src/server/activitypub.ts
Commit date: 2023-11-27 15:19:00 +0900
Commit message: Security Hot fix: GHSA-3f39-6537-3cgc

Diff: diff --git a/packages/backend/src/server/activitypub.ts b/packages/backend/src/server/activitypub.ts
index cd5f917c40..f1b546360e 100644
--- a/packages/backend/src/server/activitypub.ts
+++ b/packages/backend/src/server/activitypub.ts
@@ -1,54 +1,109 @@
-import Router from '@koa/router';
-import json from 'koa-json-body';
-import httpSignature from '@peertube/http-signature';
-
-import { renderActivity } from '@/remote/activitypub/renderer/index.js';
-import renderNote from '@/remote/activitypub/renderer/note.js';
-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 Outbox, { packActivity } from './activitypub/outbox.js';
-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 { 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 renderFollow from '@/remote/activitypub/renderer/follow.js';
+import Router from "@koa/router";
+import bodyParser from "koa-bodyparser";
+import * as crypto from 'crypto';
+import httpSignature from "@peertube/http-signature";
+
+import { In, IsNull, Not } from "typeorm";
+import config from "@/config/index.js";
+import { apLogger } from '@/remote/activitypub/logger.js';
+import { renderActivity } from "@/remote/activitypub/renderer/index.js";
+import renderNote from "@/remote/activitypub/renderer/note.js";
+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 { 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 renderFollow from "@/remote/activitypub/renderer/follow.js";
+import Featured from "./activitypub/featured.js";
+import Following from "./activitypub/following.js";
+import Followers from "./activitypub/followers.js";
+import Outbox, { packActivity } from "./activitypub/outbox.js";
+import { IActivity } from "@/remote/activitypub/type.js";
+import Koa from "koa";
+
+const logger = apLogger;
 
 // Init router
 const router = new Router();
 
 //#region Routing
 
 function inbox(ctx: Router.RouterContext) {
-	let signature;
+	// 署名の検証
+	// referenced: https://github.com/mei23/misskey/pull/4749
+	let signature: httpSignature.IParsedSignature;
 
 	try {
-		signature = httpSignature.parseRequest(ctx.req, { 'headers': [] });
+		// ヘッダーの検証はライブラリに投げる
+		signature = httpSignature.parseRequest(ctx.req, { "headers": ["(request-target)", "digest", "host", "date"] });
 	} catch (e) {
+		logger.warn("inbox: signature parse error");
+		ctx.status = 401;
+		return;
+	}
+
+	// Digestヘッダーの検証
+	const digest = ctx.req.headers.digest;
+	if (typeof digest !== "string") {
+		//logger.warn("Invalid digest (not string)")
+		ctx.status = 401;
+		return;
+	}
+
+	const match = digest.match(/^([0-9A-Za-z-]+)=(.+)$/);
+
+	if (match == null) {
+		logger.warn("Invalid digest (match == null)")
+		ctx.status = 401;
+		return;
+	}
+
+	const digestAlgo = match[1];
+	const digestExpected = match[2];
+
+	if (digestAlgo.toUpperCase() !== "SHA-256") {
+		// アルゴリズムをサポートしていない
+		logger.warn("digestAlgo is not supported")
+		ctx.status = 401;
+		return;
+	}
+
+	const digestActual = crypto.createHash("sha256").update(ctx.request.rawBody).digest("base64")
+
+	if (digestExpected !== digestActual) {
+		// 不正なダイジェスト
+		logger.warn("Invalid digest (digestExpected !== digestActual)")
 		ctx.status = 401;
 		return;
 	}
 
-	processInbox(ctx.request.body, signature);
+
+	if (!signature.params.headers.includes("host") || ctx.headers.host !== config.host) {
+		// Host not specified or not match.
+		logger.warn("Invalid host header")
+		ctx.status = 400;
+		return;
+	}
+
+	const activity = ctx.request.body as IActivity;
+	processInbox(activity, signature);
 
 	ctx.status = 202;
 }
 
-const ACTIVITY_JSON = 'application/activity+json; charset=utf-8';
-const LD_JSON = 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"; charset=utf-8';
+const ACTIVITY_JSON = "application/activity+json; charset=utf-8";
+const LD_JSON = "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"; charset=utf-8";
 
 function isActivityPubReq(ctx: Router.RouterContext) {
-	ctx.response.vary('Accept');
-	const accepted = ctx.accepts('html', ACTIVITY_JSON, LD_JSON);
-	return typeof accepted === 'string' && !accepted.match(/html/);
+	ctx.response.vary("Accept");
+	const accepted = ctx.accepts("html", ACTIVITY_JSON, LD_JSON);
+	return typeof accepted === "string" && !accepted.match(/html/);
 }
 
 export function setResponseType(ctx: Router.RouterContext) {
 	const accept = ctx.accepts(ACTIVITY_JSON, LD_JSON);
 	if (accept === LD_JSON) {
@@ -56,21 +111,37 @@ export function setResponseType(ctx: Router.RouterContext) {
 	} else {
 		ctx.response.type = ACTIVITY_JSON;
 	}
 }
 
+async function parseJsonBodyOrFail(ctx: Router.RouterContext, next: Koa.Next) {
+	const koaBodyParser = bodyParser({
+		enableTypes: ["json"],
+		detectJSON: () => true,
+	});
+
+	try {
+		await koaBodyParser(ctx, next);
+	}
+	catch {
+		ctx.status = 400;
+		return;
+	}
+}
+
+
 // inbox
-router.post('/inbox', json(), inbox);
-router.post('/users/:user/inbox', json(), inbox);
+router.post("/inbox", parseJsonBodyOrFail, inbox);
+router.post("/users/:user/inbox", parseJsonBodyOrFail, inbox);
 
 // note
-router.get('/notes/:note', async (ctx, next) => {
+router.get("/notes/:note", async (ctx, next) => {
 	if (!isActivityPubReq(ctx)) return await next();
 
 	const note = await Notes.findOneBy({
 		id: ctx.params.note,
-		visibility: In(['public' as const, 'home' as const]),
+		visibility: In(["public" as const, "home" as const]),
 		localOnly: false,
 	});
 
 	if (note == null) {
 		ctx.status = 404;
@@ -86,47 +157,47 @@ router.get('/notes/:note', async (ctx, next) => {
 		ctx.redirect(note.uri);
 		return;
 	}
 
 	ctx.body = renderActivity(await renderNote(note, false));
-	ctx.set('Cache-Control', 'public, max-age=180');
+	ctx.set("Cache-Control", "public, max-age=180");
 	setResponseType(ctx);
 });
 
 // note activity
-router.get('/notes/:note/activity', async ctx => {
+router.get("/notes/:note/activity", async ctx => {
 	const note = await Notes.findOneBy({
 		id: ctx.params.note,
 		userHost: IsNull(),
-		visibility: In(['public' as const, 'home' as const]),
+		visibility: In(["public" as const, "home" as const]),
 		localOnly: false,
 	});
 
 	if (note == null) {
 		ctx.status = 404;
 		return;
 	}
 
 	ctx.body = renderActivity(await packActivity(note));
-	ctx.set('Cache-Control', 'public, max-age=180');
+	ctx.set("Cache-Control", "public, max-age=180");
 	setResponseType(ctx);
 });
 
 // outbox
-router.get('/users/:user/outbox', Outbox);
+router.get("/users/:user/outbox", Outbox);
 
 // followers
-router.get('/users/:user/followers', Followers);
+router.get("/users/:user/followers", Followers);
 
 // following
-router.get('/users/:user/following', Following);
+router.get("/users/:user/following", Following);
 
 // featured
-router.get('/users/:user/collections/featured', Featured);
+router.get("/users/:user/collections/featured", Featured);
 
 // publickey
-router.get('/users/:user/publickey', async ctx => {
+router.get("/users/:user/publickey", async ctx => {
 	const userId = ctx.params.user;
 
 	const user = await Users.findOneBy({
 		id: userId,
 		host: IsNull(),
@@ -139,11 +210,11 @@ router.get('/users/:user/publickey', async ctx => {
 
 	const keypair = await getUserKeypair(user.id);
 
 	if (Users.isLocalUser(user)) {
 		ctx.body = renderActivity(renderKey(user, keypair));
-		ctx.set('Cache-Control', 'public, max-age=180');
+		ctx.set("Cache-Control", "public, max-age=180");
 		setResponseType(ctx);
 	} else {
 		ctx.status = 400;
 	}
 });
@@ -154,15 +225,15 @@ async function userInfo(ctx: Router.RouterContext, user: User | null) {
 		ctx.status = 404;
 		return;
 	}
 
 	ctx.body = renderActivity(await renderPerson(user as ILocalUser));
-	ctx.set('Cache-Control', 'public, max-age=180');
+	ctx.set("Cache-Control", "public, max-age=180");
 	setResponseType(ctx);
 }
 
-router.get('/users/:user', async (ctx, next) => {
+router.get("/users/:user", async (ctx, next) => {
 	if (!isActivityPubReq(ctx)) return await next();
 
 	const userId = ctx.params.user;
 
 	const user = await Users.findOneBy({
@@ -172,11 +243,11 @@ router.get('/users/:user', async (ctx, next) => {
 	});
 
 	await userInfo(ctx, user);
 });
 
-router.get('/@:user', async (ctx, next) => {
+router.get("/@:user", async (ctx, next) => {
 	if (!isActivityPubReq(ctx)) return await next();
 
 	const user = await Users.findOneBy({
 		usernameLower: ctx.params.user.toLowerCase(),
 		host: IsNull(),
@@ -186,11 +257,11 @@ router.get('/@:user', async (ctx, next) => {
 	await userInfo(ctx, user);
 });
 //#endregion
 
 // emoji
-router.get('/emojis/:emoji', async ctx => {
+router.get("/emojis/:emoji", async ctx => {
 	const emoji = await Emojis.findOneBy({
 		host: IsNull(),
 		name: ctx.params.emoji,
 	});
 
@@ -198,16 +269,16 @@ router.get('/emojis/:emoji', async ctx => {
 		ctx.status = 404;
 		return;
 	}
 
 	ctx.body = renderActivity(await renderEmoji(emoji));
-	ctx.set('Cache-Control', 'public, max-age=180');
+	ctx.set("Cache-Control", "public, max-age=180");
 	setResponseType(ctx);
 });
 
 // like
-router.get('/likes/:like', async ctx => {
+router.get("/likes/:like", async ctx => {
 	const reaction = await NoteReactions.findOneBy({ id: ctx.params.like });
 
 	if (reaction == null) {
 		ctx.status = 404;
 		return;
@@ -219,16 +290,16 @@ router.get('/likes/:like', async ctx => {
 		ctx.status = 404;
 		return;
 	}
 
 	ctx.body = renderActivity(await renderLike(reaction, note));
-	ctx.set('Cache-Control', 'public, max-age=180');
+	ctx.set("Cache-Control", "public, max-age=180");
 	setResponseType(ctx);
 });
 
 // follow
-router.get('/follows/:follower/:followee', async ctx => {
+router.get("/follows/:follower/:followee", async ctx => {
 	// This may be used before the follow is completed, so we do not
 	// check if the following exists.
 
 	const [follower, followee] = await Promise.all([
 		Users.findOneBy({
@@ -245,10 +316,10 @@ router.get('/follows/:follower/:followee', async ctx => {
 		ctx.status = 404;
 		return;
 	}
 
 	ctx.body = renderActivity(renderFollow(follower, followee));
-	ctx.set('Cache-Control', 'public, max-age=180');
+	ctx.set("Cache-Control", "public, max-age=180");
 	setResponseType(ctx);
 });
 
 export default router;

Vulnerable code: import Router from '@koa/router';
import json from 'koa-json-body';
import httpSignature from '@peertube/http-signature';

import { renderActivity } from '@/remote/activitypub/renderer/index.js';
import renderNote from '@/remote/activitypub/renderer/note.js';
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 Outbox, { packActivity } from './activitypub/outbox.js';
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 { 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 renderFollow from '@/remote/activitypub/renderer/follow.js';
	let signature;
		signature = httpSignature.parseRequest(ctx.req, { 'headers': [] });
	processInbox(ctx.request.body, signature);
const ACTIVITY_JSON = 'application/activity+json; charset=utf-8';
const LD_JSON = 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"; charset=utf-8';
	ctx.response.vary('Accept');
	const accepted = ctx.accepts('html', ACTIVITY_JSON, LD_JSON);
	return typeof accepted === 'string' && !accepted.match(/html/);
router.post('/inbox', json(), inbox);
router.post('/users/:user/inbox', json(), inbox);
router.get('/notes/:note', async (ctx, next) => {
		visibility: In(['public' as const, 'home' as const]),
	ctx.set('Cache-Control', 'public, max-age=180');
router.get('/notes/:note/activity', async ctx => {
		visibility: In(['public' as const, 'home' as const]),
	ctx.set('Cache-Control', 'public, max-age=180');
router.get('/users/:user/outbox', Outbox);
router.get('/users/:user/followers', Followers);
router.get('/users/:user/following', Following);
router.get('/users/:user/collections/featured', Featured);
router.get('/users/:user/publickey', async ctx => {
		ctx.set('Cache-Control', 'public, max-age=180');
	ctx.set('Cache-Control', 'public, max-age=180');
router.get('/users/:user', async (ctx, next) => {
router.get('/@:user', async (ctx, next) => {
router.get('/emojis/:emoji', async ctx => {
	ctx.set('Cache-Control', 'public, max-age=180');
router.get('/likes/:like', async ctx => {
	ctx.set('Cache-Control', 'public, max-age=180');
router.get('/follows/:follower/:followee', async ctx => {
	ctx.set('Cache-Control', 'public, max-age=180');
Fixed code: import Router from "@koa/router";
import bodyParser from "koa-bodyparser";
import * as crypto from 'crypto';
import httpSignature from "@peertube/http-signature";

import { In, IsNull, Not } from "typeorm";
import config from "@/config/index.js";
import { apLogger } from '@/remote/activitypub/logger.js';
import { renderActivity } from "@/remote/activitypub/renderer/index.js";
import renderNote from "@/remote/activitypub/renderer/note.js";
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 { 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 renderFollow from "@/remote/activitypub/renderer/follow.js";
import Featured from "./activitypub/featured.js";
import Following from "./activitypub/following.js";
import Followers from "./activitypub/followers.js";
import Outbox, { packActivity } from "./activitypub/outbox.js";
import { IActivity } from "@/remote/activitypub/type.js";
import Koa from "koa";

const logger = apLogger;
	// 署名の検証
	// referenced: https://github.com/mei23/misskey/pull/4749
	let signature: httpSignature.IParsedSignature;
		// ヘッダーの検証はライブラリに投げる
		signature = httpSignature.parseRequest(ctx.req, { "headers": ["(request-target)", "digest", "host", "date"] });
		logger.warn("inbox: signature parse error");
		ctx.status = 401;
		return;
	}

	// Digestヘッダーの検証
	const digest = ctx.req.headers.digest;
	if (typeof digest !== "string") {
		//logger.warn("Invalid digest (not string)")
		ctx.status = 401;
		return;
	}

	const match = digest.match(/^([0-9A-Za-z-]+)=(.+)$/);

	if (match == null) {
		logger.warn("Invalid digest (match == null)")
		ctx.status = 401;
		return;
	}

	const digestAlgo = match[1];
	const digestExpected = match[2];

	if (digestAlgo.toUpperCase() !== "SHA-256") {
		// アルゴリズムをサポートしていない
		logger.warn("digestAlgo is not supported")
		ctx.status = 401;
		return;
	}

	const digestActual = crypto.createHash("sha256").update(ctx.request.rawBody).digest("base64")

	if (digestExpected !== digestActual) {
		// 不正なダイジェスト
		logger.warn("Invalid digest (digestExpected !== digestActual)")

	if (!signature.params.headers.includes("host") || ctx.headers.host !== config.host) {
		// Host not specified or not match.
		logger.warn("Invalid host header")
		ctx.status = 400;
		return;
	}

	const activity = ctx.request.body as IActivity;
	processInbox(activity, signature);
const ACTIVITY_JSON = "application/activity+json; charset=utf-8";
const LD_JSON = "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"; charset=utf-8";
	ctx.response.vary("Accept");
	const accepted = ctx.accepts("html", ACTIVITY_JSON, LD_JSON);
	return typeof accepted === "string" && !accepted.match(/html/);
async function parseJsonBodyOrFail(ctx: Router.RouterContext, next: Koa.Next) {
	const koaBodyParser = bodyParser({
		enableTypes: ["json"],
		detectJSON: () => true,
	});

	try {
		await koaBodyParser(ctx, next);
	}
	catch {
		ctx.status = 400;
		return;
	}
}


router.post("/inbox", parseJsonBodyOrFail, inbox);
router.post("/users/:user/inbox", parseJsonBodyOrFail, inbox);
router.get("/notes/:note", async (ctx, next) => {
		visibility: In(["public" as const, "home" as const]),
	ctx.set("Cache-Control", "public, max-age=180");
router.get("/notes/:note/activity", async ctx => {
		visibility: In(["public" as const, "home" as const]),
	ctx.set("Cache-Control", "public, max-age=180");
router.get("/users/:user/outbox", Outbox);
router.get("/users/:user/followers", Followers);
router.get("/users/:user/following", Following);
router.get("/users/:user/collections/featured", Featured);
router.get("/users/:user/publickey", async ctx => {
		ctx.set("Cache-Control", "public, max-age=180");
	ctx.set("Cache-Control", "public, max-age=180");
router.get("/users/:user", async (ctx, next) => {
router.get("/@:user", async (ctx, next) => {
router.get("/emojis/:emoji", async ctx => {
	ctx.set("Cache-Control", "public, max-age=180");
router.get("/likes/:like", async ctx => {
	ctx.set("Cache-Control", "public, max-age=180");
router.get("/follows/:follower/:followee", async ctx => {
	ctx.set("Cache-Control", "public, max-age=180");