CVE-2024-25636 (TypeScript, published 2024-02-19T20:15Z)
Description: 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.
Severity: nan (CVSS v3 base score: )
Weakness: CWE-434 \u2014 Unrestricted Upload of File with Dangerous Type
The product allows the upload or transfer of dangerous file types that are automatically processed within its environment.
Repository: https://github.com/misskey-dev/misskey
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 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;

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