AWS CloudFrontを使って動的にリサイズ可能な画像をセキュアに見れる仕組みを作った

こんにちは、開発支援Gでインフラ運用をしている大島です。

簡単に画像を閲覧出来ないようにするには?

アップロードされたものやプロダクトのアセットファイルなど AWS S3 ( 以下 S3 ) 上に置いてある画像ファイルの公開に制限を設けたいといったユースケースはよくあると思います。サーバー側で常に認証を行ってから画像を返せば良いのですが、そうするとサムネイル画像を一括で取得する処理などサーバー側に負荷がかかってしまうというデメリットが浮上してしまいます。

さらに近頃のアプリケーション開発事情では、iOS、Android、webなど様々な環境から多様ななサイズで画像を取得したいという要求が出てきがちではないでしょうか。で、この画像サイズを動的に返す処理をサーバー側で常に実行していると、更に負荷が高まってしまうわけです。

そこで、画像だけを直接S3から取得してきてほしいということになるんですが、今度は「認証どうするんだ?」という話になってしまいます。

つまり、以下の要件を満たすものが作りたいということです。

  • キャッシュしてくれる
  • 動的にリサイズできる
  • 認証できる
  • サーバー以外から取得できる

今回はサーバーのアプリケーションにRuby on Rails ( 以下Rails ) を使ったケースを例にして説明していきます。

CloudFront + nginx(ngx_small_light) + S3 という解決策

最終的な構成図はこうなります。CloudFrontでリクエストを受けつつ、オリジンをS3ではなく一旦nginxで受けてからS3へプロキシするという構成です。

全体構成図
component 役割
CloudFront キャッシュ、署名の検証、カスタムヘッダの付与
ALB ロードバランサー
nginx S3のproxy
ngx_small_light nginxのプラグイン。動的画像リサイズ処理
VPC Endpoint VPCからS3への安全な接続
S3 bucket policyでVPCからのみ許可

画像アップロード時の処理シーケンス

画像アップロード時のシーケンス図

今回は大量のuploadが同時に来ることを想定していないのと画像処理はしないものとしています。ここは単にRailsがS3に受け取った画像をuploadして保存したS3のkeyをDBに保存するだけです。このとき、S3に保存するkeyがURLの一部になるので推測不可能なハッシュ値で保存するようにしておきます。もちろん、Railsを通すのでアプリケーション側での認証が可能です。

ちなみに、この部分もオフロードしたい場合は、S3のpresigned URLを発行し、そのURLに対してuploadしてもらうのが良さそうだと料理画像判定のためのバックエンドアーキテクチャを見て作った後に思いました。

画像取得時の処理シーケンス

画像取得時のシーケンス図

署名付きURLは、画像リストの取得時にCloudFrontが使う秘密鍵と同じ鍵でURLを署名してクライアントに返します。この署名付きURLはクエリに長い文字列が入っているため推測は更に難しくなるだけでなく、有効期限も設定できます

クライアントは画像を取得する際にHTTPのカスタムヘッダーに画像サイズを指定して署名URLに対してリクエストを行います。サーバー側は下記にある処理を行います。

  1. CloudFrontが署名を検証
  2. ロードバランサーにリクエストを転送
  3. nginxはS3から画像を取得
  4. nginxでS3から取得した画像をリクエストされたサイズにresize
  5. nginxは画像を返す

ここから先はそれぞれの工夫ポイントを紹介していきます。

resizeの指定方法をカスタムヘッダー方式にした

実はnginxはwebサーバーだけでなく、その気になればいろいろなことができます(怖いくらいに)。

nginxにはimage_filterという画像を動的に変更するための機能が標準で備わっていますが、この他にngx_small_lightという有名なプラグインがあります。これは、small_lihgt(dw=200,dh=200)のようにリクエストを呼ぶとImageMagickを使ってnginx上で画像を動的に変換して返却してくれるのが特徴です。

ただし、CloudFrontの署名付きURLの仕組みを使っていると、URLにサイズ指定が入ってしまい、結局サーバーはクライアント側のリクエストに沿ってサイズごとに署名URLを作成してあげる必要があります。

そこで、URLではなくHTTPのカスタムヘッダーでサイズを渡すように変更しました。幸運なことにも、CloudFrontは指定したHTTPのカスタムヘッダーをキャッシュ対象にする設定ができるので、Webサーバーへの負荷も軽減できてしまいます!!

# before
GET /w/200/h/200/image.png
# after
GET /image.png
x-image-height: 200
x-image-width: 200

セキュリティ強化のためCloudFrontでカスタムヘッダーを追加する

CloudFrontの仕組み上、nginxサーバーがぶらさがっているALBに転送するには、このALBがpublicなところに置かれている必要があります。そのため、このALBのURLが漏洩すると直接アクセスされてしまいます。推測不可能なURLにすることである程度安全性を高めてはいますが、認証なしでアクセスできる状態には変わりありません。

そこで、正当なCloudFrontからのリクエストであることを証明するために、CloudFrontを通過して転送する際にカスタムヘッダーを追加します。CloudFrontでx-hogehoge-token: super_secret_tokenといったヘッダーを追加し、nginx側でヘッダーの値をチェックする仕組みを入れます。

if ($http_x_hogehoge_token != "super_secret_token") {
    return 403;
}

これでCloudFrontからのリクエストであることがある程度保証されます。さらにこのsecret tokenの値を定期的に変えてあげれば、更に安全性は高まります。

S3へプロキシする際の認証方法

ここが意外といろいろな方法があって悩ましかったところでした。2つやり方があるのでまずそれを説明します。

その1. IAM UserでREST認証してproxyする方法

社内のとある別プロジェクトでは、set-misc-nginx-moduleでlua経由でREST認証用のauthorizationヘッダーを生成してアクセスするというものが使われていました。ただし、これはAWS Signature Version2 という時代の認証方式であり、nginx.confにsecret keyをベタ書きする必要があります。

これはまずいということで、2・3年前からAWS Signature Version4 という認証方式が出てきているようです。普通にAWS CLIとか使っていればVersion4になってることでしょうから、多くの方は殆ど意識してないかと思います。curlでAWSのAPIを叩く人なんてめったにいないでしょうから。

で、v4認証を使ってREST認証をするnginxのプラグインとして、そこそこデファクトっぽいngx_aws_authというのがあります。v4認証の場合はsecret keyを元手に署名したkeyをnginx.confに書くことになるのでセキュリティ的に安全ですが、この署名したkeyは最大7日までの有効期限となってしまいます。更に短くすることは出来るのですが、長くすることは出来ないようでした。

ということは、このS3へproxyするnginxを7日以内に常に更新する必要があるということです。

nginx.confを書き換えてHUPシグナルを送ればnginxの再読み込みを走らせることはできますが、ECSで動かしてるので個別にsignalを送るみたいな機能はありません。全インスタンスにログインしてdocker kill --signal="HUP"するみたいなめんどくさい処理を書くか、ダウンタイムは無くともが再デプロイするぐらいしかないという感じでした・・・。

その2. VPC Endpointを使ってS3へのアクセスをVPCに限定する

この頃、ちょうど別件で調べ物をしていたときに、VPC Endpoint経由なら認証不要でかつVPC内からのみのアクセスしか受け付けないということができるということが分かりました。

こんな風にbucket policyを設定するだけです。

{
    "Version": "2012-10-17",
    "Id": "Policy1415115909152",
    "Statement": [
        {
            "Sid": "Access-from-specific-VPCE-only",
            "Effect": "Allow",
            "Principal": "*",
            "Action": "s3:GetObject*",
            "Resource": [
                "arn:aws:s3:::acl-test",
                "arn:aws:s3:::acl-test/*"
            ],
            "Condition": {
                "StringEquals": {
                    "aws:sourceVpce": "vpce-1111111"
                }
            }
        }
    ]
}

VPC内のインスタンスからなら全アクセスできてしまうという問題はありますが、これならプラグイン入れる必要なく、単にS3にproxy_passするだけでいけるようになります!

proxy_pass /assets/rmp/techblog_bucket/$bucket

まとめ

nginxのrewriteの方法や、nginxとOpenRestyの関係性、「luaって何だ 🤔」、CloudFrontの各要素など多くを学んだ一件でした。

今回、インフラ構築を始める前にPlantUMLを使ってシーケンス図を書くところから始めました。PlantUMLは以前から使ってたものの、インフラの構築する前にシーケンス図を書いていみるというのが非常によかったです。サーバーチームのメンバーともシーケンス図をもとに議論したおかげで余計なインフラも作らずに済んで手戻りも防げたし1)当初はRedis作ろうとしてた、相手にも伝わりやすかったなと思います。PlantUML最高!シーケンス図最高!!

最後に、本記事はセキュリティ面でリクルートテクノロジーズのセキュリティ部隊であるRED TEAMのみなさんにレビューしていただきました。ありがとうございました。

そんなRED TEAMの紹介記事はこちらです。

脚注

脚注
1 当初はRedis作ろうとしてた
関連職種の採用情報
詳しくはこちら