Old-School Backend / 2026 ・ No.12

いにしえのバックエンド入門

第12回 ファイル ── 領収書の写真を、名前も中身も信じずに受け取る

公開

端末の画面。NaiveReceiptService.java の 27 行目は Files.copy で dir.resolve(name) に届いた名前のまま置き、35 行目は dir.resolve(name) を読む。show12.py name の結果は、arai の receipt.jpg(a67175fb)を置いたあと furui の receipt.jpg(7260a48c)を置くとディスクの receipt.jpg が 7260a48c になり、DB の目録は2行なのにディスクは1つ。../written-outside.jpg は 200 で naive の外の store に置かれた。fixed 版は番号の名前で置いた
届いたファイル名のまま置くと、同じ名前の領収書は上書きされ、../ の付いた名前は置き場所の外に書かれました(素朴版)。fixed 版は、置く名前を自分で付けます(筆者がクラウド上の Linux で、2026年10月31日に撮影。ウインドウの上の帯は説明のために描き足したもの)

経費精算には領収書が要ります。スマホで撮った写真を申請に付けたい。ところがファイルは、文字や数字とちがって、大きさも、名前も、中身も、送る側が自由に決められます。
今回は、届いた名前のまま置く素朴な受け取りを作り、上書き、置き場所の外への書き込み、中身のちがうファイル、同じ領収書の使い回し、DB とディスクのずれを、自分の練習用アプリで実測します。そして、名前は自分で付け、中身の先頭で見分け、指紋で二重利用に気づく形に直します。

一

領収書を付けたい

新井領収書の写真を付けるだけですよね。受け取って、フォルダーに置く。5分でできます。

古井名前はだれが決める? 大きさは? 中身は本当に写真か?

新井……送ってきた人が決めます。

古井そう。だから全部、確かめる。

全15回の路線図(いまここ:第12回)

1 全体図 2 Java の準備 3 素の HTTP 4 素の JDBC 5 Spring Boot 6 入力チェック 7 例外と返事 8 DB アクセス 9 トランザクション 10 状態遷移 11 認証・認可 12 ファイル 13 ログと監視 14 テスト 15 動かす いまここ 基礎 素手で 受け口 データ 業務と守り 運用
  • 大きさ ── 既定ではスマホ写真 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

大きさの上限と、返事が来ない写真(実測)

3つの大きさ × 3つの設定(実測) 領収書 0.04 MB スマホ写真 3.54 MB 大きい写真 12.05 MB 既定のまま 200 413 返事なし 10 MB に上げる 200 200 返事なし + swallow 20 MB 200 200 413 Spring の既定は1ファイル 1 MB。スマホの写真は、それだけで断られる 断る前に読み捨てる量(Tomcat は 2 MB)を超えると、返事も届かない

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']

置き場所で起きたこと

置き場所(store/)で起きたこと(実測) store/ outside.txt ← ?name=../outside.txt で読めた written-outside.jpg ← 名前 ../written-outside.jpg で書けた naive/ receipt.jpg ← 2人目の receipt.jpg で上書き receipts/ 32005963-9319-… ← fixed 版:名前は自分で付けた番号 ファイル名は、送る側が自由に書ける文字。そのまま道(パス)にしない 元の名前は目録(DB)に表示用として残し、置く名前は番号にする

目録には2行あるのに、ディスクには1つ。新井さんの領収書は、古井さんの領収書で黙って上書きされていました。../written-outside.jpg という名前は、Tomcat も Spring もそのまま渡してきて、ファイルは置き場所の外に書かれました。

直し方は、置く名前を自分で付けることです。fixed 版は UUID(ぶつからない番号)を名前にし、元の名前は目録に「表示用」として残すだけにしました。

四

中身を確かめる

ファイルと一緒に届く Content-Type(画像か、PDF か)は、名前と同じく送る側の自己申告です。拡張子も同じです。中身が文字の fake.jpg を「image/jpeg」として送りました。

端末の画面。show12.py type の結果。fake.jpg は先頭 e3 81 93 e3 で、naive は 200 image/jpeg、fixed は 415 受け付けない。receipt-small.jpg(ff d8 ff e0)、receipt.png(89 50 4e 47)、receipt.pdf(25 50 44 46)はどちらも 200。image/jpeg として送った receipt.png は、naive は image/jpeg、fixed は image/png。同じ receipt-small.jpg を EXP-2026-5021 に付けると 200、5022 に付けると 409 同じ領収書が EXP-2026-5021 に付いています
中身が文字の fake.jpg を、素朴版は「画像」として受け取りました。fixed 版は先頭のバイトで見分けて 415。同じ領収書を別の申請に付けようとすると、指紋(sha256)で気づいて 409 です(筆者がクラウド上の Linux で、2026年10月31日に撮影。ウインドウの上の帯は説明のために描き足したもの)
# 中身と、送られてきた種類(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 になりました。名前を変えても、指紋は変わりません。ただし、撮りなおした写真は別の指紋になるので、これは「うっかり」を止める仕組みです。承認者が金額や日付を見る仕事はなくなりません。

六

返すときも番号で

受け取ったら、見せる窓口も要ります。素朴版は「名前をもらって、そのファイルを返す」窓口にしました。

端末の画面。show12.py download の結果。naive の ?name=receipt.jpg は 200 で 37850 バイト、?name=../outside.txt も 200 で 85 バイト、中身は「これは、領収書の置き場所の外にあるファイルです(練習用)」。fixed では arai が自分の領収書を開くと 200 image/jpeg nosniff、attachment と filename* 付き。arai が古井さんの領収書を開くと 404、古井さんが新井さんの領収書を開くと 200、ない番号は 404
名前で返す窓口は、../ で置き場所の外のファイルまで返しました(自分の練習用の環境)。番号で返す窓口は、見てよい申請の領収書だけを返し、ほかは 404 です(筆者がクラウド上の Linux で、2026年10月31日に撮影。ウインドウの上の帯は説明のために描き足したもの)
# 名前で返す窓口(素朴版)
  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 版の受け取りの流れ(実測)

fixed 版の受け取り(上から順に。実測の例) ① 大きさは上限以内? 413 12 MB の写真(swallow を広げたとき) ② 見てよい申請?(第11回) 404 ほかの人の申請 ③ 先頭 8 バイトは JPEG・PNG・PDF? 415 fake.jpg(中身は文字) ④ 一時ファイルに書き、sha256 を取る ⑤ 同じ指紋の領収書はない? 409 同じ写真を 5022 にも ⑥ 目録を DB に入れる 500 名前 150 文字。ファイルは残らない ⑦ 自分で付けた名前で移す → コミット 200 receipts/32005963-… ファイルより先に、確かめられることを全部確かめる DB とディスクは1つのトランザクションにならない。ずれたときの後始末を書いておく

移したあとでコミットに失敗したときのために、「取り消されたらファイルも消す」後始末も登録しています。それでも、電源が落ちるような場面では、ずれはゼロにはなりません。目録にないファイルを夜中に探して片づける仕事を、別に用意しておくのが定番です。

八

どこに置くか

ファイルそのものを 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 との「ずれ」の後始末は同じ。見せるときの権限の確かめもアプリで
POINT

ファイルは、大きさ・名前・中身のどれも送る側が決める。名前は自分で付け、中身は先頭で見分け、指紋で二重利用に気づく。返すときも番号と権限で。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 ファイル 1 MB。スマホ写真が断られる。Tomcat の読み捨ては 2 MB まで。
  2. 届いた名前のまま置くと、上書きと置き場所の外。名前は自分で付ける。
  3. 種類は中身の先頭で。Content-Type と拡張子は自己申告。
  4. 中身の指紋(SHA-256)で、同じ領収書の使い回しに気づく。
  5. 返すときは番号と権限で。名前で返すと、置き場所の外まで返す。
  6. ファイルを先に置かない。ずれの後始末を書いておく。

新井5分のつもりが、一日かかりました。

古井上出来だ。ところで、12 MB の写真で返事が来なかったとき、サーバーのログには何が残っていたと思う? 次回はログと監視。困ったときに、あとから何が起きたかを読める形にしよう。

参考

記録は、筆者がクラウド上の 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

← 記事一覧へ