Old-School Backend / 2026 ・ No.12
いにしえのバックエンド入門
第12回 ファイル ── 領収書の写真を、名前も中身も信じずに受け取る
公開
経費精算には領収書が要ります。スマホで撮った写真を申請に付けたい。ところがファイルは、文字や数字とちがって、大きさも、名前も、中身も、送る側が自由に決められます。
今回は、届いた名前のまま置く素朴な受け取りを作り、上書き、置き場所の外への書き込み、中身のちがうファイル、同じ領収書の使い回し、DB とディスクのずれを、自分の練習用アプリで実測します。そして、名前は自分で付け、中身の先頭で見分け、指紋で二重利用に気づく形に直します。
領収書を付けたい
新井領収書の写真を付けるだけですよね。受け取って、フォルダーに置く。5分でできます。
古井名前はだれが決める? 大きさは? 中身は本当に写真か?
新井……送ってきた人が決めます。
古井そう。だから全部、確かめる。
全15回の路線図(いまここ:第12回)
- 大きさ ── 既定ではスマホ写真 3.5 MB が断られ、12 MB は返事すら来ない(実測)
- 名前 ── 同じ名前は上書き、../ の付いた名前は置き場所の外へ(実測)
- 中身 ── 中身が文字の .jpg を「画像」として受け取った(実測)
- 二重利用・ずれ ── 同じ領収書を2つの申請に、DB に入らなかったファイルが残る(実測)
まずは素朴に書きます。窓口は multipart/form-data でファイルを受け取り、担当者は届いた名前のままフォルダーに置き、DB には目録を1行入れます。ログインは前回のものを使います。
# 素朴版の担当者(NaiveReceiptService.java)
@Transactional
public Receipt store(String expenseId, MultipartFile file, String user) throws IOException {
String name = file.getOriginalFilename();
Files.copy(file.getInputStream(), dir.resolve(name), StandardCopyOption.REPLACE_EXISTING); // 届いた名前のまま
Receipt r = new Receipt(UUID.randomUUID().toString(), expenseId, name, file.getContentType(), file.getSize(),
"-", name, user);
receipts.insert(r); // ここで失敗しても、ファイルはもう置いてある
return r;
}
public byte[] read(String name) throws IOException {
return Files.readAllBytes(dir.resolve(name)); // 届いた名前で読む
}
# 目録の表(setup12.sql) -- 第12回:領収書の目録(ファイルそのものはディスク)と、比べるための bytea 版 create table if not exists receipt ( id uuid primary key, expense_id varchar(20) not null references expense(id), original_name varchar(100) not null, content_type varchar(100), size bigint not null, sha256 char(64) not null, stored_name varchar(255) not null, uploaded_by varchar(30) not null, created_at timestamptz not null default now() );
大きさの上限
手元の領収書の画像(0.04 MB)、スマホで撮った写真くらいの大きさ(3.54 MB)、大きな写真(12.05 MB)の3つを送りました。
# 3つの大きさを、3つの設定で送る(新井さん、素朴版の窓口)
receipt-small 0.04 MB photo-phone 3.54 MB photo-big 12.05 MB
既定のまま 200 413 返事なし(途中で切られた)
10MB に上げる 200 200 返事なし(途中で切られた)
+ swallow 20MB 200 200 413
大きさの上限と、返事が来ない写真(実測)
Spring Boot の既定では、1ファイルは 1 MB までです。スマホの写真は、それだけで 413(大きすぎる)になりました。上限を 10 MB に上げるとスマホ写真は通りましたが、12 MB の写真には返事そのものが来ませんでした。Tomcat は、断る前に残りの本文を読み捨てますが、その量は既定で 2 MB まで。超えると、返事を書く前に接続を切ってしまいます。画面には「通信エラー」としか出ません。
# application.properties に足した3行 spring.servlet.multipart.max-file-size=10MB spring.servlet.multipart.max-request-size=10MB server.tomcat.max-swallow-size=20MB
読み捨てる量(swallow)を 20 MB に広げると、12 MB の写真にも 413 と理由が返りました。上限は「業務で要る大きさ」から決め、画面の側でも送る前に大きさを確かめると親切です。
ファイル名を信じない
スマホやスキャナーは、だいたい同じような名前を付けます。新井さんと古井さんが、どちらも receipt.jpg を送ったら? そして、名前に ../ が入っていたら?
# 届いた名前のまま置く(素朴版) arai receipt.jpg (md5 a67175fb) → EXP-2026-5020 200 disk receipt.jpg = a67175fb furui receipt.jpg (md5 7260a48c) → EXP-2026-5101 200 disk receipt.jpg = 7260a48c ← 新井さんの領収書が消えた DB: EXP-2026-5020|receipt.jpg|receipt.jpg EXP-2026-5101|receipt.jpg|receipt.jpg disk: receipt.jpg arai ../written-outside.jpg → 200 store/naive/ = ['receipt.jpg'] store/ = ['written-outside.jpg', 'outside.txt'] # 同じ名前を、fixed 版で arai ../written-outside.jpg → 200 originalName = ../written-outside.jpg store/receipts/ = ['32005963-9319-4470-877a-0aaae252428a']
置き場所で起きたこと
目録には2行あるのに、ディスクには1つ。新井さんの領収書は、古井さんの領収書で黙って上書きされていました。../written-outside.jpg という名前は、Tomcat も Spring もそのまま渡してきて、ファイルは置き場所の外に書かれました。
直し方は、置く名前を自分で付けることです。fixed 版は UUID(ぶつからない番号)を名前にし、元の名前は目録に「表示用」として残すだけにしました。
中身を確かめる
ファイルと一緒に届く Content-Type(画像か、PDF か)は、名前と同じく送る側の自己申告です。拡張子も同じです。中身が文字の fake.jpg を「image/jpeg」として送りました。
# 中身と、送られてきた種類(Content-Type)
fake.jpg image/jpeg e3 81 93 e3 naive 200 image/jpeg fixed 415 受け付けない
receipt-small.jpg image/jpeg ff d8 ff e0 naive 200 image/jpeg fixed 200 image/jpeg
receipt.png image/png 89 50 4e 47 naive 200 image/png fixed 200 image/png
receipt.pdf application/pdf 25 50 44 46 naive 200 application/pdf fixed 200 application/pdf
receipt.png image/jpeg 89 50 4e 47 naive 200 image/jpeg fixed 200 image/png
# 中身の先頭で見分ける(FileType.java)
public static String detect(byte[] h) {
if (h.length >= 3 && (h[0] & 0xff) == 0xFF && (h[1] & 0xff) == 0xD8 && (h[2] & 0xff) == 0xFF) return "image/jpeg";
if (h.length >= 8 && (h[0] & 0xff) == 0x89 && h[1] == 'P' && h[2] == 'N' && h[3] == 'G') return "image/png";
if (h.length >= 5 && h[0] == '%' && h[1] == 'P' && h[2] == 'D' && h[3] == 'F' && h[4] == '-') return "application/pdf";
return null; // 領収書として受け取らない
}
素朴版は、文字のファイルを画像として受け取り、PNG を「JPEG です」と言われればそのまま JPEG として記録しました。fixed 版は中身の先頭のバイトで見分けます。JPEG は ff d8 ff、PNG は 89 50 4e 47、PDF は %PDF- で始まります。どれでもなければ 415。種類も、申告ではなく中身から決めて記録します。
先頭が正しくても、中身全体が安全とは限りません。業務では、ウイルスの検査や、画像を一度読みこんで描きなおす処理を足すこともあります。
同じ領収書を二度使わない
経費精算ならではの問題があります。同じ領収書を、別の申請にも付けたら? 二重の申請です。fixed 版は、受け取るときに中身の指紋(SHA-256)を取って、目録に入れています。
# 同じ領収書を、2つの申請に付ける receipt-small.jpg → EXP-2026-5021 200 sha256 1b3b466198a4c17d… receipt-small.jpg → EXP-2026-5022 409 同じ領収書が EXP-2026-5021 に付いています
同じ写真を 5022 に付けようとすると、指紋が 5021 の領収書と一致して 409 になりました。名前を変えても、指紋は変わりません。ただし、撮りなおした写真は別の指紋になるので、これは「うっかり」を止める仕組みです。承認者が金額や日付を見る仕事はなくなりません。
返すときも番号で
受け取ったら、見せる窓口も要ります。素朴版は「名前をもらって、そのファイルを返す」窓口にしました。
# 名前で返す窓口(素朴版)
GET /api/v12/naive/files?name=receipt.jpg → 200 37850 バイト
GET /api/v12/naive/files?name=../outside.txt → 200 85 バイト これは、領収書の置き場所の外にあるファイルです(練習用)
# 番号で返す窓口(fixed 版)
arai → arai's receipt 200
Content-Type: image/jpeg
Content-Disposition: attachment; filename="________.jpg"; filename*=UTF-8''%E9%A0%98%E5%8F%8E%E6%9B%B8%EF%BC%88%E6%96%B0%E5%B9%B9%E7%B7%9A%EF%BC%89.jpg
X-Content-Type-Options: nosniff
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
arai → furui's receipt 404
furui → arai's receipt 200
Content-Type: image/jpeg
Content-Disposition: attachment; filename="________.jpg"; filename*=UTF-8''%E9%A0%98%E5%8F%8E%E6%9B%B8%EF%BC%88%E6%96%B0%E5%B9%B9%E7%B7%9A%EF%BC%89.jpg
X-Content-Type-Options: nosniff
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
arai → none receipt 404
# 番号で返す窓口(V12Controller.java)
@GetMapping("/api/v12/receipts/{rid}")
public ResponseEntity<FileSystemResource> download(@PathVariable String rid, @AuthenticationPrincipal LoginUser u) {
Receipt r = receipts.find(rid, u);
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(r.contentType())) // 中身から決めた種類
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment()
.filename(r.originalName(), StandardCharsets.UTF_8).build().toString()) // 保存するときの名前
.body(new FileSystemResource(receipts.path(r)));
}
名前で返す窓口は、../outside.txt で置き場所の外のファイルまで返しました。同じやり方で、設定ファイルや鍵のファイルを返してしまうのが、パス・トラバーサルと呼ばれる穴です(ここでは、自分の練習用の環境に置いた無害なファイルで確かめました)。
fixed 版は、目録の番号で受け取ります。番号から目録を引き、その領収書の申請を見てよいかを第11回の担当者で確かめ、だめなら 404。返事には、中身から決めた種類、attachment(開かずに保存)、日本語の名前のための filename*、そして種類を推測させない nosniff が付きました。nosniff とキャッシュしない指示は、Spring Security が付けたものです。
受け取りの流れと、ずれ
ファイルは DB の外にあるので、DB のトランザクションには入りません。目録に入らなかったファイルが残ったら? 名前を 150 文字にして(目録の列は 100 文字まで)、目録への insert をわざと失敗させました。
# 150 文字の名前で送る(目録の列は 100 文字まで) naive → 500 receipt rows = 0 files on disk = 1 fixed → 500 receipt rows = 0 files on disk = 0 ERROR: value too long for type character varying(100)
素朴版は先にファイルを置いてから目録を入れるので、500 のあと、だれの物でもないファイルがディスクに1つ残りました。fixed 版は、一時ファイルに書き、確かめ、目録を入れ、最後に自分の名前に移します。目録で失敗すれば一時ファイルは消え、ディスクには何も残りませんでした(名前の長さは、本来は入口で 400 にすべきところです)。
# fixed 版の担当者(ReceiptService.java の store)
@Transactional
public Receipt store(String expenseId, MultipartFile file, LoginUser u) throws IOException {
claims.find(expenseId, u); // 見てよい申請か(第11回)。ちがえば 404
String type;
try (InputStream in = file.getInputStream()) {
type = FileType.detect(in.readNBytes(8)); // 先頭 8 バイトで種類を見る
}
if (type == null) throw new ReceiptRejectedException(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "JPEG・PNG・PDF だけ受け付けます");
Path tmp = Files.createTempFile(dir, "up-", ".part");
try {
String sha = copyWithSha256(file, tmp); // 一時ファイルに書きながら、中身の指紋を取る
Receipt same = receipts.findBySha(sha);
if (same != null) throw new ReceiptRejectedException(HttpStatus.CONFLICT, "同じ領収書が " + same.expenseId() + " に付いています");
String id = UUID.randomUUID().toString(); // 置くときの名前は自分で付ける
Receipt r = new Receipt(id, expenseId, file.getOriginalFilename(), type, file.getSize(), sha, id, u.getUsername());
receipts.insert(r);
Path dest = dir.resolve(id);
Files.move(tmp, dest, StandardCopyOption.ATOMIC_MOVE);
deleteIfRolledBack(dest); // DB が取り消されたら、ファイルも消す
return r;
} finally {
Files.deleteIfExists(tmp);
}
}
fixed 版の受け取りの流れ(実測)
移したあとでコミットに失敗したときのために、「取り消されたらファイルも消す」後始末も登録しています。それでも、電源が落ちるような場面では、ずれはゼロにはなりません。目録にないファイルを夜中に探して片づける仕事を、別に用意しておくのが定番です。
どこに置くか
ファイルそのものを DB に入れる(bytea)やり方もあります。約 0.94 MB の写真 20 枚で比べました。
# 約 0.94 MB の写真 20 枚を、ディスクと DB(bytea)に disk 468 ms 18,753,622 バイト bytea 1043 ms 19,529,728 バイト (pg_total_relation_size) src 18,753,622 バイト
| 置き場所 | 向いている場面 | 気をつけること |
|---|---|---|
| サーバーのディスク(この回) | サーバーが1台、ファイルが多すぎない | サーバーを2台にすると共有が要る。バックアップを DB と別に取る |
| DB(bytea) | 件数が少ない、DB と必ず一緒に消えてほしい | この回は 2.2 倍の時間。DB とバックアップが写真の分だけ太る |
| オブジェクトストレージ(S3 互換など) | サーバーが複数、量が多い、長く保管する | DB との「ずれ」の後始末は同じ。見せるときの権限の確かめもアプリで |
ファイルは、大きさ・名前・中身のどれも送る側が決める。名前は自分で付け、中身は先頭で見分け、指紋で二重利用に気づく。返すときも番号と権限で。DB とディスクは1つのトランザクションにならないので、ずれの後始末まで書く。
自分の PC で確かめる
| 試したいこと | 書き方 |
|---|---|
| ファイルの先頭のバイトを見る | head -c 8 receipt.jpg | xxd |
| ファイルの指紋を取る | sha256sum receipt.jpg |
| 届いた名前を道にしている所を探す | grep -rn 'getOriginalFilename' src/main/java |
| 目録にないファイルを探す | ディスクの一覧と select stored_name from receipt を突き合わせる |
ブラウザの開発者ツールで、ダウンロードの返事の Content-Type・Content-Disposition・X-Content-Type-Options を見ておくと、「どう表示されるか」をサーバーが決めているのが分かります。
結 — 一枚まとめと、次回
- 既定の上限は 1 ファイル 1 MB。スマホ写真が断られる。Tomcat の読み捨ては 2 MB まで。
- 届いた名前のまま置くと、上書きと置き場所の外。名前は自分で付ける。
- 種類は中身の先頭で。Content-Type と拡張子は自己申告。
- 中身の指紋(SHA-256)で、同じ領収書の使い回しに気づく。
- 返すときは番号と権限で。名前で返すと、置き場所の外まで返す。
- ファイルを先に置かない。ずれの後始末を書いておく。
新井5分のつもりが、一日かかりました。
古井上出来だ。ところで、12 MB の写真で返事が来なかったとき、サーバーのログには何が残っていたと思う? 次回はログと監視。困ったときに、あとから何が起きたかを読める形にしよう。
参考
- Spring Framework — Multipart
- Spring Boot — Common Application Properties(spring.servlet.multipart.*、server.tomcat.max-swallow-size)
- Apache Tomcat 11 — The HTTP Connector(maxSwallowSize)
- OWASP — File Upload Cheat Sheet
- OWASP — Path Traversal
- RFC 6266 — Content-Disposition in HTTP
- PostgreSQL 16 — Binary Data Types(bytea)
記録は、筆者がクラウド上の Linux(Ubuntu 24.04、カーネル 6.18)で、Spring Boot 4.1.2(Spring Framework 7.0.10、Tomcat 11.0.27、Spring Security 7.1.2、MyBatis 3.5.19)、OpenJDK 25.0.4、PostgreSQL 16.15 を使って 2026年10月31日に取ったものです。申請サーバーと DB サーバーは、1台の中に作った別々のネットワーク空間(network namespace)で、往復は 0.04 ms ほどです。置き場所の外への書き込みと読み出しは、筆者の練習用アプリと、そのために置いた無害なファイルだけで確かめました。写真は乱数で作った練習用の画像、領収書は練習用に描いたものです。表の数字や出力はすべて、そのときのコマンドの出力から読んだ実際の値です。登場人物は架空の人物です。
いにしえのバックエンド入門 第12回 — ファイル / 2026.10