Webアプリのファイルアップロードを安全にするには、厳格な権限設定、機能ごとのサイズ制限、署名付きURL、そして基本的なマルウェアスキャンのパターンが必要です。

プロフィール写真やPDF、スプレッドシートは一見無害に見えますが、外部からシステムに“中身のわからない箱”を送り込める点で初期のセキュリティ事故になりやすいです。受け入れて保存し、他の人に見せ返すと、アプリを攻撃する新たな経路を作ってしまいます。
危険は単に「ウイルスがアップロードされる」ことだけではありません。悪意あるファイルは機密ファイルを漏らしたり、ストレージ費用を膨らませたり、ユーザーをだまして権限を渡させることもあります。「invoice.pdf」という名前でも本当にPDFとは限りません。実際のPDFや画像でも、メタデータを信用したり自動的にプレビューしたり、誤った配信ルールで出すと問題を起こすことがあります。
実際の失敗例は次のような形で起きます:
多くの事故はこうした一つの事実に起因します:ファイルを「保存する」ことと「配信する」ことは同じではないという点です。保存はバイトを保管する場所、配信はブラウザやアプリにそのバイトを届ける方法です。アプリがアップロードをメインサイトと同じ信頼で配信すると、ブラウザがそれを「信頼されたもの」として扱い、問題が発生します。
小規模や成長中のアプリで「十分に安全」と言える状態は、次の4つを手でごまかさずに答えられることです:誰がアップロードできるか、何を受け入れるか、どれくらいの大きさと頻度を許すか、後で誰が読めるか。生成コードやチャット駆動プラットフォームで素早く作っているときでも、これらのガードレールは重要です。
すべてのアップロードを信頼できない入力として扱ってください。安全に保つ現実的な方法は、誰が悪用するか、彼らにとっての「成功」が何かを想像することです。
攻撃者は多くの場合、弱いアップロードフォームを探すボットか、無料ストレージを得る、データをスクレイピングする、サービスを荒らす目的で制限を押し広げようとする実ユーザーです。時には競合が情報漏洩や障害を探っていることもあります。
彼らの狙いは主に次のいずれかです:
弱点を整理すると、アップロードエンドポイントは表玄関(サイズ超過、変なフォーマット、高頻度リクエスト)、ストレージは裏の部屋(公開バケット、誤った権限、共有フォルダ)、ダウンロードURLは出口(予測可能、長寿命、ユーザーに紐づいていない)です。
例:履歴書アップロード機能。ボットが大量の大きなPDFをアップロードして費用を増やし、悪意あるユーザーがHTMLを「ドキュメント」としてアップロードして他者を騙す、など。
コントロールを導入する前に、アプリにとって重要なものを決めてください:プライバシー(誰が読めるか)、可用性(配信を維持できるか)、コスト(ストレージと帯域)、コンプライアンス(どこにデータを保存しどれくらい保持するか)。優先順位があると判断が一貫します。
ほとんどのアップロード事故は派手なハックではありません。「他人のファイルが見えてしまう」という単純なバグです。権限は後付けの機能ではなく、アップロードの一部として設計してください。
まず一つのルールから始めましょう:デフォルトで拒否。すべてのアップロードされたオブジェクトは明示的に許可するまでプライベートと仮定します。請求書、医療ファイル、アカウント書類、ユーザーに紐づくものは「デフォルトで非公開」が強力な基準です。ユーザーが明確に公開を期待する場合(公開アバターなど)にのみ公開にし、その場合でも時間限定アクセスを検討してください。
役割はシンプルに分けておき、責務を分離してください。よくある分け方は:
/user-uploads/のようなフォルダレベルのルールに頼らないでください。読み取り時に所有権やテナントアクセスを毎回チェックすることで、チーム移動や組織離脱、ファイルの割当変更に対処できます。
良いサポートパターンは狭く一時的です:特定の1ファイルへのアクセスを付与し、ログを残し、自動的に期限切れにすること。
多くの攻撃は単純なトリックから始まります:名前やブラウザヘッダーで安全そうに見えるが実は別物のファイルです。クライアントが送るものはすべて信頼しないでください。
まずはホワイトリストを作り、受け入れる正確な形式を決め(例えば .jpg, .png, .pdf のみなど)、それ以外は拒否します。「任意の画像」「任意のドキュメント」は本当に必要な場合以外避けてください。
ファイル拡張子やクライアントのContent-Typeヘッダーを信用してはいけません。どちらも偽装しやすいです。invoice.pdfという名前のファイルが実は実行ファイルということもありますし、Content-Type: image/pngが嘘であることもあります。
より強い方法はファイルの先頭バイト("マジックバイト"やファイルシグネチャ)を検査することです。PNGやJPEGのように、多くの一般形式は一貫したヘッダーを持っています。ヘッダーが許可されたものと一致しなければ拒否します。
実用的な検証セットアップの例:
リネームは見た目より重要です。ユーザー提供の名前をそのまま保存するとパスのトリック、奇妙な文字、上書きの危険を招きます。表示用は元のファイル名を保持しておき、ストレージには生成IDを使いましょう。
プロフィール写真ならJPEGとPNGのみ許可し、ヘッダーを検証して可能ならメタデータを削除してください。文書はPDFに限定し、アクティブなコンテンツがあるものは拒否することを検討してください。SVGやHTMLを後から必要とする場合は実行可能と見なして隔離処理を行ってください。
多くのアップロードによる停止は派手な攻撃ではなく、巨大なファイル、過剰なリクエスト、遅い接続でサーバーが埋まることが原因です。すべてのバイトをコストとして扱ってください。
機能ごとに最大サイズを決め、グローバルな数値は避けます。アバターと税務書類や短い動画で同じ上限は不要です。普通に感じられる最小の上限を設定し、本当に必要な場合にのみ別の「大容量アップロード」経路を設けます。
制限は複数箇所で強制してください。クライアントは嘘をつけるので、アプリロジック、Webサーバ/リバースプロキシ、アップロードタイムアウト、宣言されたサイズが大きすぎる場合の早期拒否などで守ります。
具体例:アバターは2MB、PDFは20MBに制限し、それ以上は署名付きURL経由の別フローにする、といった運用。
小さなファイルでもループで大量にアップロードされればDoSになります。ユーザーごと、IPごとのアップロードエンドポイントにレート制限を設け、匿名トラフィックにはより厳しい制限を検討してください。
再開可能なアップロードはネットワークが悪い本物のユーザーに役立ちますが、セッショントークンは短い有効期限、ユーザーへの紐付け、特定のファイルサイズと保存先にバインドする必要があります。そうしないと“resume”エンドポイントがストレージへの無料パイプになってしまいます。
アップロードをブロックする場合はユーザー向けの明確なエラーを返してください(ファイルが大きすぎる、リクエストが多すぎるなど)。ただし内部情報(スタックトレース、バケット名、ベンダー詳細)は漏らさないでください。
安全なアップロードは受け入れルールだけでなく、ファイルの置き場所と配信方法にも関わります。
アップロードされたバイトをメインのデータベースに入れないでください。多くのアプリはDBに必要なのはメタデータだけです(所有者ID、オリジナル名、検出された型、サイズ、チェックサム、ストレージキー、作成時間)。バイトはオブジェクトストレージや大きなバイナリ向けのファイルサービスに保管します。
公開と非公開のファイルをストレージレベルで分けてください。異なるバケットやコンテナとルールを使いましょう。公開ファイル(公開アバターなど)はログインなしで読めるようにしてもよいですが、私的ファイル(契約書、請求書、医療文書)は誰にも公開しないでください。URLを推測されても読めてはなりません。
可能ならユーザーファイルをメインのアプリと同じドメインから配信するのは避けてください。危険なファイル(HTML、スクリプトを含むSVG、ブラウザのMIMEスニッフィングによる問題など)が漏れるとアカウント乗っ取りにつながる恐れがあります。ユーザーコンテンツ用の専用ダウンロードドメインやストレージドメインを使えば被害範囲を限定できます。
ダウンロード時には安全なヘッダーを強制しましょう。ユーザーの主張ではなく、許可した形式に基づくContent-Typeを設定してください。ブラウザで誤解釈される可能性があるものはダウンロードとして送る方が安全です。
予防的に有効なデフォルト:
Content-Disposition: attachmentを使う\n- 安全なContent-Type(またはapplication/octet-stream)を使う\n- 不透明なオブジェクトキーで保存・配信する(ユーザーファイル名をURLにしない)\n- 非公開ファイルのダウンロードはログに記録する\n
保持方針もセキュリティです。放置されたアップロードを削除し、置換後の古いバージョンを削除し、一時ファイルに期限を設けてください。保存データが少なければ漏洩リスクも減ります。署名付きURL(プリサインURL)は、ストレージを公開にせず、APIを経由せずにユーザーにアップロードやダウンロードを許可する一般的な方法です。URLが一時的な権限を持ち、期限が切れます。
代表的なフロー:
直接アップロードはAPI負荷を下げますが、ストレージのルールやURLの制約が重要になります。
署名付きURLは使い捨ての鍵として扱ってください。具体的かつ短命に作ります。\n\n- 書き込み用URLは短く(多くは1~5分)期限を設定する。読み取り用URLも分単位で十分。\n- URLを期待する正確なオブジェクトキーに紐づける(フォルダ全体ではなく一つのオブジェクト)\n- サポートされていれば、期待するContent-Type、最大サイズ、チェックサムなどの制約を付ける\n- 権限チェックの後にのみURLを発行する\n- 誰がなぜURLを要求したか(ユーザーID、オブジェクトキー、目的、IP/ユーザーエージェント)をログに残す\n
実用的なパターンとしては、まずアップロードレコードを作成して(status: pending)、次に署名付きURLを発行します。アップロード後にオブジェクトが存在し、期待サイズと型に合致することを確認してからreadyにするのが安全です。
安全なアップロードフローは大部分が明確なルールと状態管理です。すべてのアップロードをチェックが終わるまで信頼しないでください。
各機能が何を許すかを書き出してください。プロフィール写真と税務書類が同じファイル形式、サイズ上限、可視性を共有してはなりません。
pendingのようなステータス。\nuploadedにする。\ncleanまたはquarantinedに更新する。スキャンが非同期なら完了するまでアクセスをロックする。\ncleanになって初めてダウンロード、プレビュー、処理を許可する。小さな例:プロフィール写真なら、ユーザーに紐づくavatar用途のレコードを作成し、プライベートに保管して本当にJPEG/PNGか確認し(名前だけでなく)、スキャンしてからプレビュー用のURLを生成します。
マルウェアスキャンは安全網であり、万能ではありません。既知の悪意あるファイルや明らかなトリックを検出してリスクを下げることが目的です。
信頼できるパターンはまず隔離です。新規アップロードはすべてプライベートで保管しpendingにしてチェック後に「クリーン」場所に移動するか利用可能にします。
同期スキャンは小さなファイルやトラフィックが少ない場合にだけ実用的です。多くは非同期でスキャンを行い、ユーザーには「処理中」と表示します。
基本スキャンは通常、アンチウイルスエンジン(またはサービス)といくつかのガードレールから成ります:AVスキャン、ファイル型チェック(マジックバイト)、アーカイブ制限(zip爆弾、ネストされたzip、巨大な解凍後サイズ)、不要なフォーマットのブロック。\n\nスキャナが失敗したりタイムアウトしたり「不明」と返した場合は、そのファイルを疑わしいものとして扱い隔離してください。ここで組織が失敗するのは「スキャン失敗=そのまま配布」にしてしまうことです。\n\nファイルをブロックする際のメッセージは中立的に:"このファイルは受け付けられませんでした。別のファイルを試すかサポートに連絡してください。" とし、マルウェアを検出したと断定する場合は確信があるときだけにしてください。
プロフィール写真(公開表示)と請求書PDF(非公開で請求やサポートに使う)は両方ともアップロードの問題ですが、同じルールを共有すべきではありません。
プロフィール写真は厳格に:JPEG/PNGのみ許可、サイズ上限(例:2–5MB)、サーバー側で再エンコードしてユーザーのオリジナルバイトをそのまま配信しない。チェック後に公開ストレージに置く。\n
請求書PDFは大きめを許容(例:20MBまで)、デフォルトで非公開、メインアプリのドメインからインライン表示しない。\n
シンプルなステータスモデルでユーザーに内部情報を出さずに状況を伝えます:\n\n- pending: ユーザーがファイルを選んだがアップロード開始前\n- uploaded: ストレージがバイトを受領した\n- scanning: バックグラウンドジョブがチェック中\n- clean(または rejected): ファイルが利用可能(またはブロック)\n\n署名付きURLはここにうまく当てはまります:書き込み専用の短命な署名付きURLをアップロードに使い、読み取りはcleanになってから別の短命URLを発行します。
調査に必要なログはファイルの中身ではなく次のようなものです:ユーザーID、ファイルID、型の推定、サイズ、ストレージキー、タイムスタンプ、スキャン結果、リクエストID。生データや文書内部の機密情報をログに残さないでください。
多くのバグは小さな"一時的な"ショートカットが恒久化してしまうことで発生します。すべてのファイルを信頼しない、すべてのURLは共有されると想定する、"後で直す"設定は忘れられる、という前提で設計してください。
繰り返し出る落とし穴:\n\n- クライアント側のチェックだけに頼る(ブラウザは数秒で回避される)\n- ユーザーにパスやファイル名、オブジェクトキーを決めさせる\n- "ちょっと公開"にする設定を残す\n- 長すぎる署名付きURLや複数ユーザーで使えるURLを作る\n- 間違ったContent-Typeで配信し、ブラウザに危険な解釈をさせる\n
監視は多くのチームがスキップする項目です。ストレージ請求が急増するまで見ないことが多いですが、アップロード量、平均サイズ、上位アップローダー、エラー率は監視してください。1つの乗っ取られたアカウントが夜間に大量の大きなファイルを静かにアップロードすることがあります。
例:チームがユーザー提供のファイル名でアバターを保存して共有フォルダに置いていたため、あるユーザーが他人の画像を上書きしてしまった。対策は地味ですが効果的です:サーバー側でオブジェクトキーを生成し、アップロードはデフォルトで非公開、制御されたレスポンスでリサイズ画像を提供する。
出荷前の最終確認としてこのリストを使ってください。各項目をリリースブロッカーとみなし、欠けているガードレールを追加してください。
Content-Type、安全なファイル名、文書にはattachment。許可形式、最大サイズ、誰が何にアクセスできるか、署名付きURLの有効期間、"スキャン合格"の定義を平易な言葉で書き出してください。それがプロダクト、エンジニアリング、サポート間の共有契約になります。
よくある失敗(サイズ超過、拡張子を変えた実行ファイル、権限のない読み取り、有効期限切れの署名付きURL、"スキャン中"のダウンロード)を検出するテストをいくつか追加してください。これらのテストは事故に比べて安価です。
素早く開発と反復を行っている場合は、変更を計画して安全にロールバックできるワークフローを使うと助かります。多くのチームはKoder.ai (koder.ai) のようなツールで計画モードやスナップショット/ロールバックを活用してアップロード規則を段階的に強化しますが、核心は同じです:方針を守るのはUIではなくバックエンドです。
まずはデフォルトで非公開にし、すべてのアップロードを信頼できない入力として扱ってください。サーバー側で次の4点を必ず確認します:\n\n- 誰がアップロードできるか\n- 受け付けるファイル形式(ホワイトリスト)\n- サイズと頻度(サイズ制限+レート制限)\n- 後で誰が読むことができるか(ファイルごとの権限チェック)\n\nこれらに明確に答えられれば、ほとんどの事故を回避できます。
ユーザーが「中身がわからない箱」をアップロードでき、あなたのアプリがそれを保存して他の人に配信してしまうと問題になります。結果として起きることの例:\n\n- 非公開書類への不正アクセス\n- ファイルが信頼されたウェブコンテンツとして配信されフィッシングや乗っ取りにつながる\n- アップロード洪水や巨大ファイルでの障害や高額請求\n\n多くの場合、それは単に「ウイルスがアップロードされた」だけではありません。
保存はバイトをどこかに置くこと、配信はそれをブラウザやアプリに届けることです。\n\n危険なのは、ユーザーがアップロードしたファイルをアプリがメインサイトと同じ信頼で配信してしまうことです。もし危険なファイルが通常のページとして扱われると、ブラウザが実行したりユーザーが過度に信頼してしまう可能性があります。\n\n安全なデフォルトは:まずは非公開で保存し、制御されたダウンロード応答で安全なヘッダーを付けて配信することです。
「デフォルト拒否」を使い、ダウンロードやプレビュー時には毎回アクセスを確認してください。\n\n実用的なルール:\n\n- すべてのファイルレコードに所有者(ユーザー/組織)と用途(avatar、invoiceなど)を持たせる\n- 読み取り/ダウンロード時にそのファイルに対してリクエスターが許可されているかを検証する\n- /uploads/のようなフォルダベースのセキュリティに頼らない\n- サポート用アクセスは一ファイル限定、ログ記録、期限付きにする\n\n実際のバグの多くは「別のユーザーのファイルが見える」という単純なミスです。
ファイル名やブラウザのContent-Typeを信用しないでください。サーバー側で検証します:\n\n- 機能ごとのホワイトリスト(例:アバターはJPEG/PNG、領収書はPDFなど)\n- サーバー側でMIMEタイプを検出し、マジックバイト(ファイルシグネチャ)を確認する\n- 保存時はランダムなIDでリネームし、元のファイル名は表示用のメタデータとして保持する\n- HTML、SVG、スクリプト系のフォーマットは不要ならブロックする\n\nバイトが許可された形式と合致しなければ拒否してください。
障害は多くの場合、地味な乱用から来ます:大量のアップロード、大きすぎるファイル、または遅い接続でサーバー資源を占有することです。\n\n効果的なデフォルト:\n\n- 機能ごとに最大サイズを設定(アバターは小さく、文書は大きく)\n- 複数レイヤーで制限を適用(アプリ+リバースプロキシ+タイムアウト)\n- ユーザー単位やIP単位のレート制限を追加(匿名トラフィックには厳しく)\n\nすべてのバイトをコストとして扱い、すべてのリクエストを潜在的な乱用と見なしてください。
使ってよいが注意深く扱ってください。署名付きURLはブラウザが直接オブジェクトストレージにアップロード/ダウンロードできるようにしますが、バケットを公開する必要はありません。\n\n安全なデフォルト:\n\n- 書き込み用URLは短時間で期限切れに(通常1~5分)\n- 各URLは1つのオブジェクトキーに限定する(フォルダではない)\n- 署名を発行する前に権限チェックを行う\n- 誰がどのファイルのためにURLを要求したかをログに残す\n\n直接ストレージへのアップロードはAPI負荷を下げますが、スコープと有効期限が厳守されるべきです。
最も安全なパターンは、状態管理と明確なチェックを組み合わせることです。一般的な手順:\n\n1. 機能ごとの許可形式とサイズ上限を定義(例:写真は5MBまで、PDFは20MBまで)\n2. バイト到着前に「アップロードレコード」を作成(owner、purpose、オリジナル名、想定最大サイズ、pending等のステータス)\n3. プライベートな場所にアップロード\n4. サーバー側で再検証(サイズ、マジックバイト、ホワイトリスト)→ 合格ならuploadedへ\n5. マルウェアスキャン→ cleanまたはquarantinedに更新。非同期ならチェック完了までアクセスをロック\n\nステータスがcleanになって初めてプレビューやダウンロード、処理を許可します。
スキャンは便利だが万能ではありません。セーフティーネットとして使い、未知のファイルをデフォルトで無害化することが狙いです。\n\n実務的なパターンは「まず隔離」です。新しいアップロードはすべてプライベートで保管してpendingにし、チェックを通過したらcleanに移します。\n\n同期スキャンは小さなファイルや低負荷でのみ実用的です。多くは非同期でスキャンし、ユーザーに「処理中」を返します。\n\n基本的なスキャンには次が含まれます:AVエンジン、ファイル形式チェック(マジックバイト)、アーカイブ制限(zip爆弾、深いネスト、大きな解凍後サイズ)、不要なフォーマットのブロック。\n\nスキャナが失敗、タイムアウト、または“unknown”を返したら、そのファイルは疑わしいものとして隔離し、ダウンロードリンクは出さないでください。スキャン失敗を「許可」に変えないことが重要です。
ファイルがブラウザに解釈されてページにならないように配信します。\n\n良いデフォルト:\n\n- 文書にはContent-Disposition: attachmentを使う\n- サーバー側で決めた安全なContent-Type(またはapplication/octet-stream)を送る\n- URLにユーザーのファイル名ではなく不透明なストレージキーを使う\n- 可能ならユーザーコンテンツ用の別ドメインを使う\n\nこれにより、アップロードされたファイルがフィッシングページやスクリプト実行になるリスクを減らせます。