Content Security Policy Lv3 (CSP3)でhashによりスクリプトを許可する

整理している人
©いらすとや.

前回、個人ブログであってもCSPを付けたいということを書いた。とっつきにくいので手を出し難いかもしれないけど「難しい」と尻込みしないで思い切って飛び込んでみれば実はそんなに難しくはないかも。専門家の人には怒られるかもしれないけどCSP3の 'strict-dynamic' さえ使えば楽勝よと思っておいて良いかも。

hash(ハッシュ)の求め方

例えば1行だけのJavascriptがページにあるとする。このスクリプトのhashを求める。(↓)

<script>alert('test');</script>

<script></script> は含めない。hashを求める対象はこの例では「alert('test');」の部分。

$ echo -n "alert('test');" | openssl sha256 -binary | openssl base64
GAF48QOoxRvu0gZAmQivUdJPyBacqznBAXwnkfpmQX4=       ←これが求められたhash

echoコマンドは普通は最後に改行がつくので改行を付けない「-n」を引数に付ける。
求めたhashの文字列の前に「sha256-」を付けて全体の前後に「'」を付ける。
'sha256-GAF48QOoxRvu0gZAmQivUdJPyBacqznBAXwnkfpmQX4='
これで1つ出来上がり。1行の短いスクリプトであれば難しいことはなくてこれで。

複数行、シングルクォート「'」,ダブルクォートが「"」が入り交じるスクリプトは面倒なのでそれだけを1つのファイルに書き込む。次が元のスクリプトとする。

<script>
var a = 'test';
alert( a );
</script>

先にも書いたが<script></script> は含めないので消す。ただし、ハッシュを求める際は改行も対象に含まれる
この例では、<script>var の間に改行、alert( a );</script>の間に改行があるのでその改行は残す。(これが注意)

                 改行だけの行(空行)
var a = 'test';
alert( a );           ;の後で改行
                 ←空行

このようにして今回はファイル(hash1.txt)に保存。
なお、vimのようにファイルの最後に(編集画面では)見えない改行が勝手につくエディタを使う場合はその勝手に付く最後の空行に注意。これ結構やらかして悩まされそう。vimの場合、:set bin noeol して :wqでファイル最後の改行無しで保存できる。(1行しかないと変な挙動で空ファイルになったりファイルが作成されないかも)

$ cat hash1.txt | openssl sha256 -binary | openssl base64
lpEdviF1nW++20sB72uy8WJPj9AfL8JDneneL6N1etU=     ←これが求められたhash

先の例と同様に求めたhashの文字列の前に「sha256-」を付けて全体の前後に「'」を付ける。
'sha256-lpEdviF1nW++20sB72uy8WJPj9AfL8JDneneL6N1etU='
これで2つめが出来上がり。
実際に使うことが多いのはこちらのやり方かと。

Report URIのウェブツールでハッシュ生成
ターミナルとかコマンドラインとか全然しらないし怖くて触りたくないという人向けにオンラインツール https://report-uri.com/home/hash が便利。こちらも<script></script> の前後の改行があるなら空行が必要。なのでページの <script> の後の改行と </script> の前の改行なんて付けないのが無難なのかも。

さらにカンタンなのは、(以下)
CSPヘッダを作成する前に仮で次のようなCSPヘッダをウェブサーバで設定する。

Content-Security-Policy: script-src 'strict-dynamic' https:;
ウェブサーバがNginxならこんな指定(↓)
add_header 'Content-Security-Policy' "script-src 'strict-dynamic' https:;";

ウェブサーバに設定を読み込ませてこのCSPヘッダを有効化する。その状態で次。

Report URIのウェブツールでハッシュ生成
ブラウザを開く。
重要: 拡張機能は全て無効化するか、シークレットウィンドウでCSPを設定するウェブサイトのページを表示する。Chromeブラウザであれば[F12]を押してデベロッパツールを開き、「コンソール」タブを選択する。
CSPのエラーが表示されるが、親切にもページ内の全てのスクリプトのハッシュが表示されるのでそれをコピーしてCSPヘッダに追加する(全て)

やり方としては最も簡単なのだが、どのハッシュがどのスクリプトのなのかが判らないので困るかも。

CSPヘッダを作る

1
2
3
4
5
6
7
8
Content-Security-Policy: script-src 'sha256-GAF48QOoxRvu0gZAmQivUdJPyBacqznBAXwnkfpmQX4='
                                    'sha256-lpEdviF1nW++20sB72uy8WJPj9AfL8JDneneL6N1etU='
                                    'strict-dynamic'
                                    'unsafe-inline'
                                    https:
                                    http:;
                         object-src 'none';
                         base-uri 'none';

