hotfix(backend): GHSA-qqrm-9grj-6v32 (MisskeyIO#460)
* hotfix(backend): GHSA-qqrm-9grj-6v32
Cherry-picked from 9a70ce8f5e
Co-authored-by: tamaina <tamaina@hotmail.co.jp>
* security fix: regexp
Co-authored-by: Ry0taK <49341894+Ry0taK@users.noreply.github.com>
---------
Co-authored-by: tamaina <tamaina@hotmail.co.jp>
Co-authored-by: Ry0taK <49341894+Ry0taK@users.noreply.github.com>
			
			
This commit is contained in:
		| @@ -14,9 +14,16 @@ import { DI } from '@/di-symbols.js'; | |||||||
| import type { Config } from '@/config.js'; | import type { Config } from '@/config.js'; | ||||||
| import { StatusError } from '@/misc/status-error.js'; | import { StatusError } from '@/misc/status-error.js'; | ||||||
| import { bindThis } from '@/decorators.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 { Response } from 'node-fetch'; | ||||||
| import type { URL } from 'node:url'; | import type { URL } from 'node:url'; | ||||||
|  |  | ||||||
|  | export type HttpRequestSendOptions = { | ||||||
|  | 	throwErrorWhenResponseNotOk: boolean; | ||||||
|  | 	validators?: ((res: Response) => void)[]; | ||||||
|  | }; | ||||||
|  |  | ||||||
| @Injectable() | @Injectable() | ||||||
| export class HttpRequestService { | export class HttpRequestService { | ||||||
| 	/** | 	/** | ||||||
| @@ -104,6 +111,23 @@ export class HttpRequestService { | |||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
|  | 	@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 | 	@bindThis | ||||||
| 	public async getJson<T = unknown>(url: string, accept = 'application/json, */*', headers?: Record<string, string>): Promise<T> { | 	public async getJson<T = unknown>(url: string, accept = 'application/json, */*', headers?: Record<string, string>): Promise<T> { | ||||||
| 		const res = await this.send(url, { | 		const res = await this.send(url, { | ||||||
| @@ -132,17 +156,20 @@ export class HttpRequestService { | |||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	@bindThis | 	@bindThis | ||||||
| 	public async send(url: string, args: { | 	public async send( | ||||||
| 		method?: string, | 		url: string, | ||||||
| 		body?: string, | 		args: { | ||||||
| 		headers?: Record<string, string>, | 			method?: string, | ||||||
| 		timeout?: number, | 			body?: string, | ||||||
| 		size?: number, | 			headers?: Record<string, string>, | ||||||
| 	} = {}, extra: { | 			timeout?: number, | ||||||
| 		throwErrorWhenResponseNotOk: boolean; | 			size?: number, | ||||||
| 	} = { | 		} = {}, | ||||||
| 		throwErrorWhenResponseNotOk: true, | 		extra: HttpRequestSendOptions = { | ||||||
| 	}): Promise<Response> { | 			throwErrorWhenResponseNotOk: true, | ||||||
|  | 			validators: [], | ||||||
|  | 		}, | ||||||
|  | 	): Promise<Response> { | ||||||
| 		const timeout = args.timeout ?? 5000; | 		const timeout = args.timeout ?? 5000; | ||||||
|  |  | ||||||
| 		const controller = new AbortController(); | 		const controller = new AbortController(); | ||||||
| @@ -169,6 +196,12 @@ export class HttpRequestService { | |||||||
| 			throw new StatusError(`${res.status} ${res.statusText}`, res.status, res.statusText); | 			throw new StatusError(`${res.status} ${res.statusText}`, res.status, res.statusText); | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
|  | 		if (res.ok) { | ||||||
|  | 			for (const validator of (extra.validators ?? [])) { | ||||||
|  | 				validator(res); | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  |  | ||||||
| 		return res; | 		return res; | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
|   | |||||||
| @@ -14,6 +14,7 @@ import { HttpRequestService } from '@/core/HttpRequestService.js'; | |||||||
| import { LoggerService } from '@/core/LoggerService.js'; | import { LoggerService } from '@/core/LoggerService.js'; | ||||||
| import { bindThis } from '@/decorators.js'; | import { bindThis } from '@/decorators.js'; | ||||||
| import type Logger from '@/logger.js'; | import type Logger from '@/logger.js'; | ||||||
|  | import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js'; | ||||||
|  |  | ||||||
| type Request = { | type Request = { | ||||||
| 	url: string; | 	url: string; | ||||||
| @@ -70,7 +71,7 @@ export class ApRequestCreator { | |||||||
| 			url: u.href, | 			url: u.href, | ||||||
| 			method: 'GET', | 			method: 'GET', | ||||||
| 			headers: this.#objectAssignWithLcKey({ | 			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(), | 				'Date': new Date().toUTCString(), | ||||||
| 				'Host': new URL(args.url).host, | 				'Host': new URL(args.url).host, | ||||||
| 			}, args.additionalHeaders), | 			}, args.additionalHeaders), | ||||||
| @@ -195,6 +196,9 @@ export class ApRequestService { | |||||||
| 		const res = await this.httpRequestService.send(url, { | 		const res = await this.httpRequestService.send(url, { | ||||||
| 			method: req.request.method, | 			method: req.request.method, | ||||||
| 			headers: req.request.headers, | 			headers: req.request.headers, | ||||||
|  | 		}, { | ||||||
|  | 			throwErrorWhenResponseNotOk: true, | ||||||
|  | 			validators: [validateContentTypeSetAsActivityPub], | ||||||
| 		}); | 		}); | ||||||
|  |  | ||||||
| 		return await res.json(); | 		return await res.json(); | ||||||
|   | |||||||
| @@ -105,7 +105,7 @@ export class Resolver { | |||||||
|  |  | ||||||
| 		const object = (this.user | 		const object = (this.user | ||||||
| 			? await this.apRequestService.signedGet(value, this.user) as IObject | 			? 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 ( | 		if ( | ||||||
| 			Array.isArray(object['@context']) ? | 			Array.isArray(object['@context']) ? | ||||||
|   | |||||||
| @@ -8,6 +8,7 @@ import { Injectable } from '@nestjs/common'; | |||||||
| import { HttpRequestService } from '@/core/HttpRequestService.js'; | import { HttpRequestService } from '@/core/HttpRequestService.js'; | ||||||
| import { bindThis } from '@/decorators.js'; | import { bindThis } from '@/decorators.js'; | ||||||
| import { CONTEXTS } from './misc/contexts.js'; | import { CONTEXTS } from './misc/contexts.js'; | ||||||
|  | import { validateContentTypeSetAsJsonLD } from './misc/validator.js'; | ||||||
| import type { JsonLdDocument } from 'jsonld'; | import type { JsonLdDocument } from 'jsonld'; | ||||||
| import type { JsonLd, RemoteDocument } from 'jsonld/jsonld-spec.js'; | import type { JsonLd, RemoteDocument } from 'jsonld/jsonld-spec.js'; | ||||||
|  |  | ||||||
| @@ -133,7 +134,10 @@ class LdSignature { | |||||||
| 				}, | 				}, | ||||||
| 				timeout: this.loderTimeout, | 				timeout: this.loderTimeout, | ||||||
| 			}, | 			}, | ||||||
| 			{ throwErrorWhenResponseNotOk: false }, | 			{ | ||||||
|  | 				throwErrorWhenResponseNotOk: false, | ||||||
|  | 				validators: [validateContentTypeSetAsJsonLD], | ||||||
|  | 			}, | ||||||
| 		).then(res => { | 		).then(res => { | ||||||
| 			if (!res.ok) { | 			if (!res.ok) { | ||||||
| 				throw new Error(`${res.status} ${res.statusText}`); | 				throw new Error(`${res.status} ${res.statusText}`); | ||||||
|   | |||||||
							
								
								
									
										39
									
								
								packages/backend/src/core/activitypub/misc/validator.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								packages/backend/src/core/activitypub/misc/validator.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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 = /^\s*(application|text)\/[a-zA-Z0-9\.\-\+]+\+json\s*(;|$)/; | ||||||
|  |  | ||||||
|  | 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'); | ||||||
|  | } | ||||||
							
								
								
									
										40
									
								
								packages/backend/test/e2e/fetch-validate-ap-deny.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								packages/backend/test/e2e/fetch-validate-ap-deny.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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'); | ||||||
|  | 	}); | ||||||
|  | }); | ||||||
| @@ -203,7 +203,7 @@ describe('ActivityPub', () => { | |||||||
|  |  | ||||||
| 	describe('Renderer', () => { | 	describe('Renderer', () => { | ||||||
| 		test('Render an announce with visibility: followers', () => { | 		test('Render an announce with visibility: followers', () => { | ||||||
| 			rendererService.renderAnnounce(null, { | 			rendererService.renderAnnounce('https://example.com/notes/00example', { | ||||||
| 				id: genAidx(Date.now()), | 				id: genAidx(Date.now()), | ||||||
| 				visibility: 'followers', | 				visibility: 'followers', | ||||||
| 			} as MiNote); | 			} as MiNote); | ||||||
|   | |||||||
| @@ -15,6 +15,7 @@ import { JSDOM } from 'jsdom'; | |||||||
| import * as Redis from 'ioredis'; | import * as Redis from 'ioredis'; | ||||||
| import { DEFAULT_POLICIES } from '@/core/RoleService.js'; | import { DEFAULT_POLICIES } from '@/core/RoleService.js'; | ||||||
| import { Packed } from '@/misc/json-schema.js'; | import { Packed } from '@/misc/json-schema.js'; | ||||||
|  | import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js'; | ||||||
| import { entities } from '@/postgres.js'; | import { entities } from '@/postgres.js'; | ||||||
| import { loadConfig } from '@/config.js'; | import { loadConfig } from '@/config.js'; | ||||||
| import type * as misskey from 'misskey-js'; | import type * as misskey from 'misskey-js'; | ||||||
| @@ -328,7 +329,6 @@ export const uploadFile = async (user?: UserToken, { path, name, blob }: UploadO | |||||||
| 	}); | 	}); | ||||||
|  |  | ||||||
| 	const body = res.status !== 204 ? await res.json() as misskey.Endpoints['drive/files/create']['res'] : null; | 	const body = res.status !== 204 ? await res.json() as misskey.Endpoints['drive/files/create']['res'] : null; | ||||||
|  |  | ||||||
| 	return { | 	return { | ||||||
| 		status: res.status, | 		status: res.status, | ||||||
| 		headers: res.headers, | 		headers: res.headers, | ||||||
| @@ -477,6 +477,14 @@ export const simpleGet = async (path: string, accept = '*/*', cookie: any = unde | |||||||
| 		'text/html; charset=utf-8', | 		'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 = | 	const body = | ||||||
| 		jsonTypes.includes(res.headers.get('content-type') ?? '') ? await res.json() : | 		jsonTypes.includes(res.headers.get('content-type') ?? '') ? await res.json() : | ||||||
| 		htmlTypes.includes(res.headers.get('content-type') ?? '') ? new JSDOM(await res.text()) : | 		htmlTypes.includes(res.headers.get('content-type') ?? '') ? new JSDOM(await res.text()) : | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user
	 まっちゃとーにゅ
					まっちゃとーにゅ