mirror of
https://github.com/misskey-dev/misskey.git
synced 2026-06-25 17:10:43 +00:00
Merge d198aa0053 into 079ec865e0
This commit is contained in:
commit
24a6f6a645
7 changed files with 213 additions and 10 deletions
|
|
@ -82,9 +82,26 @@ export const FILE_TYPE_BROWSERSAFE = [
|
|||
// backward compatibility
|
||||
'audio/x-flac',
|
||||
'audio/vnd.wave',
|
||||
|
||||
// テキストはブラウザでそのまま表示する
|
||||
// charset 未指定だと iOS Safari 等で UTF-8 ファイルが文字化けするため
|
||||
// getSafeContentType 側で charset=utf-8 を補う (UTF-8 以外のテキストは依然として化ける可能性あり)
|
||||
// content sniffing による XSS を防ぐため setSafeContentTypeHeader 側で nosniff も常時付与する
|
||||
// 現代ブラウザ (Chrome 27+, Firefox 50+, Safari 7+) は text/* を HTML/JS としてスニッフィングしないが
|
||||
// nosniff は防御的多層保護として付与し続ける
|
||||
'text/plain',
|
||||
'text/csv',
|
||||
];
|
||||
/*
|
||||
https://github.com/sindresorhus/file-type/blob/main/supported.js
|
||||
https://github.com/sindresorhus/file-type/blob/main/core.js
|
||||
https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Containers
|
||||
*/
|
||||
|
||||
export function getBaseMime(mime: string): string {
|
||||
return mime.split(';', 1)[0].trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function isBrowserSafeMime(mime: string): boolean {
|
||||
return FILE_TYPE_BROWSERSAFE.includes(getBaseMime(mime));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import type { MiRemoteUser, MiUser } from '@/models/User.js';
|
|||
import { MiDriveFile } from '@/models/DriveFile.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
|
||||
import { FILE_TYPE_BROWSERSAFE } from '@/const.js';
|
||||
import { isBrowserSafeMime } from '@/const.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import { contentDisposition } from '@/misc/content-disposition.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
|
|
@ -165,7 +165,7 @@ export class DriveService {
|
|||
|
||||
// 拡張子からContent-Typeを設定してそうな挙動を示すオブジェクトストレージ (upcloud?) も存在するので、
|
||||
// 許可されているファイル形式でしかURLに拡張子をつけない
|
||||
if (!FILE_TYPE_BROWSERSAFE.includes(type)) {
|
||||
if (!isBrowserSafeMime(type)) {
|
||||
ext = '';
|
||||
}
|
||||
|
||||
|
|
@ -374,8 +374,12 @@ export class DriveService {
|
|||
@bindThis
|
||||
private async upload(key: string, stream: fs.ReadStream | Buffer, type: string, ext?: string | null, filename?: string) {
|
||||
if (type === 'image/apng') type = 'image/png';
|
||||
if (!FILE_TYPE_BROWSERSAFE.includes(type)) type = 'application/octet-stream';
|
||||
if (!isBrowserSafeMime(type)) type = 'application/octet-stream';
|
||||
|
||||
// NOTE: S3 から直接配信される場合、ここで設定した ContentType がそのまま使われる。
|
||||
// Misskey の FileServer 経由なら setSafeContentTypeHeader が nosniff や charset=utf-8 を付与するが、
|
||||
// S3 直接配信ではそれらのヘッダが付かない。ただし S3 URL は Misskey とは別オリジンのため
|
||||
// セッション窃取等のリスクは低い (type も file-type ライブラリ由来の bare MIME)。
|
||||
const params = {
|
||||
Bucket: this.meta.objectStorageBucket,
|
||||
Key: key,
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { contentDisposition } from '@/misc/content-disposition.js';
|
|||
import { correctFilename } from '@/misc/correct-filename.js';
|
||||
import { isMimeImage } from '@/misc/is-mime-image.js';
|
||||
import { VideoProcessingService } from '@/core/VideoProcessingService.js';
|
||||
import { attachStreamCleanup, handleRangeRequest, setFileResponseHeaders, getSafeContentType, needsCleanup } from './FileServerUtils.js';
|
||||
import { attachStreamCleanup, handleRangeRequest, setFileResponseHeaders, setSafeContentTypeHeader, needsCleanup } from './FileServerUtils.js';
|
||||
import type { FileServerFileResolver } from './FileServerFileResolver.js';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
|
||||
|
|
@ -84,7 +84,7 @@ export class FileServerDriveHandler {
|
|||
|
||||
attachStreamCleanup(image.data, file.cleanup);
|
||||
|
||||
reply.header('Content-Type', getSafeContentType(image.type));
|
||||
setSafeContentTypeHeader(reply, image.type);
|
||||
reply.header('Content-Length', file.file.size);
|
||||
reply.header('Cache-Control', 'max-age=31536000, immutable');
|
||||
reply.header('Content-Disposition',
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import * as fs from 'node:fs';
|
|||
import sharp from 'sharp';
|
||||
import { sharpBmp } from '@misskey-dev/sharp-read-bmp';
|
||||
import type { Config } from '@/config.js';
|
||||
import { FILE_TYPE_BROWSERSAFE } from '@/const.js';
|
||||
import { isBrowserSafeMime } from '@/const.js';
|
||||
import { StatusError } from '@/misc/status-error.js';
|
||||
import { contentDisposition } from '@/misc/content-disposition.js';
|
||||
import { correctFilename } from '@/misc/correct-filename.js';
|
||||
|
|
@ -157,7 +157,7 @@ export class FileServerProxyHandler {
|
|||
return this.imageProcessingService.convertToWebpStream(file.path, 2048, 2048);
|
||||
}
|
||||
|
||||
if (!file.mime.startsWith('image/') || !FILE_TYPE_BROWSERSAFE.includes(file.mime)) {
|
||||
if (!file.mime.startsWith('image/') || !isBrowserSafeMime(file.mime)) {
|
||||
throw new StatusError('Rejected type', 403, 'Rejected type');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@
|
|||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import { FILE_TYPE_BROWSERSAFE } from '@/const.js';
|
||||
import { FILE_TYPE_BROWSERSAFE, getBaseMime as _getBaseMime } from '@/const.js';
|
||||
export { getBaseMime, isBrowserSafeMime } from '@/const.js';
|
||||
import { contentDisposition } from '@/misc/content-disposition.js';
|
||||
import type { IImageStreamable } from '@/core/ImageProcessingService.js';
|
||||
import type { FastifyReply } from 'fastify';
|
||||
|
|
@ -49,11 +50,46 @@ export function attachStreamCleanup(data: IImageStreamable['data'], cleanup: ()
|
|||
}
|
||||
}
|
||||
|
||||
// RFC 7231 の MIME token 相当の文字集合 (ヘッダインジェクション防止のため厳しめに制限)
|
||||
const MIME_TOKEN_RE = /^[\w.+-]+$/;
|
||||
|
||||
/**
|
||||
* MIME タイプがブラウザセーフかどうかに応じて Content-Type を返す
|
||||
* - パラメータ (例: `; charset=utf-8`) が付いていてもベース MIME で判定する
|
||||
* - text/* で charset 未指定の場合は utf-8 を補う (iOS Safari 等の自動判定対策)
|
||||
* ただし UTF-8 以外のテキストファイル (Shift_JIS 等) は依然として化ける可能性あり (既知の制約)
|
||||
* - パラメータ key/value は MIME token に合致しないものを捨て、ヘッダインジェクションを防ぐ
|
||||
*/
|
||||
export function getSafeContentType(mime: string): string {
|
||||
return FILE_TYPE_BROWSERSAFE.includes(mime) ? mime : 'application/octet-stream';
|
||||
const segments = mime.split(';');
|
||||
const base = _getBaseMime(mime);
|
||||
if (!FILE_TYPE_BROWSERSAFE.includes(base)) return 'application/octet-stream';
|
||||
|
||||
const params: string[] = [];
|
||||
let hasCharset = false;
|
||||
for (const seg of segments.slice(1)) {
|
||||
const eq = seg.indexOf('=');
|
||||
if (eq < 0) continue;
|
||||
const k = seg.slice(0, eq).trim().toLowerCase();
|
||||
let v = seg.slice(eq + 1).trim();
|
||||
// RFC 2045 の quoted-string (例: charset="utf-8") を受け入れる
|
||||
if (v.length >= 2 && v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1);
|
||||
if (!MIME_TOKEN_RE.test(k) || !MIME_TOKEN_RE.test(v)) continue;
|
||||
if (k === 'charset') hasCharset = true;
|
||||
params.push(`${k}=${v}`);
|
||||
}
|
||||
if (base.startsWith('text/') && !hasCharset) params.push('charset=utf-8');
|
||||
|
||||
return params.length > 0 ? `${base}; ${params.join('; ')}` : base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Content-Type と X-Content-Type-Options: nosniff を一緒に設定する
|
||||
* (text/plain などを inline 配信する経路で content sniffing による XSS を防ぐため)
|
||||
*/
|
||||
export function setSafeContentTypeHeader(reply: FastifyReply, mime: string): void {
|
||||
reply.header('Content-Type', getSafeContentType(mime));
|
||||
reply.header('X-Content-Type-Options', 'nosniff');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -91,7 +127,7 @@ export function setFileResponseHeaders(
|
|||
reply: FastifyReply,
|
||||
options: FileResponseOptions,
|
||||
): void {
|
||||
reply.header('Content-Type', getSafeContentType(options.mime));
|
||||
setSafeContentTypeHeader(reply, options.mime);
|
||||
reply.header('Cache-Control', options.cacheControl ?? 'max-age=31536000, immutable');
|
||||
reply.header('Content-Disposition', contentDisposition('inline', options.filename));
|
||||
if (options.size !== undefined) {
|
||||
|
|
|
|||
|
|
@ -97,6 +97,13 @@ describe('FileServerService', () => {
|
|||
storedPaths.push(dest);
|
||||
}
|
||||
|
||||
function writeInternalBuffer(key: string, content: Buffer) {
|
||||
const dest = internalStorageService.resolvePath(key);
|
||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||
fs.writeFileSync(dest, content);
|
||||
storedPaths.push(dest);
|
||||
}
|
||||
|
||||
async function insertDriveFile(params: {
|
||||
accessKey: string;
|
||||
thumbnailAccessKey?: string | null;
|
||||
|
|
@ -319,6 +326,52 @@ describe('FileServerService', () => {
|
|||
expect(res.headers['content-type']).toBe('image/png');
|
||||
expect(res.headers['content-length']).toBe(String(dummySize));
|
||||
expect(res.headers['content-disposition'] ?? '').toMatch(/^inline;/);
|
||||
expect(res.headers['x-content-type-options']).toBe('nosniff');
|
||||
});
|
||||
|
||||
test('GET /files/:key text/plain は charset=utf-8 が補われる', async () => {
|
||||
const accessKey = randomString();
|
||||
writeInternalBuffer(accessKey, textBuffer);
|
||||
await insertDriveFile({
|
||||
accessKey,
|
||||
storedInternal: true,
|
||||
isLink: false,
|
||||
name: 'note.txt',
|
||||
type: 'text/plain',
|
||||
size: textBuffer.length,
|
||||
});
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: `/files/${accessKey}`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers['content-type']).toBe('text/plain; charset=utf-8');
|
||||
expect(res.headers['x-content-type-options']).toBe('nosniff');
|
||||
expect(res.headers['content-disposition'] ?? '').toMatch(/^inline;/);
|
||||
});
|
||||
|
||||
test('GET /files/:key text/plain で既存の charset は維持される', async () => {
|
||||
const accessKey = randomString();
|
||||
writeInternalBuffer(accessKey, textBuffer);
|
||||
await insertDriveFile({
|
||||
accessKey,
|
||||
storedInternal: true,
|
||||
isLink: false,
|
||||
name: 'note.txt',
|
||||
type: 'text/plain; charset=Shift_JIS',
|
||||
size: textBuffer.length,
|
||||
});
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: `/files/${accessKey}`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers['content-type']).toBe('text/plain; charset=Shift_JIS');
|
||||
expect(res.headers['x-content-type-options']).toBe('nosniff');
|
||||
});
|
||||
|
||||
test('GET /files/:key Range で部分配信する', async () => {
|
||||
|
|
@ -499,6 +552,7 @@ describe('FileServerService', () => {
|
|||
expect(res.headers['cache-control']).toBe('max-age=31536000, immutable');
|
||||
expect(res.headers['content-length']).toBe(String(dummyBuffer.length));
|
||||
expect(res.headers['content-disposition'] ?? '').toContain('remote.png');
|
||||
expect(res.headers['x-content-type-options']).toBe('nosniff');
|
||||
});
|
||||
|
||||
test('GET /files/:key 外部リンクを Range で部分配信する', async () => {
|
||||
|
|
|
|||
92
packages/backend/test/unit/server/FileServerUtils.ts
Normal file
92
packages/backend/test/unit/server/FileServerUtils.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { getBaseMime, getSafeContentType, isBrowserSafeMime } from '@/server/file/FileServerUtils.js';
|
||||
|
||||
describe('getBaseMime', () => {
|
||||
test('パラメータなしはそのまま小文字化', () => {
|
||||
expect(getBaseMime('image/png')).toBe('image/png');
|
||||
});
|
||||
test('パラメータ付きはベース部分のみ', () => {
|
||||
expect(getBaseMime('text/plain; charset=utf-8')).toBe('text/plain');
|
||||
});
|
||||
test('大文字は小文字に正規化される', () => {
|
||||
expect(getBaseMime('TEXT/PLAIN')).toBe('text/plain');
|
||||
});
|
||||
test('前後空白を除去する', () => {
|
||||
expect(getBaseMime(' image/png ')).toBe('image/png');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isBrowserSafeMime', () => {
|
||||
test('許可リストの MIME は true', () => {
|
||||
expect(isBrowserSafeMime('image/png')).toBe(true);
|
||||
});
|
||||
test('未許可 MIME は false', () => {
|
||||
expect(isBrowserSafeMime('application/x-msdownload')).toBe(false);
|
||||
});
|
||||
test('パラメータ付きでもベースで判定する', () => {
|
||||
expect(isBrowserSafeMime('text/plain; charset=Shift_JIS')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSafeContentType', () => {
|
||||
test('browser-safe MIME はそのまま返る', () => {
|
||||
expect(getSafeContentType('image/png')).toBe('image/png');
|
||||
});
|
||||
test('未許可 MIME は application/octet-stream', () => {
|
||||
expect(getSafeContentType('application/x-msdownload')).toBe('application/octet-stream');
|
||||
});
|
||||
test('未許可 MIME はパラメータ付きでも application/octet-stream', () => {
|
||||
expect(getSafeContentType('application/x-evil; charset=utf-8')).toBe('application/octet-stream');
|
||||
});
|
||||
test('空文字列は application/octet-stream', () => {
|
||||
expect(getSafeContentType('')).toBe('application/octet-stream');
|
||||
});
|
||||
test('text/plain は charset=utf-8 が補われる', () => {
|
||||
expect(getSafeContentType('text/plain')).toBe('text/plain; charset=utf-8');
|
||||
});
|
||||
test('text/plain で既存 charset があれば維持する (値の大小は保持)', () => {
|
||||
expect(getSafeContentType('text/plain; charset=Shift_JIS')).toBe('text/plain; charset=Shift_JIS');
|
||||
});
|
||||
test('CHARSET の大文字 key も charset 付与スキップ判定に効く', () => {
|
||||
expect(getSafeContentType('text/plain; CHARSET=utf-8')).toBe('text/plain; charset=utf-8');
|
||||
});
|
||||
test('大文字 base MIME も小文字に正規化されて許可される', () => {
|
||||
expect(getSafeContentType('TEXT/PLAIN')).toBe('text/plain; charset=utf-8');
|
||||
});
|
||||
test('text/csv も charset=utf-8 が補われる', () => {
|
||||
expect(getSafeContentType('text/csv')).toBe('text/csv; charset=utf-8');
|
||||
});
|
||||
test('text/csv で既存 charset があれば維持する', () => {
|
||||
expect(getSafeContentType('text/csv; charset=Shift_JIS')).toBe('text/csv; charset=Shift_JIS');
|
||||
});
|
||||
test('image 等の非 text/* には charset は補われない', () => {
|
||||
expect(getSafeContentType('image/png')).toBe('image/png');
|
||||
});
|
||||
test('image/png のパラメータも保持される', () => {
|
||||
expect(getSafeContentType('image/png; foo=bar')).toBe('image/png; foo=bar');
|
||||
});
|
||||
test('CRLF を含む不正なパラメータ値は黙って捨てる', () => {
|
||||
expect(getSafeContentType('text/plain; charset=utf-8\r\nX-Injected: foo'))
|
||||
.toBe('text/plain; charset=utf-8');
|
||||
});
|
||||
test('= を含まないパラメータ片は捨てる', () => {
|
||||
expect(getSafeContentType('text/plain; garbage')).toBe('text/plain; charset=utf-8');
|
||||
});
|
||||
test('空のパラメータ片は捨てる', () => {
|
||||
expect(getSafeContentType('text/plain;;charset=utf-8')).toBe('text/plain; charset=utf-8');
|
||||
});
|
||||
test('クォート付き charset 値はクォートを剥がして保持する', () => {
|
||||
expect(getSafeContentType('text/plain; charset="utf-8"')).toBe('text/plain; charset=utf-8');
|
||||
});
|
||||
test('クォート付き Shift_JIS もクォートを剥がして保持する', () => {
|
||||
expect(getSafeContentType('text/plain; charset="Shift_JIS"')).toBe('text/plain; charset=Shift_JIS');
|
||||
});
|
||||
test('クォート内に不正文字がある場合は捨てる', () => {
|
||||
expect(getSafeContentType('text/plain; charset="utf-8; evil=x"')).toBe('text/plain; charset=utf-8');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue