この記事は、Techouse Advent Calendar 2024 5日目です。
昨日は nakayama さんによる 開発チームマネージャが考える、Techouseインターン生が圧倒的成長ができる3つの理由 でした。
はじめに
こんにちは、2024年に新卒で入社し、ジョブハウスでバックエンドエンジニア/モバイルアプリエンジニアをしているnozomemeinです。
Ruby on Rails の Active Storage は お手軽に画像ファイルなどをクラウドストレージに保管できる便利なライブラリです。
しかし正しく利用するにはクラウドストレージの仕様やWeb標準への理解が必要不可欠です。
今日はそんなActive Storageを使って、 Amazon S3 に画像ファイルを保存するというありがちなパターンで色々沼った話をしようと思います。
ある日のこと
社内ユーザ向けの履歴書生成ツールを開発していました。
「今まで外部ツールで使っていたんだけど、データがサービスのDBに入らないので、社内で内製してほしいよ〜」という要望があったためです。
このツールは求職者の情報を入力し、顔写真をアップロードすると日本のJIS規格のようなA4サイズの履歴書をPDF形式データでダウンロードできる、というものです。
表画面と裏側はRuby on Railsで作られており、PDFダウンロード機能をhtml2pdf.jsを使うことで実装しました。
また証明写真をアップロードするために、Ruby on Rails の Active Storage を採用しました。
Active Storageは、Amazon S3(AWS)やCloud Storage(Google Cloud)を意識しないでアセット(画像など)を扱えるようにする仕組みを提供してくれる非常に便利なライブラリです。
html2pdf.jsはどうやって動いている?
html2pdf.jsを履歴書ダウンロード機能として利用しているという話をしましたが、 実はこのライブラリは内部的には下記に示した2つのライブラリの組み合わせで動いています。
html2canvasの役割
html2canvasでは文字通り、htmlのDOMElementをCanvasに変換した後、画像にしています。
WebページのDOMやCSSを読み込み、その結果を元に解釈した結果をCanvasエレメント上に描画するライブラリということですね。
ここで面白いのが、スクリーンショットを撮っているわけではなく、挙動としてはレンダリングエンジンに近いです。 techblog.kayac.com なので、DOMとCSSをパースしてCanvasに変換しているすごいライブラリです。
Canvasにすることで何が嬉しいかというと、一度Canvasにしてしまえば画像に変換できるからです。 CanvasはDataURL(画像などのバイナリをbase64エンコードすることでインライン化)への変換をサポートしており、Canvasから画像形式への変換が可能になっています。
jsPDFの役割
こちらは画像をPDFに埋め込んでファイル化する役割を担っています。
まとめると
上記の内容から、下記のような図としてまとめることができます。
HTMLからCanvasを経由して画像化し、 それをjsPDFが画像としてPDFに埋め込んでダウンロードできるようにする、と言った流れです。
なかなかに面白いですね!!。 ちなみになぜhtml2pdf.jsを選んだかというと、
- 利用にあたりDSLが必要なく、HTML/CSSだけでデザインを組めること
- クライアントで処理が完結すること
- ライブラリのスター数やメンテ頻度
をベースに選定しました。
問題発生!!
さて、話はそれましたが、ここまで特に大きなトラブルにも遭遇せずに、ローカル環境で順調に開発を進めていました。
いよいよリリースを間近にしたタイミングで、本番環境相当の検証環境での検証を行うことになりましたが、 検証環境にあげたところPDFがダウンロードできなくなってしまいました、、、無念、、
気を取り直して、ブラウザのコンソールからエラーログを見てみると、 CanvasがTainted(汚染)されているとメッセージが出ていたので、
error DOMException: Failed to execute 'toDataURL' on 'HTMLCanvasElement': Tainted canvases may not be exported.
調べてみると、CORSの許可を得ずに他のオリジンから読み込んだデータをキャンバスに描画できないようです。
CORS による許可なしに他のオリジンから読み込んだ何らかのデータをキャンバスに描画すると、キャンバスは汚染 (taint) されてしまいます。 汚染されたキャンバスは安全とみなされなくなり、そのキャンバスから画像データを取得しようとすると、例外が発生するでしょう。
しかし既にこの時点でS3側のCORS対応は行っており、ブラウザコンソールから適当なGETリクエストを送ってみてもアクセスできるのでCORS設定は正しくできていそうです。
const uri = "https://xxxx.s3.ap-northeast-1.amazonaws.com/..." fetch(uri).then(res => console.log(res)); # => Response {type: 'image', url: '...', redirected: false, status: 200, ok: true, …}
ここで指摘されているCORSとは、 Same Origin Policy(SOP / RFC 6454 - The Web Origin Concept)を特定の条件下で緩和する仕組みです。
特定の条件下とは、
- クライアントが、リクエストのヘッダーとして、Originを詰める
- サーバー側でAccess-Control-Allow-Originでレスポンスヘッダーに詰めて返し、アクセス可能なOriginを示す
- 両者が一致している
上記が満たされている場合にオリジンが異なっていても、やりとりしてOK!になります。
なお、あくまで読み取りを制限するための仕組みなので、 リクエスト自体はサーバーに届いて処理されてしまうという点は注意が必要ですね。
※単純リクエスト・プリフライトリクエストや他のAccess-Control-Allow-xxxの話は割愛してます。 続きが気になる方は下記を参照してみてください。
問題解決への道: Chrome DevToolsの活用
おそらくCORSに問題がありそうなことはわかったのですが、 なぜCanvasが汚染されてしまうのかを調べるためにChrome DevToolsのネットワークタブを利用しました。
CORSでエラーになっているなら、リクエストヘッダが怪しいのではと思い、 ネットワークタブをじーっと見ていると、
画像取得するリクエストヘッダーのOriginがリダイレクト時に抜け落ちていることに気がつきました。
これでは、ブラウザがリソースを読んで良いかどうか判断できません。
MDN曰く、CORSリクエストでリダイレクトは許可されていないようです。
CORS リクエストに対して、サーバーが元のリクエストとは異なるオリジンの URL へのリダイレクトを返答しましたが、これは CORS リクエストでは許可されていません。
CORSでリダイレクトを禁止する理由は推察ですが、リダイレクトでのCORSアクセスを許可してしまうと
- ブラウザがどのオリジンが最終的にリソース提供するのか把握できない
- リダイレクト先が信用できるかもわからない
- プリフライトリクエストのような事前チェックの仕組みが意味をなさなくなってしまう
が理由ではないかと思ってます。
例えば、CORSでブロックされているサービスAに対して、悪意のあるユーザーが自前のサービスXを経由したリダイレクトでサービスAのリソースにアクセスする、みたいなことができてしまいます。
そもそもですが、ここでリクエストのリダイレクトが起きていた理由としては、 Active Storageの設定でアセットの表示にはリダイレクトモードを利用していたからです。
リダイレクトモードでは、
/rails/active_storage/blobs/xxxxx--xxxxx/icon.png
にアクセスすると、https\://abcdef.s3.ap-northeast-1.amazonaws.com/...
にリダイレクトする挙動をします。
前者はRailsで制御しているパスで、後者はS3の署名つきURL(有効期限があるURL)になっています。
これでは画像にアクセスする際にリダイレクトが発生してしまい、CORSエラーが起きていたようです。
なので、画像の取得時にリダイレクトをさせないように変更しました。
## before <%= image_tag(@resume.id_photo) %>
## after <%= image_tag(@resume.id_photo.url) %>
これでリダイレクトさせずに、直接S3の署名付きURLを表示するようになりました。 Originがヘッダーから落ちずにしっかりリクエストに詰められるようになっていますね。
これにて解決!無事、画像付きでPDFがダウンロードできるようになりました。!
余談: プロキシーモードがあるじゃん。それではダメなの?
リダイレクトに苦戦しているのなら、 プロキシーモードを使えば良いのでは?という意見もごもっともだと思います。
プロキシーモードを利用すると、Ruby on RailsのサーバーからAmazon S3のファイルをダウンロードしてクライアント側に返す形式を取ります。
そのためCORS問題は自然と解決するんですが、
こうなるとApplication Load Balancerより手前のCDN(CloudFront)でキャッシュされてしまう可能性が設定次第では出てきます。
この場合、Application Load Balancerでの認証を挟まずにエッジサーバー(POP)から 秘匿ファイル(顔写真ファイルは個人情報なのでセキュリティを担保したい)を閲覧できてしまい、 セキュリティ上の脆弱性となってしまうので、今回はそれを避ける形をとりました。
秘匿情報でないのであれば、プロキシーモードを選択するというやり方もOKだと思います。
問題発生!!(2回目)
無事ダウンロード機能が使えるようになったので、確認を進めていたところ、 検証のためにブラウザのキャッシュをずっとOFFにしていたことに気がつきました。
この設定のチェックを外して、検証してみると、
Access to image at '' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource
なんということでしょうか。またCORSエラーです、、😇
今度はダウンロードしたPDFに顔写真が取り込まれなくなってしまいました。
問題解決への道: ブラウザキャッシュを知る
ブラウザキャッシュをONにしてエラーになったということは、ブラウザキャッシュが原因だと睨みました。
ブラウザキャッシュ問題を解決するために、Cache-controlヘッダの設定や、Cache-Bustingを行うことも考えましたが、ストレートな解決策には思えなかったので、別の手段を探すことにしました。
調査を続けていくと、ブラウザはHTTPリクエストをキャッシュして同じリクエストに対してキャッシュしたレスポンスを返すことがあるようです。
最初にCORSではないリクエストを送信したことにより、CORSのヘッダーを含まないレスポンスをキャッシュ、次のOriginヘッダーを含むリクエストでキャッシュされたCORSのヘッダーをレスポンスを利用したため、CORSエラーが発生したと考えられる。
またimgタグはCORSを気にせずリソースにアクセスができます。これはimgタグがSOPの適用外だからですね。
これらのことを踏まえて、下記のようなことが起こっていると考えました。
- PDFプレビュー画面が表示されるタイミングの初回のリクエスト時では、imgタグ由来のnon-CORSリクエストなので、non-CORSなレスポンスがブラウザにキャッシュされる(当然このキャッシュされたレスポンスヘッダにはAccess-Control-Allow-Originが含まれない)
- PDFダウンロード時にJS側で読みにいくと、レスポンスにAccess-Control-Allow-Originが付与されていないので、ブラウザはアクセス権限がないと勘違いし、CORSエラーになってしまう
では、imgタグの読み込みもCORSリクエストにすれば良いだろうと考えて、crossorigin
属性を利用します。
crossorigin 属性は、audio, img, link, script, video の各要素で有効です。CORS への対応を提供し、したがって要素が読み取るデータのために CORS リクエストの構成を有効にします。要素によっては、属性は CORS 設定属性になります。
これに従う形で実装側を下記のように修正しました。
## before <%= image_tag(@resume.id_photo.url) %>
## after <%= image_tag(@resume.id_photo.url, crossorigin: 'anonymous') %>
実際にこれで検証してみてOK。今度こそ直りました!!🎉
今回からの学び
JavaScriptやCORS、ブラウザキャッシュ、Active Storageが絡む今回の問題を経験・解消できたのはすごく良い経験でした。自分なりに学びも沢山あったと思います。
- MDNは大事
- 検証ツールのネットワークでリクエスト・レスポンスのヘッダーを見たりすることは、泥臭いが大事
- なんかやり方が微妙なんだよなーと思う直感も大事
深いところを知りたいという好奇心を忘れずに今後も精進していくつもりです。
読んでいただいた方の少しでも役に立つ日が来れば嬉しいです。
明日のTechouse Advent Calendar 2024は daiki_fujioka さんによる OIDCの仕組みを完全理解して、SaaSプロダクトに2FA機能を実装しました です。
Techouseでは、社会課題の解決に一緒に取り組むエンジニアを募集しております。ご応募お待ちしております。