CVE-2023-49095 (TypeScript, published 2023-11-30T07:15Z)
Description: 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.
Severity: nan (CVSS v3 base score: 7.5)
Weakness: CWE-20 \u2014 Improper Input Validation
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.
Repository: https://github.com/nexryai/nexkey
Files changed:
packages/backend/src/server/activitypub.ts
Commit message: Security Hot fix: GHSA-3f39-6537-3cgc


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");