mirror of
https://github.com/misskey-dev/misskey.git
synced 2026-06-25 17:10:43 +00:00
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:
parent
02e6993e5b
commit
d198aa0053
5 changed files with 44 additions and 26 deletions
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue