アップロードパイプラインの堅牢性強化(検証・クリーンアップ)
何のためのものか
アップロードパイプラインの実装後、次の3点が未対応であることに気づいた。
- アップロードされたファイルの中身が一切検証されていない(拡張子・Content-Type・マジックバイトいずれもチェックなし)。TUSは任意のバイト列をそのまま受け入れ、Workerはそれをそのまま
ffmpegに食わせていた。 - 変換成功後もGarageの一時ストレージ(
audio-tmp)に元ファイルが残り続け、削除もライフサイクルルールもなかった。 - Worker側の変換失敗(
FailUploadというsqlcクエリ自体は存在した)が実際には一度も呼ばれておらず、失敗したジョブはRiverが黙ってリトライを繰り返すだけで、uploads.statusがfailedになることがなかった。
/digでこれらを詰め、実装した。
決定事項
ファイル検証: 2層構成、app/workerで分担
appコンテナには元々ffmpeg/ffprobeが入っておらず(backend/cmd/app/Dockerfile参照)、追加もしない方針とした。そのため検証を2段に分けている。
- app側(TUSアップロード完了直後、
Handler.CompleteTUSUpload): Garageから元ファイルの先頭3072バイトだけをレンジGETし、gabriel-vasile/mimetypeでマジックバイトを判定する。FLAC・Ogg(Opus/Vorbis)を正しく認識できることを確認済み(Goの標準ライブラリnet/http.DetectContentTypeはFLACを全く認識せず、Oggもaudio/*ではなくapplication/oggになるため不採用)。 - worker側(
transcodeAudio内、ffmpeg実行前): appが検出したフォーマット名を強制したffprobeで、実際にその形式としてデコードできるかを検証する(validateInputFormat、internal/transcode/ffmpeg.go)。
対応フォーマットはffmpegFormatForMIME(internal/uploads/mimevalidation.go)が持つ閉じた対応表(wav/mp3/flac/ogg)のみ。audio/*で始まるかどうかのヒューリスティックは使わない。後述の-f強制に使う値なので、対応表にない形式を「たぶん音声っぽいから」で通すと強制の意味がなくなるため。
ffmpegの入力フォーマット自動判定(auto-probe)がSSRF攻撃面になる
検証の過程で、ffmpegが入力フォーマットを自動判定する挙動自体が実際の攻撃面であることが分かった。悪意あるファイルがHLS/concatデマルチプレクサとして誤認識されると、ファイル内に埋め込まれたURLへの内部リクエスト(SSRF)や任意ファイル読み込みにつながる(CVE-2023-6601など、2023年としても新しい)。マジックバイト検証を通っただけでは、ffmpeg自身がそれを信用して同じ判定をする保証はない。
対策として、buildFfmpegArgs(internal/transcode/ffmpeg.go)に以下を常時付与している。
-protocol_whitelist file: ネットワークプロトコルハンドラを無効化する。inputPath/outputPathはどちらもos.MkdirTemp配下のローカルパスなので、fileプロトコルの許可だけで正常系は壊れない。-f <format>: appが検出したフォーマット名でデマルチプレクサを強制し、ffmpeg自身の自動判定を無効化する。
formatはjobs.TranscodeAudioArgs.Format経由でapp→workerへ引き渡す。実際のffmpegコマンドはffmpeg -y -protocol_whitelist file -f <format> -i <input> -vn -c:a libopus -b:a 128k <output>。
Worker変換失敗時の挙動: 即座にFailUpload、Riverリトライなし
FailUpload(uploads.status=failed・error_message記録)は元々sqlcクエリとして定義されていたが、どこからも呼ばれていなかった。今回、Worker.WorkがtranscodeAudioのエラー(新設のffprobe事前検証失敗を含む)を受けたら、即座にFailUploadを呼びWorkはnilを返す(Riverはジョブ成功として扱い、リトライしない)よう変更した。フォーマット検証失敗や壊れた音声ファイルはリトライしても成功する見込みが薄いため。FailUpload自体がDBエラーで失敗した場合のみ、そのエラーをWorkの戻り値として返しRiverにリトライさせる。
audio-tmpのクリーンアップ: 即時削除 + ライフサイクルルールの二段構え
- 変換成功時、
finishUploadのDBコミットが成功した後(コミット前ではない。コミット失敗時のリトライで元ファイルが必要なため)に、Workerが元ファイルを一時ストレージから削除する(deleteObject、internal/transcode/audio.go)。削除失敗はログのみ、ジョブは成功扱い。 - 変換失敗時は即座には削除しない(デバッグ・再アップロード猶予のため)。
- 保険として、
audio-tmpバケット全体に30日のS3ライフサイクルルール(Expiration.Days=30、Prefixフィルタなし)を設定している。Garage(v2.3.0)はS3のPutBucketLifecycleConfigurationをサポートする(v0.9以降、garage.tomlではなくS3 API経由)。バックグラウンドワーカーが1日1回(UTC深夜)評価するため即時性はない。 - ルール設定は
backend/cmd/garage-lifecycle-setup(aws-sdk-go-v2/service/s3を使った一回限りのGoユーティリティ、mise run garage-lifecycle-setupまたはmise run setupから実行)で行う。AWS CLIは1コマンドのためだけに新規導入しない方針とし、既存のGo依存で完結させた。このユーティリティはホスト上でgo runされるため、.envのDocker向けエンドポイント(http://garage:3900)ではなくGarageの公開ポート(http://localhost:3900)を使う必要がある(mise-tasks/garage-lifecycle-setupがTMP_AUDIO_S3_ENDPOINTを上書きしている)。本番R2側は同等のルールをCloudflareのダッシュボード/APIで別途手動設定する。
JobTimeoutの明示設定
上記とは別軸だが、実装中にRiverのJobTimeoutがデフォルト1分(暗黙)のままだったことに気づいた。長尺のASMR音声(1〜3時間規模)の変換には短すぎるため、backend/cmd/worker/main.goのriver.ConfigにJobTimeout: 10 * time.Minuteを明示設定した。トラブルシューティングはtroubleshooting参照。
テスト方針
ffmpegFormatForMIME(純粋関数、MIME文字列→ffmpegフォーマット名のマッピング)はユニットテスト済み。completeTUSUploadTxのフォーマット検証分岐(FailUpload呼び出し、SetUploadTranscoding/ジョブ投入をスキップ)もfakeベースでユニットテスト済み。detectUploadFormat(S3レンジGET + mimetype判定)・validateInputFormat(ffprobe実行)・deleteObjectは実I/Oを伴うため、既存のdownloadObject/uploadObjectと同じ境界に沿ってユニットテスト対象外とし、scripts/e2e-smoke.shの「Non-audio upload rejection」セクションで実際に非音声ファイルをアップロードし、uploads.status=failed・error_messageが記録されることを検証している。
対象外・フォローアップ
- ウイルス/マルウェアスキャン(ClamAV等)は導入していない。コスト対効果の判断が必要な別テーマとして見送った。
ffmpegプロセス自体のサンドボックス化(seccomp、ulimit等)は、Dockerコンテナ内で動いている以上の追加インフラ強化として別途検討。解消済み: ffmpeg/ffprobeの実行は、ネットワークも認証情報も持たない専用の隔離コンテナ(cmd/transcoder)へ分離した。詳細設計はtranscode-sandboxを参照。- 開発用DBに対するのと同様、本番R2側のライフサイクルルール設定手順はまだ文書化していない。