- サイバーセキュリティ 🛡️
ファイルアップロードのセキュリティ対策|実装コードと検証チェックリスト
ファイルアップロードのセキュリティ対策とは、「受け取る種類を許可リストで絞る」「中身(マジックバイト)で種類を確定する」「サイズをアプリと Web サーバーの両方で制限する」「ファイル名をサーバー側で決めて公開ディレクトリの外に保存する」「配信時に Content-Disposition と X-Content-Type-Options: nosniff を付ける」という 5 つの柱を、受信・保存・配信の各段階に分けて実装することです。 どれか 1 つで守るのではなく、段階ごとに「そこを通ったものは何が保証されているか」を決めておくと、レビューでも抜けが見つけやすくなります。
この記事では、対策の一覧表と保存・配信の設計図のあと、Node(Express 5 + multer 2 + file-type)と Python(FastAPI + filetype + Pillow)のそのまま動く検証コードを載せ、拡張子だけ .png の偽装ファイル、../ を含むファイル名、上限を超えた 3 MB、二重拡張子、script 入りの SVG を実際に送って弾かれた応答(ステータスと本文)を表で示します。nginx 側の上限とレート制限、SVG・CSV・ZIP の形式別の扱い、レビュー用チェックリスト、よくあるつまずきの「前と後」も実行結果つきです。
ファイルアップロード対策の早見表
| 観点 | こういう入力が来るとこうなる | こう実装すると防げる | どこで |
|---|---|---|---|
| 拡張子 | .php .exe .html などを受け取ると、配信先で実行・表示されうる |
許可リスト(png/jpg/webp/pdf など業務に必要な種類だけ)。拒否リストは使わない | アプリ |
| Content-Type | 申告値は curl -F "file=@x;type=image/png" のように自由に付けられる |
申告値を信用しない。参考情報としてログに残す程度にする | アプリ |
| 中身の種類 | 拡張子は .png、中身はテキストや HTML |
マジックバイト(先頭バイト列)で判定し、拡張子と一致しなければ拒否 | アプリ |
| サイズ | 上限がないとメモリ・ディスクを使い切る | アプリ(multer の limits / チャンク読みの上限)と nginx(client_max_body_size)の両方で制限 |
アプリ + nginx |
| ファイル名 | ../../etc/x.png や二重拡張子 a.php.png |
保存名はサーバー側でランダム生成(UUID)。申告名は表示用にだけ保持し、basename で切る |
アプリ |
| 保存場所 | 公開ディレクトリ(public/ や static/)に置くと URL で直接開ける |
公開ディレクトリの外(または別ホスト・オブジェクトストレージ)に保存し、配信は専用ルート経由 | アプリ + インフラ |
| 画像のメタデータ | 位置情報や機材名などの EXIF がそのまま公開される | Pillow などで再エンコードして保存(EXIF が落ち、壊れた画像も弾ける) | アプリ |
| SVG | XML なので <script> やイベント属性を含みうる |
原則受け付けない。必要なら DOMPurify で除去し、attachment + nosniff + 別ドメインで配信 |
アプリ + 配信 |
| CSV | = + - @ で始まるセルは表計算ソフトで式として評価される |
先頭に ' を付けて無効化し、出力時は全セルを引用符で囲む |
アプリ |
| ZIP | 高圧縮のファイル(zip bomb)や ../ を含むエントリ名 |
展開前に展開後サイズ・圧縮率・パスを検査し、上限超えは展開しない | アプリ |
| ウイルススキャン | 種類が正しくても内容が有害なことがある | ClamAV などで保存前にスキャン(clamd の INSTREAM)。CDR(無害化)も選択肢 |
アプリ + インフラ |
| 配信ヘッダー | ブラウザが中身を推測して HTML として表示することがある | X-Content-Type-Options: nosniff と Content-Disposition: attachment、保存時に確定した Content-Type |
配信 |
| 配信ドメイン | 同じオリジンで配信すると Cookie やセッションと同居する | 別ドメイン(files.example.net のような専用ホスト)から配信 |
インフラ |
| 認可 | URL を知っていれば誰でも取得できる | アップロードも取得もログインと所有者確認を通す。推測できない ID を使う | アプリ |
| レート制限 | 短時間に大量のアップロードが届く | nginx の limit_req やアプリ側の制限で回数を絞る |
nginx |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「ファイルアップロードのセキュリティ対策|実装コードと検証チェックリスト」
受信から配信までの設計図
要点:検証が終わるまでディスクに書かず、保存名はサーバーが決め、公開ディレクトリの外に置き、配信は別ルート(できれば別ドメイン)を通す。 この流れにしておくと、途中のどこかが抜けても他の段階で止まります。
flowchart LR
C[ブラウザ / クライアント] -->|multipart/form-data| N[nginx<br/>client_max_body_size 2m<br/>limit_req]
N --> A[アプリ<br/>認証・認可]
A --> V{検証<br/>拡張子許可リスト<br/>サイズ上限<br/>マジックバイト<br/>再エンコード}
V -->|NG| R[400 / 413 / 415 / 422<br/>一時ファイルを削除]
V -->|OK| S[(公開外の保存先<br/>uuid.png + メタデータ<br/>またはオブジェクトストレージ)]
S --> D[配信ルート /files/:id<br/>認可 → Content-Type 確定値<br/>Content-Disposition: attachment<br/>X-Content-Type-Options: nosniff]
D -->|別ドメイン files.example.net| C
Node(Express 5 + multer 2)で実装する
要点:limits でサイズと個数を先に切り、fileFilter は拡張子の一次チェックだけ、種類の確定は file-type のマジックバイト判定で行い、保存名は randomUUID() で決める。 multer は 2.x 系で 2025〜2026 年に複数の修正が出ているので、最新の 2.4 系を使います(検証:Node 22.22、express 5.2.1、multer 2.4.0、file-type 22.1.1。file-type は ESM 専用なので .mjs で書いています)。
npm install express@5 multer@2 file-type@22
// server.mjs — 実行: PORT=7650 node server.mjs
import express from "express";
import multer from "multer";
import { fileTypeFromBuffer } from "file-type";
import { randomUUID } from "node:crypto";
import { mkdir, writeFile, readFile, readdir } from "node:fs/promises";
import path from "node:path";
const app = express();
const UPLOAD_DIR = path.resolve("./private-uploads"); // express.static の外
await mkdir(UPLOAD_DIR, { recursive: true });
const ALLOWED = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp", pdf: "application/pdf" };
const MAX_BYTES = 2 * 1024 * 1024; // nginx の client_max_body_size と揃える
// 1) multer: サイズ・個数の上限と、拡張子の一次チェック(軽い足切り)
const upload = multer({
storage: multer.memoryStorage(), // 検証が終わるまでディスクに書かない
limits: { fileSize: MAX_BYTES, files: 1, fields: 5 },
fileFilter(req, file, cb) {
const ext = path.extname(file.originalname).toLowerCase().slice(1);
if (!(ext in ALLOWED)) return cb(Object.assign(new Error(`許可していない拡張子です: .${ext}`), { status: 400 }));
cb(null, true);
},
});
// 2) マジックバイトで種類を確定し、ランダム名で公開外に保存
app.post("/upload", (req, res, next) => {
upload.single("file")(req, res, async (err) => {
if (err) return next(err);
if (!req.file) return res.status(400).json({ error: "file フィールドがありません" });
const declaredExt = path.extname(req.file.originalname).toLowerCase().slice(1);
const detected = await fileTypeFromBuffer(req.file.buffer); // 中身から判定
if (!detected || !(detected.ext in ALLOWED)) {
return res.status(415).json({ error: "ファイルの中身が許可された形式ではありません",
declared: { ext: declaredExt, contentType: req.file.mimetype }, detected: detected ?? null });
}
if (ALLOWED[detected.ext] !== ALLOWED[declaredExt]) {
return res.status(415).json({ error: "拡張子と中身の形式が一致しません", declared: { ext: declaredExt }, detected });
}
const id = randomUUID(); // 申告されたファイル名は保存パスに一切使わない
const storedName = `${id}.${detected.ext}`;
const dest = path.join(UPLOAD_DIR, storedName);
await writeFile(dest, req.file.buffer, { flag: "wx" }); // 既存があれば失敗(上書きしない)
await writeFile(`${dest}.json`, JSON.stringify({ originalName: path.basename(req.file.originalname).slice(0, 255),
mime: ALLOWED[detected.ext], size: req.file.size })); // 申告名は表示用にだけ保持
res.status(201).json({ id, stored: storedName, mime: ALLOWED[detected.ext], size: req.file.size });
});
});
// 3) 配信: 保存時に確定した MIME と nosniff を付け、ダウンロード扱いで返す(実運用ではここで認可も行う)
app.get("/files/:id", async (req, res) => {
if (!/^[0-9a-f-]{36}$/.test(req.params.id)) return res.status(404).end();
const hit = (await readdir(UPLOAD_DIR)).find((n) => n.startsWith(req.params.id + ".") && n.endsWith(".json"));
if (!hit) return res.status(404).end();
const meta = JSON.parse(await readFile(path.join(UPLOAD_DIR, hit), "utf8"));
res.set({ "Content-Type": meta.mime, "X-Content-Type-Options": "nosniff",
"Content-Disposition": `attachment; filename="download.${hit.split(".")[1]}"`,
"Content-Security-Policy": "default-src 'none'; sandbox", "Cache-Control": "private, max-age=0" });
res.send(await readFile(path.join(UPLOAD_DIR, hit.replace(/\.json$/, ""))));
});
// 4) multer の上限超過は 413、それ以外は 400/500 に変換
app.use((err, req, res, next) => {
if (err instanceof multer.MulterError) {
return res.status(err.code === "LIMIT_FILE_SIZE" ? 413 : 400).json({ error: err.message, code: err.code });
}
res.status(err.status ?? 500).json({ error: err.message });
});
app.listen(Number(process.env.PORT ?? 7650));
実際に送って弾かれた応答(Express 版)
検証用の入力は Pillow で作った本物の PNG(291 バイト)、EXIF 付き JPEG、中身がテキストの fake.png、3 MB の big.png、script 入りの evil.svg などです。curl -F "file=@ファイル;filename=名前;type=MIME" で申告名と Content-Type を自由に変えて送りました。
| # | 送った入力 | ステータス | 応答本文(抜粋) |
|---|---|---|---|
| 1 | 本物の PNG | 201 | {"id":"34565f76-…","stored":"34565f76-….png","mime":"image/png","size":291} |
| 2 | fake.png(中身はテキスト。curl は拡張子から image/png を申告) |
415 | {"error":"ファイルの中身が許可された形式ではありません","declared":{"ext":"png","contentType":"image/png"},"detected":null} |
| 3 | 同じ偽装ファイルに type=image/png を明示 |
415 | 2 と同じ(申告値は判定に使っていない) |
| 4 | 本物の PNG を filename=../../etc/passwd.png で送信 |
201 | 保存名は 80e12923-….png(親ディレクトリに passwd.png は作られない) |
| 5 | 3 MB の big.png |
413 | {"error":"File too large","code":"LIMIT_FILE_SIZE"} |
| 6 | 二重拡張子 shell.php.png(中身はテキスト) |
415 | "detected":null で拒否 |
| 7 | 二重拡張子 shell.php.png(中身は本物の PNG) |
201 | 保存名は 1aaaf839-….png。申告名は表示用 JSON にだけ残る |
| 8 | script 入り evil.svg / tool.exe |
400 | {"error":"許可していない拡張子です: .svg"}(.exe も同様) |
| 9 | 拡張子 .jpg で中身は PNG |
415 | {"error":"拡張子と中身の形式が一致しません","declared":{"ext":"jpg"},"detected":{"ext":"png","mime":"image/png"}} |
マジックバイト判定は先頭バイトしか見ないので、先頭 60 バイトだけ PNG で残りが壊れたファイルは 201 で通ります(検証済み)。 画像は sharp で再エンコードすると、壊れたファイルは例外で弾け、EXIF も落ちます(sharp 0.35.5 で同じ入力を通した結果)。
import sharp from "sharp"; // npm install sharp
const clean = await sharp(req.file.buffer).rotate().toFormat(detected.ext === "jpg" ? "jpeg" : detected.ext).toBuffer();
// real.png: OK 353 bytes, exif=なし / photo.jpg: OK 329 bytes, exif=なし / truncated.png: NG vipspng: libpng read error
配信側の GET /files/{id} の応答ヘッダーは Content-Type: image/jpeg、X-Content-Type-Options: nosniff、Content-Disposition: attachment; filename="download.jpg"、Content-Security-Policy: default-src 'none'; sandbox となり、ブラウザは中身を推測せず、ダウンロードとして扱います。 /files/../server.mjs や %2e%2e%2f を含む ID は正規表現で 404 になりました。
Python(FastAPI + filetype + Pillow)で実装する
要点:UploadFile をチャンクで読みながら上限を数え、filetype.guess() のマジックバイトで種類を確定し、Pillow で開き直して再エンコードしてから保存する。 再エンコードで EXIF が落ち、途中で壊れているファイルは例外で弾けます(検証:Python 3.11、fastapi 0.142.2、starlette 1.0.0、filetype 1.2.0、Pillow 12.2.0)。
Starlette の request.form() にある max_part_size(既定 1 MB)はファイル以外のフォーム項目の上限で、ファイル本体は SpooledTemporaryFile に流し込まれるだけなので、ファイルサイズの上限はアプリ側で数える必要があります。
pip install fastapi uvicorn python-multipart filetype Pillow
# main.py — 実行: uvicorn main:app --port 7652
import io, secrets
from pathlib import Path
import filetype
from fastapi import FastAPI, HTTPException, UploadFile
from PIL import Image, UnidentifiedImageError
app = FastAPI()
UPLOAD_DIR = Path("./private-uploads").resolve() # 静的配信の外
UPLOAD_DIR.mkdir(exist_ok=True)
MAX_BYTES = 2 * 1024 * 1024 # nginx と揃える
CHUNK = 64 * 1024
ALLOWED = {"image/png": "png", "image/jpeg": "jpg", "image/webp": "webp"}
Image.MAX_IMAGE_PIXELS = 24_000_000 # 超えると DecompressionBombError(既定は約 1.79 億 px)
async def read_limited(upload: UploadFile, limit: int) -> bytes:
"""上限を超えた時点で読むのをやめて 413 を返す(全部読んでから len() を見ない)"""
buf = bytearray()
while chunk := await upload.read(CHUNK):
buf.extend(chunk)
if len(buf) > limit:
raise HTTPException(413, f"ファイルが大きすぎます(上限 {limit // 1024 // 1024} MB)")
return bytes(buf)
@app.post("/upload", status_code=201)
async def upload_image(file: UploadFile):
data = await read_limited(file, MAX_BYTES)
kind = filetype.guess(data) # 1) 中身で種類を決める。file.content_type は申告値
if kind is None or kind.mime not in ALLOWED:
raise HTTPException(415, {"error": "許可された画像形式ではありません",
"declared_content_type": file.content_type,
"detected": kind.mime if kind else None})
try: # 2) 開き直して再エンコード → EXIF が落ち、形式も確定
with Image.open(io.BytesIO(data)) as im:
im.load() # 壊れた画像や巨大画像はここで例外になる
out = io.BytesIO()
fmt = "JPEG" if kind.mime == "image/jpeg" else kind.extension.upper()
clean = im.convert("RGB") if fmt == "JPEG" else im
clean.save(out, format=fmt) # exif= を渡さないので付かない
except (UnidentifiedImageError, OSError, Image.DecompressionBombError) as e:
raise HTTPException(422, f"画像として読み込めません: {type(e).__name__}")
stored = f"{secrets.token_hex(16)}.{ALLOWED[kind.mime]}" # 3) 保存名はランダム
dest = (UPLOAD_DIR / stored).resolve()
if not dest.is_relative_to(UPLOAD_DIR): # 念のための最終確認
raise HTTPException(400, "invalid path")
dest.write_bytes(out.getvalue())
return {"id": stored.split(".")[0], "stored": stored, "mime": kind.mime,
"received_bytes": len(data), "saved_bytes": dest.stat().st_size,
"display_name": Path(file.filename or "").name[:255]} # 申告名は表示用にだけ
配信は Express 版と同じ考え方で、FileResponse(path, media_type=mime, filename="download.jpg", headers={"X-Content-Type-Options": "nosniff", ...}) のように filename= を渡すと Content-Disposition: attachment が付き、media_type には保存時に確定した値を渡します(検証では content-type: image/jpeg、content-disposition: attachment; filename="download.jpg"、x-content-type-options: nosniff が返りました)。
実際に送って弾かれた応答(FastAPI 版)
| # | 送った入力 | ステータス | 応答本文 |
|---|---|---|---|
| 1 | 本物の PNG | 201 | {"id":"2d2b1ca5…","stored":"2d2b1ca5….png","mime":"image/png","received_bytes":291,"saved_bytes":291,"display_name":"real.png"} |
| 2 | fake.png(中身テキスト、type=image/png を明示) |
415 | {"detail":{"error":"許可された画像形式ではありません","declared_content_type":"image/png","detected":null}} |
| 3 | 本物の PNG を filename=../../etc/passwd.png で送信 |
201 | 保存名は 8b278751….png、"display_name":"passwd.png"(ディレクトリ部分は捨てる) |
| 4 | 3 MB の big.png |
413 | {"detail":"ファイルが大きすぎます(上限 2 MB)"} |
| 5 | 二重拡張子 shell.php.png(中身テキスト) / script 入り evil.svg |
415 | "detected":null で拒否(SVG は "declared_content_type":"image/svg+xml") |
| 6 | EXIF 付き JPEG(884 バイト) | 201 | "received_bytes":884,"saved_bytes":789(再エンコードで縮んだ) |
| 7 | 先頭 60 バイトだけ PNG で残りが壊れたファイル | 422 | {"detail":"画像として読み込めません: OSError"} |
再エンコードで EXIF が実際に消えたかを、保存前後の JPEG で確認した出力です。
before: {272: 'Model-X', 37510: 'secret note', 271: 'SampleCamera'}
after : {} -> 14d899ed896a43e2c62ea60a73724425.jpg
filetype と python-magic の違い
filetype は純 Python でインストールが軽く、バイナリ形式の判定に向きます。python-magic は libmagic(file コマンドと同じ判定)を使うので、テキスト系も細かく分類できます。どちらも「テキストは画像ではない」と正しく判定できるので、画像アップロードなら filetype で十分です(同じ入力で比較した出力)。
| 入力 | filetype 1.2.0 | python-magic 0.4.27 |
|---|---|---|
real.png / photo.jpg |
image/png / image/jpeg |
image/png / image/jpeg |
fake.png / shell.php.png(中身テキスト) |
None / None |
text/x-php / text/plain |
evil.svg / members.csv |
None / None |
image/svg+xml / text/csv |
tool.exe |
application/x-msdownload |
application/x-dosexec |
Node の file-type も同じ設計で、公式に「テキスト系(.txt/.csv/.svg)は判定対象外」と明記されています。テキスト形式を受け付ける機能では、「その形式としてパースできるか」で検証します(CSV なら csv モジュールで読めるか、文字コードが想定どおりか)。
nginx で二重に守る(サイズ上限とレート制限)
要点:アプリの上限と同じ値を client_max_body_size に書き、limit_req で回数も絞る。 アプリより手前で止まるので、上限超えの本文がアプリのメモリに載りません(検証:nginx 1.24.0)。
http {
limit_req_zone $binary_remote_addr zone=upload:1m rate=10r/m;
server {
listen 127.0.0.1:7653;
client_max_body_size 2m; # アプリの MAX_BYTES と揃える
location /upload {
limit_req zone=upload burst=5 nodelay; # 1 IP あたり 10 回/分(瞬間的には +5 回まで)
proxy_pass http://127.0.0.1:7650;
proxy_request_buffering on; # 本文を受け切ってからアプリへ渡す
}
location /files/ {
proxy_pass http://127.0.0.1:7650;
add_header X-Content-Type-Options nosniff always;
}
}
}
nginx 経由で 3 MB を送ると 413 が nginx から返り、アプリには届きません。 同じ IP から連続 8 回送ると 7 回目から 503 になりました。
$ curl -s -w "HTTP %{http_code}\n" -F "file=@samples/big.png" http://127.0.0.1:7653/upload
<html><head><title>413 Request Entity Too Large</title></head>...
HTTP 413
error.log: client intended to send too large body: 3146215 bytes, ... request: "POST /upload HTTP/1.1"
$ for i in $(seq 1 8); do curl -s -o /dev/null -w "%{http_code} " -F "file=@samples/real.png" http://127.0.0.1:7653/upload; done
201 201 201 201 201 201 503 503
形式別の注意点(SVG・CSV・ZIP・ウイルススキャン)
SVG は「画像」ではなく XML として扱う
要点:プロフィール画像などの用途では SVG を許可リストに入れない。 どうしても必要なら DOMPurify で script やイベント属性を落とし、それでも attachment + nosniff + 別ドメインで配信します(検証:dompurify 3.4.16 + jsdom 30.1.1)。
// npm install dompurify jsdom
import { JSDOM } from "jsdom";
import createDOMPurify from "dompurify";
const DOMPurify = createDOMPurify(new JSDOM("").window);
const input = `<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"><a href="javascript:alert(2)"><circle r="5"/></a><image href="x" onerror="alert(3)"/></svg>`;
console.log(DOMPurify.sanitize(input, { USE_PROFILES: { svg: true } }));
// → <svg xmlns="http://www.w3.org/2000/svg"><a><circle r="5"></circle></a><image href="x"></image></svg>
onload・onerror・javascript: の href がすべて落ち、<script> 要素を含む SVG を通した場合も要素ごと除去されました。
CSV は先頭文字を無効化してから書き出す
表計算ソフトは = + - @(およびタブ・CR)で始まるセルを式として評価します。アップロードされた値を CSV として書き出すときは先頭に ' を付け、全セルを引用符で囲みます(Python 3.11 標準ライブラリ)。
import csv, io
FORMULA_PREFIXES = ("=", "+", "-", "@", "\t", "\r")
def neutralize_cell(value: str) -> str:
return "'" + value if value.startswith(FORMULA_PREFIXES) else value
def export_csv(rows: list[dict]) -> str:
out = io.StringIO()
w = csv.DictWriter(out, fieldnames=rows[0].keys(), quoting=csv.QUOTE_ALL)
w.writeheader()
for r in rows:
w.writerow({k: neutralize_cell(str(v)) for k, v in r.items()})
return out.getvalue()
print(export_csv([{"name": "Alice", "memo": "=1+1"}, {"name": "Bob", "memo": "@SUM(A1:A2)"}, {"name": "Dave", "memo": "plain text"}]))
"name","memo"
"Alice","'=1+1"
"Bob","'@SUM(A1:A2)"
"Dave","plain text"
ZIP は展開する前に中身を検査する
要点:infolist() で展開後サイズの合計・圧縮率・エントリ名を確認し、条件を満たすものだけ展開する。 20 MB のゼロ埋めは数十 KB に縮むので、圧縮率で見分けられます。
import zipfile
from pathlib import PurePosixPath
MAX_TOTAL, MAX_RATIO, MAX_ENTRIES = 50 * 1024 * 1024, 100, 1000
def inspect_zip(path: str) -> list[str]:
problems = []
with zipfile.ZipFile(path) as zf:
infos = zf.infolist()
if len(infos) > MAX_ENTRIES:
problems.append(f"エントリ数が多すぎます: {len(infos)}")
if (total := sum(i.file_size for i in infos)) > MAX_TOTAL:
problems.append(f"展開後サイズが上限超え: {total:,} bytes")
for i in infos:
p = PurePosixPath(i.filename)
if p.is_absolute() or ".." in p.parts:
problems.append(f"展開先を抜けるパス: {i.filename!r}")
if i.compress_size and i.file_size / i.compress_size > MAX_RATIO:
problems.append(f"圧縮率が高すぎます: {i.filename!r} {i.file_size / i.compress_size:,.0f}x")
return problems
samples/suspicious.zip: NG
- 展開先を抜けるパス: '../outside.txt'
- 圧縮率が高すぎます: 'zeros.bin' 1,029x
samples/normal.zip: OK(展開してよい)
検査に通ったあとも、extractall() ではなくエントリごとに zf.open() で読み、書き出し先を resolve() して保存先ディレクトリの内側であることを確認してから書くと安心です。
ウイルススキャンは保存前に通す(この記事では未実行)
ClamAV を使う場合、clamd を起動して Python の clamd パッケージや Node の clamscan から INSTREAM(ファイルをソケット経由で渡す)でスキャンし、結果が OK のものだけ保存します。この記事の検証環境ではウイルス定義の取得ができないため実行していません。スキャン待ちの間は一時領域に置き、結果が出るまで配信 URL を発行しない設計にします。
配信の設計:ヘッダー・別ドメイン・認可
要点:Content-Type は保存時に確定した値を返し、nosniff で推測を止め、attachment でダウンロード扱いにし、別ドメインで Cookie と切り離す。 表示が必要な画像(再エンコード済みの PNG/JPEG/WebP)だけ inline にし、Content-Security-Policy: default-src 'none'; sandbox を付けておくと、万一表示されてもスクリプトと通信が止まります。配信ホストは files.example.net のような専用ドメイン(オブジェクトストレージ + CDN でも同様)にするとアプリの Cookie・セッションと同居せず、/files/:id ではログインと所有者確認を通し、ID は UUID や署名付き URL のように推測できない値にします。Cache-Control: private で共有キャッシュにも残しません。
よくあるつまずきと直し方(前と後)
要点:multer の limits 未設定、fileFilter の拡張子判定だけ、diskStorage の一時ファイル掃除忘れ、preservePath: true の 4 つは「動くけれど守れていない」状態なので、実行結果で確認しておく。 比較用に、よくある書き方(limits なし、拡張子の正規表現だけ、diskStorage で先に保存)で同じ入力を送りました。
| 入力 | よくある書き方(前) | この記事の実装(後) |
|---|---|---|
3 MB の big.png |
201 {"saved":"1790806035489-big.png","size":3146019}(limits なし=上限は Infinity) |
413 LIMIT_FILE_SIZE |
fake.png(中身テキスト) |
fileFilter を通過し、後段で弾いても一時ファイルが残る:"leftover":["…-big.png","…-fake.png"] |
415。memoryStorage なので残らない |
filename=../escaped.png |
preservePath: true + 申告名で保存すると tmp2/uploads/ ではなく tmp2/escaped.png(1 つ上)に作られた |
申告名を使わないので保存先は常に UPLOAD_DIR/uuid.png |
limitsは必ず書く:fileSizeだけでなくfiles・fields・partsも用途に合わせて絞ると、想定外の multipart が届いても早く止まります。fileFilterは一次チェック:ここではoriginalnameとmimetype(どちらも申告値)しか見えません。中身の判定は本体で行います。diskStorageを使うなら、検証で弾いたときにfs.unlinkする:検証が終わるまでmemoryStorage、大きなファイルはストリームで一時領域に書き、成功時だけ本保存先へrenameするのが扱いやすい形です。- multer(busboy)は既定で
basenameを取る:preservePath: false(既定)なら../escaped.pngはescaped.pngになります。それでも申告名を保存パスに使わないことが基本です。 - FastAPI はサイズ上限を自分で数える:
max_part_sizeはフォーム項目用で、ファイル本体には効きません。read_limited()のように途中で打ち切ります。
レビュー用チェックリスト
| 段階 | 確認項目 | 確認方法 |
|---|---|---|
| 受信 | 許可リストは業務に必要な拡張子だけか(.svg .html .zip を安易に含めていないか) |
許可リストの定義を読む |
| 受信 | サイズ上限がアプリと nginx(client_max_body_size)の両方にあり、値が揃っているか |
上限 +1 バイトを送って 413 を確認 |
| 受信 | 個数・フィールド数の上限(multer files/fields、Starlette max_files/max_fields)があるか |
設定値を確認 |
| 受信 | ログイン必須で、レート制限があるか | 未ログインで 401、連続送信で 429/503 |
| 検証 | 種類はマジックバイトで確定し、拡張子と一致しないものを拒否しているか | 中身テキストの .png、.jpg 名の PNG で 415 |
| 検証 | Content-Type 申告値を判定に使っていないか |
type=image/png を付けた偽装で 415 |
| 検証 | 画像は再エンコードしているか(EXIF が消えるか) | 保存後のファイルで getexif() が空 |
| 検証 | CSV の先頭文字、ZIP の展開後サイズ・圧縮率・パスを検査しているか | 検査コードとテストの有無 |
| 検証 | ウイルススキャン(または CDR)が保存前にあるか | 設計書と設定 |
| 保存 | 保存名はサーバーが生成し、申告名はパスに使っていないか | filename=../x.png で保存先が変わらない |
| 保存 | 公開ディレクトリの外(または別ホスト・オブジェクトストレージ)に置き、上書きせず、一時ファイルを掃除しているか | 保存先の URL 直打ちで 404、失敗ケース後に一時領域が空 |
| 配信 | Content-Type は保存時の確定値、nosniff、attachment、CSP が付くか |
curl -D - でヘッダー確認 |
| 配信 | 取得時に認可を通し、ID が推測できず、別ドメイン(または署名付き URL)から配信しているか | 他人の ID で 403/404、配信ホストと Cookie の範囲を確認 |
| 運用 | multer / Pillow / 画像ライブラリを最新に保っているか | npm audit pip-audit |
よくある質問
Q. 拡張子とマジックバイトの両方を見る必要はありますか?
A. あります。拡張子は「配信時にどう扱われるか」、マジックバイトは「中身が何か」を決めるもので、役割が違います。この記事の実装では、拡張子で一次チェックをしたうえで中身を判定し、両者が食い違えば 415 で返しています(.jpg 名の PNG が弾かれた例)。
Q. オブジェクトストレージ(S3 互換)に保存する場合も同じですか?
A. 検証の流れは同じで、保存先が「公開ディレクトリの外」から「バケット」に変わるだけです。バケットは非公開にし、配信は署名付き URL か CDN 経由にします。オブジェクトのキーはランダム名、Content-Type と Content-Disposition はアップロード時のメタデータとして確定値を設定します。
Q. アプリ側で上限があるなら、nginx の client_max_body_size は不要では?
A. 両方あると、上限超えの本文が nginx で止まり、アプリのメモリと CPU を使いません。検証では 3 MB が nginx で 413 になり、アプリのログには何も残りませんでした。値は必ず揃えておきます(nginx だけ大きいと、アプリが受け切ってから 413 を返すことになります)。
まとめ
- 受け取る種類は許可リスト、種類の確定はマジックバイト。
Content-Typeと拡張子は申告値として扱う - サイズ上限はアプリと nginx の両方に同じ値で書く(multer は
limits、FastAPI はチャンク読みで数える) - 保存名はサーバーが生成し、公開ディレクトリの外へ。申告名は表示用にだけ
basenameで保持する - 画像は再エンコードして EXIF を落とし、壊れたファイルは例外で弾く
- SVG は原則受け付けず、CSV は先頭文字を無効化、ZIP は展開前に検査する
- 配信は
nosniff+attachment+ 確定Content-Type+ 認可 + 別ドメイン limits未設定・拡張子だけの判定・一時ファイルの掃除忘れは実行結果で確認しておく
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「ファイルアップロードのセキュリティ対策|実装コードと検証チェックリスト」
株式会社RJCでは、ファイルアップロードのように「受信・保存・配信」を分けて考える設計を、コードレビューやペア作業の中で仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。