CVE-2024-25636: Unrestricted Upload of File with Dangerous Type (CWE-434)
value: Misskey is an open source, decentralized social media platform with ActivityPub support. Prior to version 2024.2.0, when fetching remote Activity Streams objects, Misskey doesn't check that the response from the remote server has a `Content-Type` header value of the Activity Streams media type, which allows a threat actor to upload a crafted Activity Streams document to a remote server and make a Misskey instance fetch it, if the remote server accepts arbitrary user uploads. The vulnerability allows a threat actor to impersonate and take over an account on a remote server that satisfies all of the following properties: allows the threat actor to register an account; accepts arbitrary user-uploaded documents and places them on the same domain as legitimate Activity Streams actors; and serves user-uploaded document in response to requests with an `Accept` header value of the Activity Streams media type. Version 2024.2.0 contains a patch for the issue.
CWE description: The product allows the upload or transfer of dangerous file types that are automatically processed within its environment.
Published: 2024-02-19T20:15Z
Repository: https://github.com/misskey-dev/misskey
Language: TypeScript
Files changed: packages/backend/src/core/HttpRequestService.ts, packages/backend/src/core/activitypub/ApRequestService.ts, packages/backend/src/core/activitypub/ApResolverService.ts, packages/backend/src/core/activitypub/LdSignatureService.ts, packages/backend/src/core/activitypub/misc/validator.ts, packages/backend/test/e2e/fetch-validate-ap-deny.ts, packages/backend/test/unit/activitypub.ts, packages/backend/test/utils.ts
Commit date: 2024-02-17 12:41:19 +0900
Commit message: Merge pull request from GHSA-qqrm-9grj-6v32

* maybe ok

* fix

* test wip

* :v:

* fix

* if (res.ok)

* validateContentTypeSetAsJsonLD

* 条件を考慮し直す

* その他の+json接尾辞が付いているメディアタイプも受け容れる

* https://github.com/misskey-dev/misskey-ghsa-qqrm-9grj-6v32/pull/1#discussion_r1490999009

* add `; profile="https://www.w3.org/ns/activitystreams"`

* application/ld+json;
Diff: diff --git a/packages/backend/src/core/HttpRequestService.ts b/packages/backend/src/core/HttpRequestService.ts
index b36b9f6e3c7..7f3cac7c580 100644
--- a/packages/backend/src/core/HttpRequestService.ts
+++ b/packages/backend/src/core/HttpRequestService.ts
@@ -12,13 +12,20 @@ import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent';
 import { Inject, Injectable } from '@nestjs/common';
 import { DI } from '@/di-symbols.js';
 import type { Config } from '@/config.js';
 import { StatusError } from '@/misc/status-error.js';
 import { bindThis } from '@/decorators.js';
+import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
+import type { IObject } from '@/core/activitypub/type.js';
 import type { Response } from 'node-fetch';
 import type { URL } from 'node:url';
 
+export type HttpRequestSendOptions = {
+	throwErrorWhenResponseNotOk: boolean;
+	validators?: ((res: Response) => void)[];
+};
+
 @Injectable()
 export class HttpRequestService {
 	/**
 	 * Get http non-proxy agent
 	 */
@@ -102,10 +109,27 @@ export class HttpRequestService {
 		} else {
 			return url.protocol === 'http:' ? this.httpAgent : this.httpsAgent;
 		}
 	}
 
+	@bindThis
+	public async getActivityJson(url: string): Promise<IObject> {
+		const res = await this.send(url, {
+			method: 'GET',
+			headers: {
+				Accept: 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
+			},
+			timeout: 5000,
+			size: 1024 * 256,
+		}, {
+			throwErrorWhenResponseNotOk: true,
+			validators: [validateContentTypeSetAsActivityPub],
+		});
+
+		return await res.json() as IObject;
+	}
+
 	@bindThis
 	public async getJson<T = unknown>(url: string, accept = 'application/json, */*', headers?: Record<string, string>): Promise<T> {
 		const res = await this.send(url, {
 			method: 'GET',
 			headers: Object.assign({
@@ -130,21 +154,24 @@ export class HttpRequestService {
 
 		return await res.text();
 	}
 
 	@bindThis
-	public async send(url: string, args: {
-		method?: string,
-		body?: string,
-		headers?: Record<string, string>,
-		timeout?: number,
-		size?: number,
-	} = {}, extra: {
-		throwErrorWhenResponseNotOk: boolean;
-	} = {
-		throwErrorWhenResponseNotOk: true,
-	}): Promise<Response> {
+	public async send(
+		url: string,
+		args: {
+			method?: string,
+			body?: string,
+			headers?: Record<string, string>,
+			timeout?: number,
+			size?: number,
+		} = {},
+		extra: HttpRequestSendOptions = {
+			throwErrorWhenResponseNotOk: true,
+			validators: [],
+		},
+	): Promise<Response> {
 		const timeout = args.timeout ?? 5000;
 
 		const controller = new AbortController();
 		setTimeout(() => {
 			controller.abort();
@@ -164,8 +191,14 @@ export class HttpRequestService {
 
 		if (!res.ok && extra.throwErrorWhenResponseNotOk) {
 			throw new StatusError(`${res.status} ${res.statusText}`, res.status, res.statusText);
 		}
 
+		if (res.ok) {
+			for (const validator of (extra.validators ?? [])) {
+				validator(res);
+			}
+		}
+
 		return res;
 	}
 }
diff --git a/packages/backend/src/core/activitypub/ApRequestService.ts b/packages/backend/src/core/activitypub/ApRequestService.ts
index 202e07814ec..93ac8ce9a74 100644
--- a/packages/backend/src/core/activitypub/ApRequestService.ts
+++ b/packages/backend/src/core/activitypub/ApRequestService.ts
@@ -12,10 +12,11 @@ import type { MiUser } from '@/models/User.js';
 import { UserKeypairService } from '@/core/UserKeypairService.js';
 import { HttpRequestService } from '@/core/HttpRequestService.js';
 import { LoggerService } from '@/core/LoggerService.js';
 import { bindThis } from '@/decorators.js';
 import type Logger from '@/logger.js';
+import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
 
 type Request = {
 	url: string;
 	method: string;
 	headers: Record<string, string>;
@@ -68,11 +69,11 @@ export class ApRequestCreator {
 
 		const request: Request = {
 			url: u.href,
 			method: 'GET',
 			headers: this.#objectAssignWithLcKey({
-				'Accept': 'application/activity+json, application/ld+json',
+				'Accept': 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
 				'Date': new Date().toUTCString(),
 				'Host': new URL(args.url).host,
 			}, args.additionalHeaders),
 		};
 
@@ -193,10 +194,13 @@ export class ApRequestService {
 		});
 
 		const res = await this.httpRequestService.send(url, {
 			method: req.request.method,
 			headers: req.request.headers,
+		}, {
+			throwErrorWhenResponseNotOk: true,
+			validators: [validateContentTypeSetAsActivityPub],
 		});
 
 		return await res.json();
 	}
 }
diff --git a/packages/backend/src/core/activitypub/ApResolverService.ts b/packages/backend/src/core/activitypub/ApResolverService.ts
index db44c042e77..bb3c40f0939 100644
--- a/packages/backend/src/core/activitypub/ApResolverService.ts
+++ b/packages/backend/src/core/activitypub/ApResolverService.ts
@@ -103,11 +103,11 @@ export class Resolver {
 			this.user = await this.instanceActorService.getInstanceActor();
 		}
 
 		const object = (this.user
 			? await this.apRequestService.signedGet(value, this.user) as IObject
-			: await this.httpRequestService.getJson(value, 'application/activity+json, application/ld+json')) as IObject;
+			: await this.httpRequestService.getActivityJson(value)) as IObject;
 
 		if (
 			Array.isArray(object['@context']) ?
 				!(object['@context'] as unknown[]).includes('https://www.w3.org/ns/activitystreams') :
 				object['@context'] !== 'https://www.w3.org/ns/activitystreams'
diff --git a/packages/backend/src/core/activitypub/LdSignatureService.ts b/packages/backend/src/core/activitypub/LdSignatureService.ts
index f958e9d16ec..9de184336f0 100644
--- a/packages/backend/src/core/activitypub/LdSignatureService.ts
+++ b/packages/backend/src/core/activitypub/LdSignatureService.ts
@@ -6,10 +6,11 @@
 import * as crypto from 'node:crypto';
 import { Injectable } from '@nestjs/common';
 import { HttpRequestService } from '@/core/HttpRequestService.js';
 import { bindThis } from '@/decorators.js';
 import { CONTEXTS } from './misc/contexts.js';
+import { validateContentTypeSetAsJsonLD } from './misc/validator.js';
 import type { JsonLdDocument } from 'jsonld';
 import type { JsonLd, RemoteDocument } from 'jsonld/jsonld-spec.js';
 
 // RsaSignature2017 based from https://github.com/transmute-industries/RsaSignature2017
 
@@ -131,11 +132,14 @@ class LdSignature {
 				headers: {
 					Accept: 'application/ld+json, application/json',
 				},
 				timeout: this.loderTimeout,
 			},
-			{ throwErrorWhenResponseNotOk: false },
+			{
+				throwErrorWhenResponseNotOk: false,
+				validators: [validateContentTypeSetAsJsonLD],
+			},
 		).then(res => {
 			if (!res.ok) {
 				throw new Error(`${res.status} ${res.statusText}`);
 			} else {
 				return res.json();
diff --git a/packages/backend/src/core/activitypub/misc/validator.ts b/packages/backend/src/core/activitypub/misc/validator.ts
new file mode 100644
index 00000000000..6ba14a222fe
--- /dev/null
+++ b/packages/backend/src/core/activitypub/misc/validator.ts
@@ -0,0 +1,39 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import type { Response } from 'node-fetch';
+
+export function validateContentTypeSetAsActivityPub(response: Response): void {
+	const contentType = (response.headers.get('content-type') ?? '').toLowerCase();
+
+	if (contentType === '') {
+		throw new Error('Validate content type of AP response: No content-type header');
+	}
+	if (
+		contentType.startsWith('application/activity+json') ||
+		(contentType.startsWith('application/ld+json;') && contentType.includes('https://www.w3.org/ns/activitystreams'))
+	) {
+		return;
+	}
+	throw new Error('Validate content type of AP response: Content type is not application/activity+json or application/ld+json');
+}
+
+const plusJsonSuffixRegex = /(application|text)\/[a-zA-Z0-9\.\-\+]+\+json/;
+
+export function validateContentTypeSetAsJsonLD(response: Response): void {
+	const contentType = (response.headers.get('content-type') ?? '').toLowerCase();
+
+	if (contentType === '') {
+		throw new Error('Validate content type of JSON LD: No content-type header');
+	}
+	if (
+		contentType.startsWith('application/ld+json') ||
+		contentType.startsWith('application/json') ||
+		plusJsonSuffixRegex.test(contentType)
+	) {
+		return;
+	}
+	throw new Error('Validate content type of JSON LD: Content type is not application/ld+json or application/json');
+}
diff --git a/packages/backend/test/e2e/fetch-validate-ap-deny.ts b/packages/backend/test/e2e/fetch-validate-ap-deny.ts
new file mode 100644
index 00000000000..434a9fe209b
--- /dev/null
+++ b/packages/backend/test/e2e/fetch-validate-ap-deny.ts
@@ -0,0 +1,40 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+process.env.NODE_ENV = 'test';
+
+import { validateContentTypeSetAsActivityPub, validateContentTypeSetAsJsonLD } from '@/core/activitypub/misc/validator.js';
+import { signup, uploadFile, relativeFetch } from '../utils.js';
+import type * as misskey from 'misskey-js';
+
+describe('validateContentTypeSetAsActivityPub/JsonLD (deny case)', () => {
+	let alice: misskey.entities.SignupResponse;
+	let aliceUploadedFile: any;
+
+	beforeAll(async () => {
+		alice = await signup({ username: 'alice' });
+		aliceUploadedFile = await uploadFile(alice);
+	}, 1000 * 60 * 2);
+
+	test('ActivityStreams: ファイルはエラーになる', async () => {
+		const res = await relativeFetch(aliceUploadedFile.webpublicUrl);
+
+		function doValidate() {
+			validateContentTypeSetAsActivityPub(res);
+		}
+
+		expect(doValidate).toThrow('Content type is not');
+	});
+
+	test('JSON-LD: ファイルはエラーになる', async () => {
+		const res = await relativeFetch(aliceUploadedFile.webpublicUrl);
+
+		function doValidate() {
+			validateContentTypeSetAsJsonLD(res);
+		}
+
+		expect(doValidate).toThrow('Content type is not');
+	});
+});
diff --git a/packages/backend/test/unit/activitypub.ts b/packages/backend/test/unit/activitypub.ts
index 88ff49b1190..b4b06b06bd0 100644
--- a/packages/backend/test/unit/activitypub.ts
+++ b/packages/backend/test/unit/activitypub.ts
@@ -201,11 +201,11 @@ describe('ActivityPub', () => {
 		});
 	});
 
 	describe('Renderer', () => {
 		test('Render an announce with visibility: followers', () => {
-			rendererService.renderAnnounce(null, {
+			rendererService.renderAnnounce('https://example.com/notes/00example', {
 				id: genAidx(Date.now()),
 				visibility: 'followers',
 			} as MiNote);
 		});
 	});
diff --git a/packages/backend/test/utils.ts b/packages/backend/test/utils.ts
index d5da8e02265..a2220ffae65 100644
--- a/packages/backend/test/utils.ts
+++ b/packages/backend/test/utils.ts
@@ -11,14 +11,15 @@ import { inspect } from 'node:util';
 import WebSocket, { ClientOptions } from 'ws';
 import fetch, { File, RequestInit } from 'node-fetch';
 import { DataSource } from 'typeorm';
 import { JSDOM } from 'jsdom';
 import { DEFAULT_POLICIES } from '@/core/RoleService.js';
+import { Packed } from '@/misc/json-schema.js';
+import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
 import { entities } from '../src/postgres.js';
 import { loadConfig } from '../src/config.js';
 import type * as misskey from 'misskey-js';
-import { Packed } from '@/misc/json-schema.js';
 
 export { server as startServer, jobQueue as startJobQueue } from '@/boot/common.js';
 
 interface UserToken {
 	token: string;
@@ -121,13 +122,13 @@ export function randomString(chars = 'abcdefghijklmnopqrstuvwxyz0123456789', len
  * @param timeout 待機ミリ秒
  */
 function timeoutPromise<T>(p: Promise<T>, timeout: number): Promise<T> {
 	return Promise.race([
 		p,
-		new Promise((reject) =>{
-			setTimeout(() => { reject(new Error('timed out')); }, timeout)
-		}) as never
+		new Promise((reject) => {
+			setTimeout(() => { reject(new Error('timed out')); }, timeout);
+		}) as never,
 	]);
 }
 
 export const signup = async (params?: Partial<misskey.Endpoints['signup']['req']>): Promise<NonNullable<misskey.Endpoints['signup']['res']>> => {
 	const q = Object.assign({
@@ -325,11 +326,10 @@ export const uploadFile = async (user?: UserToken, { path, name, blob }: UploadO
 		body: formData,
 		headers,
 	});
 
 	const body = res.status !== 204 ? await res.json() as misskey.Endpoints['drive/files/create']['res'] : null;
-
 	return {
 		status: res.status,
 		headers: res.headers,
 		body,
 	};
@@ -341,11 +341,11 @@ export const uploadUrl = async (user: UserToken, url: string): Promise<Packed<'D
 	const catcher = makeStreamCatcher(
 		user,
 		'main',
 		(msg) => msg.type === 'urlUploadFinished' && msg.body.marker === marker,
 		(msg) => msg.body.file as Packed<'DriveFile'>,
-		60 * 1000
+		60 * 1000,
 	);
 
 	await api('drive/files/upload-from-url', {
 		url,
 		marker,
@@ -432,24 +432,24 @@ export const waitFire = async (user: UserToken, channel: string, trgr: () => any
  * @param extractor 取り出し処理
  * @param timeout ミリ秒タイムアウト
  * @returns 時間内に正常に処理できた場合に通知からextractorを通した値を得る
  */
 export function makeStreamCatcher<T>(
-		user: UserToken,
-		channel: string,
-		cond: (message: Record<string, any>) => boolean,
-		extractor: (message: Record<string, any>) => T,
-		timeout = 60 * 1000): Promise<T> {
-	let ws: WebSocket
+	user: UserToken,
+	channel: string,
+	cond: (message: Record<string, any>) => boolean,
+	extractor: (message: Record<string, any>) => T,
+	timeout = 60 * 1000): Promise<T> {
+	let ws: WebSocket;
 	const p = new Promise<T>(async (resolve) => {
 		ws = await connectStream(user, channel, (msg) => {
 			if (cond(msg)) {
-				resolve(extractor(msg))
+				resolve(extractor(msg));
 			}
 		});
 	}).finally(() => {
-		ws?.close();
+		ws.close();
 	});
 
 	return timeoutPromise(p, timeout);
 }
 
@@ -474,10 +474,18 @@ export const simpleGet = async (path: string, accept = '*/*', cookie: any = unde
 	];
 	const htmlTypes = [
 		'text/html; charset=utf-8',
 	];
 
+	if (res.ok && (
+		accept.startsWith('application/activity+json') ||
+		(accept.startsWith('application/ld+json') && accept.includes('https://www.w3.org/ns/activitystreams'))
+	)) {
+		// validateContentTypeSetAsActivityPubのテストを兼ねる
+		validateContentTypeSetAsActivityPub(res);
+	}
+
 	const body =
 		jsonTypes.includes(res.headers.get('content-type') ?? '') ? await res.json() :
 		htmlTypes.includes(res.headers.get('content-type') ?? '') ? new JSDOM(await res.text()) :
 		null;
 

Vulnerable code: 	public async send(url: string, args: {
		method?: string,
		body?: string,
		headers?: Record<string, string>,
		timeout?: number,
		size?: number,
	} = {}, extra: {
		throwErrorWhenResponseNotOk: boolean;
	} = {
		throwErrorWhenResponseNotOk: true,
	}): Promise<Response> {
				'Accept': 'application/activity+json, application/ld+json',
			: await this.httpRequestService.getJson(value, 'application/activity+json, application/ld+json')) as IObject;
			{ throwErrorWhenResponseNotOk: false },
			rendererService.renderAnnounce(null, {
import { Packed } from '@/misc/json-schema.js';
		new Promise((reject) =>{
			setTimeout(() => { reject(new Error('timed out')); }, timeout)
		}) as never

		60 * 1000
		user: UserToken,
		channel: string,
		cond: (message: Record<string, any>) => boolean,
		extractor: (message: Record<string, any>) => T,
		timeout = 60 * 1000): Promise<T> {
	let ws: WebSocket
				resolve(extractor(msg))
		ws?.close();
Fixed code: import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
import type { IObject } from '@/core/activitypub/type.js';
export type HttpRequestSendOptions = {
	throwErrorWhenResponseNotOk: boolean;
	validators?: ((res: Response) => void)[];
};

	@bindThis
	public async getActivityJson(url: string): Promise<IObject> {
		const res = await this.send(url, {
			method: 'GET',
			headers: {
				Accept: 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
			},
			timeout: 5000,
			size: 1024 * 256,
		}, {
			throwErrorWhenResponseNotOk: true,
			validators: [validateContentTypeSetAsActivityPub],
		});

		return await res.json() as IObject;
	}

	public async send(
		url: string,
		args: {
			method?: string,
			body?: string,
			headers?: Record<string, string>,
			timeout?: number,
			size?: number,
		} = {},
		extra: HttpRequestSendOptions = {
			throwErrorWhenResponseNotOk: true,
			validators: [],
		},
	): Promise<Response> {
		if (res.ok) {
			for (const validator of (extra.validators ?? [])) {
				validator(res);
			}
		}

import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
				'Accept': 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
		}, {
			throwErrorWhenResponseNotOk: true,
			validators: [validateContentTypeSetAsActivityPub],
			: await this.httpRequestService.getActivityJson(value)) as IObject;
import { validateContentTypeSetAsJsonLD } from './misc/validator.js';
			{
				throwErrorWhenResponseNotOk: false,
				validators: [validateContentTypeSetAsJsonLD],
			},
/*
 * SPDX-FileCopyrightText: syuilo and misskey-project
 * SPDX-License-Identifier: AGPL-3.0-only
 */

import type { Response } from 'node-fetch';

export function validateContentTypeSetAsActivityPub(response: Response): void {
	const contentType = (response.headers.get('content-type') ?? '').toLowerCase();

	if (contentType === '') {
		throw new Error('Validate content type of AP response: No content-type header');
	}
	if (
		contentType.startsWith('application/activity+json') ||
		(contentType.startsWith('application/ld+json;') && contentType.includes('https://www.w3.org/ns/activitystreams'))
	) {
		return;
	}
	throw new Error('Validate content type of AP response: Content type is not application/activity+json or application/ld+json');
}

const plusJsonSuffixRegex = /(application|text)\/[a-zA-Z0-9\.\-\+]+\+json/;

export function validateContentTypeSetAsJsonLD(response: Response): void {
	const contentType = (response.headers.get('content-type') ?? '').toLowerCase();

	if (contentType === '') {
		throw new Error('Validate content type of JSON LD: No content-type header');
	}
	if (
		contentType.startsWith('application/ld+json') ||
		contentType.startsWith('application/json') ||
		plusJsonSuffixRegex.test(contentType)
	) {
		return;
	}
	throw new Error('Validate content type of JSON LD: Content type is not application/ld+json or application/json');
}
/*
 * SPDX-FileCopyrightText: syuilo and misskey-project
 * SPDX-License-Identifier: AGPL-3.0-only
 */

process.env.NODE_ENV = 'test';

import { validateContentTypeSetAsActivityPub, validateContentTypeSetAsJsonLD } from '@/core/activitypub/misc/validator.js';
import { signup, uploadFile, relativeFetch } from '../utils.js';
import type * as misskey from 'misskey-js';

describe('validateContentTypeSetAsActivityPub/JsonLD (deny case)', () => {
	let alice: misskey.entities.SignupResponse;
	let aliceUploadedFile: any;

	beforeAll(async () => {
		alice = await signup({ username: 'alice' });
		aliceUploadedFile = await uploadFile(alice);
	}, 1000 * 60 * 2);

	test('ActivityStreams: ファイルはエラーになる', async () => {
		const res = await relativeFetch(aliceUploadedFile.webpublicUrl);

		function doValidate() {
			validateContentTypeSetAsActivityPub(res);
		}

		expect(doValidate).toThrow('Content type is not');
	});

	test('JSON-LD: ファイルはエラーになる', async () => {
		const res = await relativeFetch(aliceUploadedFile.webpublicUrl);

		function doValidate() {
			validateContentTypeSetAsJsonLD(res);
		}

		expect(doValidate).toThrow('Content type is not');
	});
});
			rendererService.renderAnnounce('https://example.com/notes/00example', {
import { Packed } from '@/misc/json-schema.js';
import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
		new Promise((reject) => {
			setTimeout(() => { reject(new Error('timed out')); }, timeout);
		}) as never,
		60 * 1000,
	user: UserToken,
	channel: string,
	cond: (message: Record<string, any>) => boolean,
	extractor: (message: Record<string, any>) => T,
	timeout = 60 * 1000): Promise<T> {
	let ws: WebSocket;
				resolve(extractor(msg));
		ws.close();
	if (res.ok && (
		accept.startsWith('application/activity+json') ||
		(accept.startsWith('application/ld+json') && accept.includes('https://www.w3.org/ns/activitystreams'))
	)) {
		// validateContentTypeSetAsActivityPubのテストを兼ねる
		validateContentTypeSetAsActivityPub(res);
	}