'strict-dynamic' は付けるのがオススメ。これがあることで許可したスクリプト(今回ハッシュを求めたスクリプト)から別のスクリプトが呼ばれる場合にそのスクリプトを追加する必要がないのでカンタン。逆に、つけないとAdSenseなどの外部の広告サービスを使用する場合にCSPの許可リストが破綻するかも。(クリック広告サービスではAdSenseは実はマシな方。)
'unsafe-inline' は古いブラウザのために付けておく。CSP2,CSP3を理解するブラウザはこの指定を無視するので大丈夫。(デベロッパツールには警告として出るが害はない) むしろ、最近のPageSpeed Insightsでは'unsafe-inline'無しにしてると互換性のために'unsafe-inline'を付けろと言ってくるくらい。

CSP3はまだドラフトなのでブラウザが対応していなくても文句は言えませんが、strict-dynamicは最近のバージョンはIE, Opera Mini, Firefox(Android版), UCブラウザ(Android版)を除いて対応済みということになっています。

出来たら1行にしてウェブサーバの設定に加える。

例: Nginx
add_header 'Content-Security-Policy' "script-src 'sha256-vD3/JXDdBKKMffxw2mAt10e7VpBqnfXak1o+9jzbxgs=' 'sha256-lpEdviF1nW++20sB72uy8WJPj9AfL8JDneneL6N1etU=' 'strict-dynamic' 'unsafe-inline' https: http:;object-src 'none';base-uri 'none';";

前回書いたが、CSP2では外部スクリプトをハッシュで許可するのはできなくて、CSP3ではそれができるけどブラウザの対応がまだ不安なので採用には躊躇する?また、外部スクリプトをハッシュで許可というのはその外部スクリプトに変更が加えられると許可が外れてしまうので怖い。バージョン毎に違うURLで提供されるようなライブラリなら大丈夫だろうけど。
この2つの問題に対応するために、外部スクリプトを内部スクリプト(動的スクリプト)にして読み込むのが「がとらぼ」の中の人としてはお薦めの方法。(以下)

例えば外部スクリプトの https://orange.example.com/banana.js を利用したいとする。
普通であれば <script src="https://orange.example.com/banana.js"></script> のようになる。

これを内部スクリプトから読むようにする。

1
2
3
4
5
<script>
var scr = document.createElement("script");
scr.src = "https://orange.example.com/banana.js";
document.head.appendChild(scr);
</script>
ハッシュは上と同様の求め方で 'sha256-OA/LscJiJ8EYhoLVBpK5Pos3J251HJhvYg5Wvpe477Q='

AdSenseのコードにハッシュを付けて許可する

Google AdSenseなら元はこのようなコード。(最近の)
1
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-0000000000000000" crossorigin="anonymous"></script>

これは外部スクリプトなのでハッシュを付けられない。(CSP3の外部スクリプトのハッシュ対応ブラウザを除く)
そこで動的スクリプトに変形してハッシュ対応にする。

1
2
3
4
5
6
<script>
var scr = document.createElement("script");
scr.src = "//pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-0000000000000000";
scr.crossorigin = 'anonymous';
document.head.appendChild(scr);
</script>

動的スクリプトは最初から非同期なのでasync不要。この例では最後のappendChild()はGoogleさんのドキュメントどおりにdocument.head(HTMLヘッダ <head>〜</head>)の子要素として付けているが、HTMLボディ(<body>〜</body>)の子要素にしたければdocument.bodyにする。個人的にはHTMLヘッダにスクリプトを置くのが嫌いなのでbody派です。

このように変換するならついでにAdSenseの遅延表示を導入するのもアリです。遅延表示させるための動的スクリプトなので単にそれからハッシュを求めてやれば良いことになります。AdSenseの遅延表示については「AdSense 遅延」などのキーワードでググってください。わかりやすいだけでなくほぼコピペで使えるコードが置いてあるサイトがヒットします。

AdSenseの個別の広告ユニットでは以下のようなコードを使います。この内、Javascriptは最後の3行です。<script>の次の改行とスクリプト行の行頭のスペース、スクリプト行の最後に改行があるのでお忘れなく。

<ins class="adsbygoogle"
	style="display:block"
	data-ad-client="ca-pub-0000000000000000"
	data-ad-slot="0000000000"
	data-ad-format="auto"
	data-full-width-responsive="true"></ins>
<script>
	(adsbygoogle = window.adsbygoogle || []).push({});
</script>

同様にハッシュを求めてCSPヘッダに追加します。広告ユニットが複数あって広告ユニットの種類が違ったとしてもJavascript部分は同じ筈なので1つハッシュを作るだけです。
なので、AdSenseに限れば基本的にはpagead2.googlesyndication.com/pagead/js/adsbygoogle.jsを読み込むスクリプトと広告ユニットの「(adsbygoogle = window.adsbygoogle || []).push({});」の2つのハッシュだけで済むということです。CSP1の個別許可だとAdSense用だけでも多くのホストを許可しなくてはならないのが2つだけというのは素晴らしく気分が良いというものです。

