Rails: Content-Security-Policyの検証方法と、SeleniumとCupriteの比較(翻訳)
その昔、RailsEventStoreの熱心な愛好家がissueを報告してくれたことがありました(#1062)。それによって、RES::Browser
コンポーネントが、彼らのRailsアプリで使われている十分筋の通ったContent-Security-Policy(CSP)と互換性がないことが判明したのです。このレポートは興味深い議論に発展しました。最終的に、あるプルリクの後で当プロジェクトは新たなコントリビュータを迎えることとなり、CSPにより適したセットアップになりました。
手動テストを行わずに、この改良が今後のリリースで壊れないことを保証するにはどうすればよいと思いますか?続きをお読みください。
Content-Security-Policyについて
Content-Security-Policyについて簡単におさらいしておきましょう。
Content-Security-Policyは、最近のブラウザがドキュメント(Webページ)のセキュリティを強化するために使うHTTPレスポンスヘッダの名前。Content-Security-Policyヘッダーは、JavaScript、CSSなど、ブラウザが読み込むリソースを制限できる。
what this CSP thing isより
要するに、Content-Security-PolicyヘッダーはXSSやインジェクション攻撃を防ぐのに役立ちます。
たとえば、Webサーバーが「インラインスクリプトを実行してはならない」とブラウザに指示するとします。ブラウザはレスポンスで以下のHTTPヘッダーを受け取ることで、この指示を認識します。
content-security-policy: script-src 'self'
これにより、ブラウザはレスポンスのHTML body内で以下のようなインラインスクリプトを見つけても一切実行しなくなります。
<script type="text/javascript">
alert("spanish inquisition");
</script>
代わりにエラーが発生してログに保存されます。インラインスクリプトが正当なものか攻撃者が注入したものかどうかは無関係です。このポリシーはインラインスクリプトの実行を厳格に禁止します。
Refused to execute inline script because it violates the following Content Security Policy directive: "script-src 'self'". Either the 'unsafe-inline' keyword, a hash ('sha256-b1No4u4UwgH6M1mNU7GPc4D3Fc2lJ26AvLJAgCR+lvE='), or a nonce ('nonce-...') is required to enable inline execution.
Content-Security-Policy違反の検出方法
この時点では、ポリシーを決定するのがアプリケーションまたはWebサーバーであることが既にわかっています。そして、Webブラウザはこのポリシーを検証して強制する「エンジン」を搭載しています。したがって、テストではヘッドレスブラウザを用いるようにし、ブラックボックスの中は覗き込まないようにするのがベストでしょう。
望ましいContent-Security-Policyを検証するには、最初にエンジンの振る舞いをエミュレーションする必要があります。RES::Browser
は技術的にはRackアプリケーションの1つで、Railsアプリにマウントされるか単独で実行されます。前者が最も多いユースケースなので、ここでは前者に注目しましょう。
RES::Browser
がマウントされると、RES::Browser
はRailsのContent-Security-Policyヘッダーに依存するようになります。テストではアプリケーション全体をこれに巻き込む必要はありません。Content-Security-Policyヘッダーを追加する小さなRackミドルウェアを使えば十分エミュレーションできます。
class CspApp
def initialize(app, policy)
@app = app
@policy = policy
end
def call(env)
status, headers, response = @app.call(env)
headers["content-security-policy"] = @policy
[status, headers, response]
end
end
このミドルウェアでRES::Browser
コンポーネントをラップします。これで、CapybaraやCupriteで動くWebブラウザがroot URLにアクセスすると、受信したContent-Security-Policyヘッダーを実際に配信されたHTMLと比較します。問題がある場合は即座に"異論"を唱えます。テストシステムの外にある実際のWebブラウザでも同じように動作するでしょう。
session =
Capybara::Session.new(
:cuprite,
CspApp.new(
RubyEventStore::Browser::App.for(event_store_locator: -> { event_store }),
"style-src 'self'; script-src 'self'",
),
)
session.visit("/")
問題が発生したかどうかは、どうすれば知ることができるでしょうか?ページの一部が正常に読み込まれなくなることをアサーションでチェックできます。これは動的コンテンツのチェックに最適な方法です。
expect(session).to have_content("RubyEventStore v2.5.1")
しかしポリシー制約のせいでインラインCSSが読み込めない場合はどうでしょうか?HTMLコンテンツだけをチェックしていては検出できないかもしれません。以下のようにWebブラウザのログにエラーがあるかどうかを調べるという、より普遍性の高い方法も考えられます。
expect(logger.messages.select { |m| m["params"]["entry"]["level"] == "error" }).to be_empty
このlogger
はどこから来るのでしょうか?Cupriteの場合はドライバに渡せます。logger
はputs
メソッドに応答しなければならないことになっています。1個のテストを実装するのに十分なテストは以下のような感じになるでしょう。
logger =
Class.new do
attr_reader :messages
def initialize
@messages = []
end
def puts(message)
_, _, body = message.strip.split(" ", 3)
body = JSON.parse(body)
@messages << body if body["method"] == "Log.entryAdded"
end
end.new
Capybara.register_driver(:cuprite_with_logger) { |app| Capybara::Cuprite::Driver.new(app, logger: logger) }
Cuprite vs Selenium
ごく最近になって、ChromedriverはCupriteを動かすのに必須ではないことを知りました。このことだけで、試してみようという気になりました(依存性を減らしたくない人はいませんよね?)。それにChromedriverは更新が頻繁で、Chromeブラウザと常にバージョンを揃えて検疫(quarantine)を解除する必要がある点が依存関係として厄介です。
以前のRailsEventStoreではSeleniumとヘッドレスChromeを使っていました。このセットアップではブラウザログの検査方法が異なっていました。ロガーを明示的に渡す必要もなく、既にドライバのインターフェイスに公開されていました。
expect(session.driver.browser.manage.logs.get(:browser).select { |le| le.level == "SEVERE" }).to be_empty
SeleniumからCupriteへの移行はb6ec85cのコミットで最も詳しく見ることができます。この小さなサンプルセットでは、まだCupriteの欠点は見つかっていません。
Happy hacking!
お知らせ: 5600人以上のRailsエンジニアが購読しているメールマガジン
元記事末尾のフォームに登録いただくと、Arkencyのベテランプログラマーによる磨き抜かれた洞察や知恵を結晶したメールマガジンを配信いたします。
私たちは皆さんのメールボックスにスパムを決して送信することのないよう最大限に配慮しています。フォームを送信いただくと確認メールが届きます。
概要
元サイトの許諾を得て翻訳・公開いたします。
日本語タイトルは内容に即したものにしました。
参考: コンテンツセキュリティポリシー (CSP) - HTTP | MDN