fix(backend): text/plain・text/csv のインライン配信と MIME パラメータ処理の改善

- text/plain と text/csv を FILE_TYPE_BROWSERSAFE に追加し、nosniff + CSP でブラウザ表示を許可
- getSafeContentType で text/* に charset=utf-8 を自動補完 (iOS Safari 文字化け対策)
- RFC 2045 の quoted-string パラメータ (charset="Shift_JIS" 等) をサポート
- isBrowserSafeMime をパラメータ付き MIME でも正しく判定するよう改善
- S3 直接配信時のヘッダ制約についてコメントで明記

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
fruitriin 2026-06-19 15:35:18 +09:00
commit d198aa0053
5 changed files with 44 additions and 26 deletions

View file

@ -87,10 +87,21 @@ export const FILE_TYPE_BROWSERSAFE = [
// 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

@ -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';
@ -52,31 +53,16 @@ export function attachStreamCleanup(data: IImageStreamable['data'], cleanup: ()
// RFC 7231 の MIME token 相当の文字集合 (ヘッダインジェクション防止のため厳しめに制限)
const MIME_TOKEN_RE = /^[\w.+-]+$/;
/**
* MIME
*/
export function getBaseMime(mime: string): string {
return mime.split(';', 1)[0].trim().toLowerCase();
}
/**
* MIME
* ( `; charset=...` )
*/
export function isBrowserSafeMime(mime: string): boolean {
return FILE_TYPE_BROWSERSAFE.includes(getBaseMime(mime));
}
/**
* MIME Content-Type
* - (: `; charset=utf-8`) MIME
* - text/plain charset utf-8 (iOS Safari )
* - text/* charset utf-8 (iOS Safari )
* UTF-8 (Shift_JIS ) ()
* - key/value MIME token
*/
export function getSafeContentType(mime: string): string {
const segments = mime.split(';');
const base = (segments[0] ?? '').trim().toLowerCase();
const base = _getBaseMime(mime);
if (!FILE_TYPE_BROWSERSAFE.includes(base)) return 'application/octet-stream';
const params: string[] = [];
@ -85,12 +71,14 @@ export function getSafeContentType(mime: string): string {
const eq = seg.indexOf('=');
if (eq < 0) continue;
const k = seg.slice(0, eq).trim().toLowerCase();
const v = seg.slice(eq + 1).trim();
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 === 'text/plain' && !hasCharset) params.push('charset=utf-8');
if (base.startsWith('text/') && !hasCharset) params.push('charset=utf-8');
return params.length > 0 ? `${base}; ${params.join('; ')}` : base;
}

View file

@ -58,7 +58,13 @@ describe('getSafeContentType', () => {
test('大文字 base MIME も小文字に正規化されて許可される', () => {
expect(getSafeContentType('TEXT/PLAIN')).toBe('text/plain; charset=utf-8');
});
test('text/plain 以外には charset は補われない', () => {
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 のパラメータも保持される', () => {
@ -74,4 +80,13 @@ describe('getSafeContentType', () => {
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');
});
});