diff --git "a/source/_posts/20240711a_\343\203\206\343\202\271\343\203\210\350\250\230\344\272\213.md" "b/source/_posts/20240711a_\343\203\206\343\202\271\343\203\210\350\250\230\344\272\213.md" new file mode 100644 index 000000000000..311a70b24e37 --- /dev/null +++ "b/source/_posts/20240711a_\343\203\206\343\202\271\343\203\210\350\250\230\344\272\213.md" @@ -0,0 +1,225 @@ +--- +title: "署名付きURLを利用したファイルアップロードWeb API設計の勘所" +date: 2024/07/05 00:00:00 +postid: a +tag: + - 署名付きURL + - ファイルアップロード + - 設計 + - WebAPI +category: + - Programming +thumbnail: /images/20240705a/thumbnail.png +author: 武田大輝 +lede: "現代のWebアプリケーションにおいて、ユーザが写真や動画などのファイルをアップロードする機能は、しばしば求められます。本記事では、ファイルアップロードを実現するための一手段として、「署名付きURL」を利用した方式を取り上げ、その設計について詳しく解説します。" +--- +## はじめに + +現代のWebアプリケーションにおいて、ユーザが写真や動画などのファイルをアップロードする機能は、しばしば求められます。 + +本記事では、ファイルアップロードを実現するための一手段として、「署名付きURL」を利用した方式を取り上げ、その設計について詳しく解説します。 +今回は、Amazon Web Services(AWS)を利用する前提のもと、このアプローチを探求していきます。 + +前半部分は署名付きURLをそもそもよく知らない方向けの導入部となっていますので、要点だけ抑えたい方は[設計上のポイント](#設計上のポイント)から読まれることをお勧めします。 + +## ファイルアップロードの実現方式パターン + +署名付きURLの話をする前に、ファイルアップロード機能をWeb APIとして実現する方式について、いくつか代表的なものを紹介します。 + +### Pattern 1. multipart/form-data + +`multipart/form-data` は、ファイルとその他のフォームデータを一緒に送信するためのエンコーディング形式です。この形式では、各部分が境界(boundary)によって区切られ、それぞれの部分にはヘッダとコンテンツが含まれます。これにより、ファイルやバイナリデータをテキストデータと共に安全に送信することができます。 + +これは、ファイルアップロードにおいて古くから利用されている一般的な方法で、[RFC 2388](https://www.ietf.org/rfc/rfc2388.txt)でも定義されています。 + +王道の方式である一方で、REST APIなどJSONベースのAPIシステムとの親和性が低く、ブラウザ以外のクライアントにとっては手動でエンコードを行うなど手間がかかることもあります。 + +### Pattern 2. Base64 + +ファイルをBase64形式にエンコードして、通常のフォームデータとして送信する方法です。 +サーバ側で特別な処理を行う必要がなく、通常のJSONベースのAPIと変わらずに取り扱いができるというメリットがある一方で、Base64エンコードによりファイルサイズが大きくなるというデメリットもあります。 +そのため、巨大なファイルを取り扱う場合には不向きであり。プロフィール画像やサムネイル画像など限定されたユースケースでの利用が向いていると考えられます。 + +GitHubの[Contents API](https://docs.github.com/ja/rest/repos/contents?apiVersion=2022-11-28)でも採用されており、この方式もまた「`multipart/form-data`」と同様に一般的な方式と言って差し支えないでしょう。 + +### Pattern 3. 署名付きURL + +本記事が取り上げる方式です。 + +この方式は、クラウドストレージの普及と共に広く利用されるようになり、今では広く慣れ親しまれた方式です。 +余談ですが、筆者は2013年に公開された Qiitaの画像アップロードの実装事例[(Qiitaの画像アップロード機能も簡単に実装できる。そう、S3ならね。)](https://qiita.com/yuku_t/items/40b7daf018d3dab48974)を見て、この方式を知りました。 + +この方式の詳細について次の章で見ていきましょう。 + +## 署名付きURLによるファイルアップロード + +署名付きURLによるファイルアップロードとは、特定の期間内にのみ有効な一時的なURLを生成し、そのURLを使用してアップロードを行う方法です。 +この方式により、自分たちのサーバに負荷をかけず、直接ストレージにファイルをアップロードすることがセキュアに実現できます。 + +なお、Amazon API Gateway + AWS Lambdaといったサーバレス構成を取る場合などは、ペイロードサイズの制限[^1]やタイムアウトの制限[^2]、コスト面などの観点からこの手法が第一候補となります。 + +署名付きURLを用いたファイルアップロードを行う場合、次のような処理フローが一般的です。各処理について詳しく説明していきます。 + +image.png + +### 1. 署名付きURLの生成 + +クライアントからのリクエストに応じて、署名付きURLを生成します。 +署名付きURLは各クラウドサービスの提供するSDKを利用することで簡単に生成することができます。 + +AWS SDK(for Go V2)を利用してS3アップロード用の署名付きURLを作成する簡易的な例は次の通りです。通常のPUTを行う際のリクエストを用いてバケット名やオブジェクト名を指定して、署名付きのURLを生成しています。 + +```go +// 15分間有効な署名付きURLを生成 +presign, err := client.PresignPutObject(ctx, &s3.PutObjectInput{ + Bucket: aws.String("your-bucket-name"), + Key: aws.String("your-object-name"), +}, s3.WithPresignExpires(15*time.Minute)) +if err != nil { + log.Fatalf("failed to sign request: %v", err) +} +fmt.Printf("the presigned URL is: %s\n", presign.URL) +fmt.Printf("the HTTP method is: %s\n", presign.Method) +fmt.Printf("the HTTP header is: %v\n", presign.SignedHeader) +``` + +署名は [AWS Signature Version 4](https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_aws-signing.html) の仕様に基づいて実行環境内でローカルに生成されます。 +稀に誤解されることがありますが、署名を作成する時点ではS3に対するネットワーク通信は発生しません。 + +### 2. ファイルアップロード + +生成した署名付きURLに対して、実ファイルをアップロードします。 +前述のサンプルコードの通り、署名付きURLの生成時に、アップロード時に用いるHTTPメソッド及びHTTPヘッダも返却されるため、それらを利用することが望ましいでしょう。 + +### 3. ファイルメタデータ登録 + +ファイルが正常にアップロードされた後に、ファイルのメタデータ(例. ファイルパス、ファイルサイズ、アップロード時刻など)をバックエンドに登録します。 +この手順は必須ではありませんが、多くのケースにおいて必要となるでしょう。 +例えば、ユーザがプロフィールを登録する場合、プロフィール画像のアップロードが完了した後に、ユーザのプロフィール情報(例. 名前や年齢など)と画像パスをAPIで登録するケースが想定されます。 + +## 設計上のポイント + +ようやくここからが本題です。 +署名付きURLによるファイルアップロード処理を設計する際のポイントについていくつか説明していきます。 + +### 署名付きURLの有効期限は不必要に長くしない + +署名付きURLは有効期限を設定することが一般的(AWSにおいては必須)です。 +署名付きURLは、そのURLを知っていれば誰でもアクセスできてしまうという特性があります。そのため、有効期限を短く設定することで、万が一漏洩した場合でもセキュリティリスクを軽減することができます。 + +ファイルアップロード用の署名付きURLについては、アップロード処理の直前に発行することが多く、その場合は非常に短い有効期限(数十秒 ~ 数分程度)を設定することができます。 + +また、少し話は逸れますが、漏洩時のリスクを減らすという観点では、アップロード元のネットワークが限られている場合、バケットポリシーでIP制限を行っておくことも有効です。 + +### 署名付きURL生成時にアップロード時の制限をかける + +悪意のあるユーザが、アップロード時に不正な拡張子や `Content-Type` を指定したり、許容していないサイズのファイルをアップロードしたりすることを防ぐ必要があります。[^3] + +このためには、署名付きURLを生成する際のリクエストとして、アップロードしたいファイルの拡張子やファイルサイズを受け取り、それが適切か検証を行なった上で、問題ない場合のみ署名付きURLを生成することが求められます。 +署名付きURLの生成時には、`ContentType` や `ContentLength` を指定することで、ファイルアップロード時に偽装を行えないようにします。これにより、万が一署名付きURL生成時のリクエストと異なる拡張子やファイルサイズが指定された場合でもアップロード時にエラーとして弾くことができます。 + +サンプルコードを次に示します。 + +```go +// (例) 要求されたファイルサイズが100MBより大きい場合はエラー +if 100*1024*1024 < req.Size { + // エラー処理 +} +// (例) 要求された拡張子が、jpg, jpeg, jpe 以外の場合はエラー +if !slices.Contains([]string{"jpg", "jpeg", "jpe"}, req.Extension) { + // エラー処理 +} + +// 15分間有効な署名付きURLを生成 +presign, err := client.PresignPutObject(ctx, &s3.PutObjectInput{ + Bucket: aws.String("your-bucket-name"), + // 要求された拡張子をオブジェクトキーに付与 + Key: aws.String("your-object-name." + req.Extension), + // 要求された拡張子を元にコンテンツタイプを指定 + ContentType: aws.String(mime.TypeByExtension("." + req.Extension)), + // 要求されたファイルサイズを指定 + ContentLength: aws.Int64(req.Size), +}, s3.WithPresignExpires(15*time.Minute)) +``` + +許容するファイルサイズや拡張子は、アップロードするファイルの種別(動画/画像など)によっても異なる可能性があります。その場合は、ファイル種別をリクエストとして受け取り、ファイル種別に応じて異なる検証を行うなどの設計が考えられます。 + +なお、オブジェクトキーに拡張子を指定したとしても、署名付きURLでは拡張子の偽装には対応できないため、厳密に制御するためには後続の処理で別途検証を行う[(後述)](####セキュリティの向上)必要があります。 + +### アップロード時に必要な情報は全てレスポンスで返却する + +これは署名付きURLを発行するAPIのレスポンス設計についての話です。 +レスポンスとして返却する項目は、署名付きURLのみではなく、ファイルアップロード時に指定するHTTPメソッドやHTTPヘッダも合わせて返却を行うのが良いでしょう。 + +```jsonc レスポンスJSONのイメージ +{ + "method":"PUT", + "url":"https://your-bucket-name.s3.ap-northeast-1.amazonaws.com/your-object-name.jpeg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=YOUR_ACCESS_KEY_ID%2F20240704%2Fap-northeast-1%2Fs3%2Faws4_request&X-Amz-Date=20240704T123456Z&X-Amz-Expires=3600&X-Amz-SignedHeaders=host&X-Amz-Signature=abcdefghijklmnopqrstuvwxyz1234567890abcdef +", + "header":{ + "Host":[ + "your-bucket-name.s3.ap-northeast-1.amazonaws.com" + ], + "X-Amz-Server-Side-Encryption":[ + "AES256" + ], + "Content-Type":[ + "image/jpeg" + ] + // 省略... + } +} +``` + +クライアント側にはアップロード時に必要な情報をハードコードせず、APIのレスポンスを元にアップロード処理を動的に組み立てることで、クライアントとオブジェクトストレージを疎結合にすることができます。 +このようにしておくことで、例えば仕様変更によりアップロード時に指定すべきヘッダを追加する必要がある、オブジェクトストレージをS3からGoogle Cloud Storage(GCS)に変更する必要がある、といった場合でもクライアント側に手を加えずに対応することができます。 + +### テンポラリバケットを経由する + +署名付きURLによるファイルアップロードは、一時的なバケットを経由して、本バケットにアップロードを行う方式を推奨します。 + +具体的には、署名付きURLによるファイルアップロードは一時的なテンポラリバケットに対して行い、後続のファイルメタデータ登録のAPIの中で、テンポラリバケットから本バケットにファイルをコピーする設計が考えられます。 + +image.png + +テンポラリバケットを設ける理由としては2つあります。 + +#### セキュリティの向上 + +テンポラリバケットを経由することで、外部からのアップロードに対して本バケットへの直接アクセスを制限します。本バケットに直接アクセスする機会を減らすことで、データの漏洩や不正アクセスのリスクを低減できます。 + +また、テンポラリバケットに保存されたファイルを本バケットにコピーする前に、必要に応じてウイルススキャンやデータの検証、フィルタリングを行うことができます。この段階で問題が発見された場合、ファイルを本バケットにコピーせず弾くことで、不正なファイルが本バケットに登録されることを防ぎます。 + +拡張子の偽装などに備えファイルをバイナリレベルでチェックしたり、個人情報の観点からEXIFを除去したり、本登録前にさまざまな前処理を行うケースが考えられます。 + + +#### ゴミファイルが残らないようにする + +テンポラリバケットを設けず、直接本バケットにファイルアップロードを行う場合、次のようなケースにおいて不要なファイルが本バケットに残り続けます。 + +* 後続のファイルメタデータ登録APIでエラーが起きるなど、全ての処理が正常に完了しない状態で、ユーザが離脱した +* ファイルアップロード後に、ファイルの誤りに気づき、別のファイルが再度アップロードされた + +テンポラリバケットを設けることで、必要なファイルのみを本バケットに保持し、本バケットをクリーンに保つことができます。 +テンポラリバケットは、ライフサイクルポリシーを設定することで、一定期間が経過したファイルを自動的に削除するようにしておくと良いでしょう。 + + +### 署名付きURLを独自ドメインで運用する + +S3の署名付きURLはドメインにバケット名が含まれますが、セキュリティの観点から不用意にバケット名を露出させたくないというケースがあるかもしれません。他にもさまざまな理由で独自ドメインを利用したいケースが考えられます。 + +AWSのサービスを利用する場合は、CloudFrontをS3の前段に置き、CloudFrontの署名付きURLを利用することで、独自ドメインの署名付きURLを発行することができます。 + +ただし、CloudFrontの署名付きURLを利用する場合、S3の署名付きURLと異なり、`Content-Type`や`Content-Length`を制限することはできません。他にも、CloudFrontを経由することで追加のコストが発生するなど、メリット・デメリットを踏まえた上で慎重に検討すると良いでしょう。 + +CloudFrontの署名付きURLを利用したアップロードはあまり事例がオープンになっていないので、機会があれば手順を別途ブログ化したいと思います。 + +## おわりに + +署名付きURLを利用したファイルアップロード設計の勘所をいくつか紹介しました。 +他にも設計上のポイントがあればぜひ [@future_techblog](https://x.com/future_techblog) などでフィードバックいただけますと幸いです。 + +[^1]: API Gateway のペイロードサイズ上限は[10MB](https://docs.aws.amazon.com/apigateway/latest/developerguide/limits.html)、Lambdaのペイロードサイズ上限は[6MB(同期)](https://docs.aws.amazon.com/lambda/latest/dg/gettingstarted-limits.html#function-configuration-deployment-and-execution)となります。 +[^2]: これまでAPI Gatewayの統合タイムアウト29秒がネックになるケースが多くありましたが、[先日のアップデート](https://aws.amazon.com/jp/about-aws/whats-new/2024/06/amazon-api-gateway-integration-timeout-limit-29-seconds/)で緩和が可能になりました。 +[^3]: [S3経由でXSS!?不可思議なContent-Typeの値を利用する攻撃手法の新観点](https://blog.flatt.tech/entry/content_type) という記事では、不正な `Content-Type` の指定による攻撃手法が紹介されています。 +