Google Analyticsのコードを動的スクリプトにしてハッシュを付ける

1
2
3
4
5
6
7
8
<script async src="https://www.googletagmanager.com/gtag/js?id=G-0000000000"></script>
<script>
  window.dataLayer = window.dataLayer || [];
  function gtag(){dataLayer.push(arguments);}
  gtag('js', new Date());

  gtag('config', 'G-0000000000');
</script>
仮に、Analytics用のコードがこのようなものだとします。2つのスクリプトで、1行目は外部スクリプトを読み込むもの、2〜8行目は内部スクリプトです。 1行目のスクリプトを動的スクリプトに書き換えてハッシュを求め、2〜8行目のスクリプトの方もハッシュを求めます。つまりこの例では2つのハッシュを作成してCSPヘッダに追加することになります。先のAdSenseも使っているならAdSenseとAnalyticsで4つのハッシュです。

作成したCSPヘッダをPHPで出力する

基本的にはApacheやNginxのようなウェブサーバでCSPヘッダを出力する方が良いと思いますが、レンタルサーバによってはその辺りは触らせて貰えないかもしれません。PHPが使えるサーバならPHPで出力させることができます。

<?php
        header("Content-Security-Policy: script-src 'sha256-GAF48QOoxRvu0gZAmQivUdJPyBacqznBAXwnkfpmQX4=' 'sha256-lpEdviF1nW++20sB72uy8WJPj9AfL8JDneneL6N1etU=' 'sha256-BYZOgvpTTjvdOWhTv/ssZ9j0ujjYZVRqiMDllmV8ZlQ=' 'sha256-ZbBokixf9ZfhHkdE8RY5euZnQTEGGYGODZhRpq917sM=' 'strict-dynamic' https: http:;");
?>

見ればわかりますが header("出力させたいHTTPヘッダの文字列"); だけです。

WORDPRESS/wp-content/themes/使用中のテーマ/functions.php (挿入)
1
2
3
4
5
6
7
8
function response_header_insert() {
    if($response_headers = get_post_meta(get_the_ID(), 'response_header', false)){
        foreach($response_headers as $header_code){
            @header( $header_code );
        }
    }
}
add_action( 'send_headers', 'response_header_insert', 10, 0 );

WordPressで任意のHTTPレスポンスヘッダを記事のカスタムフィールドで設定できるようにするコードを作ってみました。任意のHTTPスレポンスヘッダなのでCSP専用ではありません。投稿/固定記事のカスタムフィールドの「名前」の欄、初めてHTTPレスポンスを登録する場合は「新規追加」リンクを押して名前欄に「response_header」と入力します。一度登録すると次回以降は「名前」のドロップダウンに「response_header」があるのでそれを選択します。
値のテキストボックスに「Content-Security-Policy: 文字列」を入力して記事を保存します。「文字列」の部分は上で作成したCSPの設定です。
なお、手抜きのコードなので他にHTTPヘッダを出力する機能があるとその機能が出力したHTTPヘッダを壊す(無くす)可能性があります。そのまま使おうとせずにご自身で問題ないものにしてください。

カスタムヘッダなんてものではなく、単にテーマ内のsend_headers用のアクションの中で直接header("CSPヘッダの文字列");でも良さそうです。その際はis_admin()で分岐し、管理者パネルではCSPヘッダを出力しないようにする方が無難です。

なお、ページ表示高速化のためにページキャッシュの仕組みを導入している場合、PHPでHTTPレスポンスヘッダを出力しても、ページキャッシュではそれは出力されません。つまり、キャッシュがヒットせずに動的ページ作成が行われた場合はHTTPレスポンスヘッダがあって、キャッシュがヒットしてそれが表示された場合はHTTPレスポンスヘッダがありません。これはウェブサイトオーナーの意図する動作ではない筈です。ページキャッシュを使用しているサイトでは素直にウェブサーバでHTTPレスポンスヘッダを出力するのが無難です。(または次)

HTMLヘッダのmetaタグでCSPを出力する

<meta http-equiv="Content-Security-Policy" content="文字列">

content="文字列"の部分にCSPの設定を入れるだけです。これも1行で書きます。
個人的にはHTMLヘッダでCSPを設定するのは好きではありませんが、ウェブサーバの自由がない、ページキャッシュを使用しているなどの理由でどうしても使わざるを得ないということがあるかもしれません。

次回はハッシュではなく使い捨て合言葉nonse(ナンス)を使う場合というのをやりたいと思っているけど、いまのところ自身で使ってないので暫くかかるかも。「悪いインターネット」で暫く試行錯誤したいと思います。