This commit is contained in:
果物リン 2026-06-24 21:11:27 +00:00 committed by GitHub
commit 24a6f6a645
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 213 additions and 10 deletions

View file

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

View file

@ -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,

View file

@ -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',

View file

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

View file

@ -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) {

View file

@ -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 () => {

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