今まで色んな案件で同じようなファイルアップロード処理を見てきたので、共通のコンテナにできたらいいかも、と思ってAIに作ってもらいました。
よくある要件というのは、
- フロントからアップロードされたファイルはS3に置く。できれば一度tmpに書いたりせずにストリーミングで処理したい
- ファイルアップロード機能をウェブアプリで提供したいがウィルススキャンを必ずする
よくある方の前者はフレームワーク側で一度ローカルのtmpに置くとストレージの小さい(tmpがtmpfsだったりする)コンテナとかLambdaで動かすとちょっと嬉しく無いかもねという感じです。
よくある方の後者はだいたい「ビジネス要件」というよりもセキュリティ要件みたいなIT部門の受け入れ要件になっていますね。おそらく、根拠はOWASP Application Security Verification Standardでは無いかと思います。最新の5.0だと5章のFile Handlingにいくつか項目がありますね。
これをよく読むと、アップロードしたファイルをユーザーがダウンロードするような要件がなければ、例えば、アップロードされたCSVファイルをパースして取り込むみたな機能であればウィルススキャンは不要そうです。とはいえ、「これがルールです。ダメなら受け入れられません」と言われたらまあ対応せざるを得ないこともあるかもしれません。
AWSにもGuard Dutyがあってウィルススキャン流行ってくれる機能はあるのですがアップロードしたあとにしばらくしたらタグがつくという機能です。そのタグをEventBridgeで拾って・・・みたいな感じですが、可能ならチェック済みのみファイルのみが上がって欲しいというか非同期処理が挟まると面倒ですよね。
それ以外にも結構ヘビーな要件が結構多いですね。
- 5.2.1 アプリケーションが処理できるサイズのファイルのみを受け入れること
- 5.2.2 アプリケーションがファイルを単独で、または zip ファイルなどのアーカイブ内で受け入れた際に、ファイル拡張子が期待されるファイル拡張子と一致しているかを確認し、内容が拡張子が表すタイプと一致しているかを検証する
- 5.2.3 アプリケーションが圧縮ファイル(例:zip、gz、docx、odt)を許容される最大未圧縮サイズおよび最大ファイル数に対してチェックしていることを確認し、ファイルを解凍する前に確認する
- 5.2.4 単一のユーザーが過剰なファイルや過度に大きなファイルでストレージを埋められないよう、ファイルサイズクォータとユーザーあたりの最大ファイル数が適用されることを確認する
- 5.2.5 アプリケーションが、特に必要な場合を除き、シンボリックリンクを含む圧縮ファイルのアップロードを許可していないことを確認する
- 5.2.6 アプリケーションが、許容される最大ピクセルサイズを超えるピクセルサイズのアップロード画像を拒否していることを確認する
毎回みんながそれを実装するのは大変そうなので、共通ミドルウェア化したら良さそうだなと思いました。
作ってみたもの
StreamUploaderという名前で作ってみました。システム構成としてはこんな感じです。ファイルアップロード前にアップロードキーを取得します。そのキーを使ってStreamUploaderにファイルの実体を送ります。
裏では以下のようなチェックなどをします。
- S3に投げる前にチェックをする(除外ファイル形式とかは設定変更可)
- 事前にマジックバイト(先頭の方)を見てコンテンツ内容との不一致のチェックや許可されているコンテンツかどうかなどをチェック
- 圧縮ファイルの中のチェック
- zipbombチェック
- xlsxやPDFはマクロやJSなどの動的要素をエラーする
- 画像ファイルは回転情報とカラープロファイル以外のプライバシーの問題がありそうなEXIF情報削除
- ClamAVでウィルススキャン
- 非同期でコンテンツ作成
- サムネイル作成
- OCRやオフィスファイルのテキスト抽出
zipファイルは仕方ないので全量ダウンロードしてからのチェックをしていますが、ClamAVのウィルススキャンの間、ファイルをオンメモリに持ち続けるとメモリを消費してしまうので、MultiWriterでS3アップロードと、ClamAVのTCPのAPIに並行で投げて、正常なら最終的な名前にリネーム、エラーがあったらS3側を削除という形にしています。
検索エンジンなども入れたらいいかと思ったのですが、アプリごとのカスタマイズとか多そうなのでテキストの抽出ぐらいにしています。
使い方
フロントエンドからすると、StreamUploaderにfetch()でファイルを送ると、ファイルタイプのチェックやウィルスチェックがその場で行われ、4xxエラーが返ります。GuardDutyだと送信そのものは成功し、結果は別途非同期で取ってこないといけないので、それと比べるとだいぶシンプルです。
アップロードキーはちょっと面倒に見えますが、S3アップロードにSigned URLを使うのとほぼ変わらないですし、ファイルドロップをされた瞬間に取得してファイルを送信してキーを保持しておく感じですね。そして確定されたタイミングでそれをバックエンドに成功したキーだけ送ると、例えばファイル本体とそのファイルの説明を一緒に送るみたいなところはシンプルにできます。
なおファイル名やcontent-typeはキー取得時に送っています。というのも、FileオブジェクトをFormDataでラップして送ると、サーバー側でストリーミング処理しにくいです。デコード処理をしないといけないので。Fileオブジェクトをそのままfetch()のbodyに渡せばファイルの中身だけを送ることになるのでサーバー側でストリーミングで処理しやすくなります。
fileInput.addEventListener("change", async () => { |
他にも、fetch()だとやりにくいファイルアップロード進捗を別途WebSocketで返すエンドポイントがあったりします。
まとめ
結構、仕事の中だと「時間がないから最低限で」という感じで済ますということが結構多いんじゃないかなと思います。生成AIのおかげでそういうアイディアが形にしやすくなったのは良いですね。
まあみんながこれを使うというよりも、きっとこのアイディアを元にみんな各々実装するという形になるかもしれませんが、きちんと実証実験を行なっておけるのは単に頭の中でずっと考えているだけよりもずっとよいですね。スッキリしました。
サムネイル作成はGoのライブラリがあるものはそれを使い、CLIでオフロードできるものは積極的に使うみたいな感じでやっています。ただ、Officeファイルのサムネイル作成。LibreOfficeを入れるとイメージサイズが1GB増えちゃうので、そこもなんかライブラリ作りたい気がしています。