3分で分かるAngularJSセキュリティ

先日のng-mtg#4 AngularJS 勉強会でLTしようと思ったけど申し込みが間に合わなかったのでブログに書きます。

先月リリースされたAngularJS 1.2はセキュリティがんばってる的なことを聞いたので、セキュリティ周りの仕組みを調べてみました。

お題は以下です。

  • CSRF
  • JSON
  • CSP (Content Security Policy)
  • Escaping

CSRF

  • ユニークなトークンをHTTPリクエストに載せてサーバーでチェックする対応が世の中では主流(最近はカスタムヘッダのチェックによる対策も
  • AngularJSでは、XSRF-TOKEN Cookieにトークンが載っていると、$httpを使ったHTTPリクエストのヘッダに自動的にX-XSRF-TOKENヘッダーが付く。
  • XSRF-TOKEN CookieはもちろんNot HttpOnlyで。
  • Angular界ではCSRFではなくXSRFで統一されてるのでgrepするとき注意。
  • Cookie名とヘッダー名は変更もできるよ。
app.config(['$httpProvider',
  function($httpProvider) {
    $httpProvider.defaults.xsrfCookieName = 'csrf_token';
    $httpProvider.defaults.xsrfHeaderName = 'X-CSRF-Token';
  }]);

JSON

JSON関連の攻撃手法は主にこの3つ。

  • 直接ブラウズ
  • JSONハイジャック
  • UTF-7

参考: PHPのイタい入門書を読んでAjaxのXSSについて検討した(3)~JSON等の想定外読み出しによる攻撃~ - ockeghem(徳丸浩)の日記

  • AngularJSでは、)]}',をJSONレスポンスの先頭に付ける手法が組み込みで用意されていて、これが付いてる場合は自動で除去してくれる。
)]}',
['one','two']
  • このあたりは$httpProvider.defaults.transformResponseで実装されてる。詳しくは AngularJS: ng.$http
  • 欠点
    • AngularJS以外のクライアントでは特殊な対応が必要
    • devtoolなどでのデバッグが面倒
    • これだけだと直接ブラウズ攻撃は防げない

おすすめはカスタムヘッダのチェック

  • HTTPヘッダーにX-Requested-With: XMLHttpRequestを付加する。
app.config(['$httpProvider',
  function($httpProvider) {
    $httpProvider.defaults.headers.common = {'X-Requested-With': 'XMLHttpRequest'};
  }]);
  • サーバーはAPIに対するリクエストについてX-Requested-Withヘッダがあることをチェックする。
  • これで上記3つの攻撃を防げる。

CSP (Content Security Policy)

  • 参考: CSP (Content Security Policy) - Security | MDN
  • インラインスクリプト, eval, Function(string)など脆弱なパーツの使用を禁止できる、対DOM Based XSS最終兵器。
  • AngularJSではngCspディレクティブを指定するだけでクライアント側の対応は完了する。
<html ng-csp>
   ...
</html>
  • AngularJSはもともとインラインスクリプトを使わないのでCSPに向いている。
  • ただし、高速化のために使用しているFunction(string)を無効化するので30%速度低下とのこと。
  • CSSの注入もできなくなるので、angular-csp.cssを別途ロードが必要。
  • 詳細は AngularJS: ngCsp を参照。

個人的にはCSPはもっと普及して欲しいと思っているので、組み込みでこういう対応が入ってるのは好感持てます。

Escaping

各種テンプレートエンジンのデフォルトエスケープの普及によって単純なXSSは少なくなりましたが、DOM Based XSSはがんがん検出されている印象です。 理由としては次の2点があると思います。

  • innerHTMLやそれに準ずるメソッド(jQuery系メソッドなど)への危険なHTMLのぶっ込み
  • コンテキストが重なる場所でのエスケープの難しさ

コンテキストによるエスケープの難しさは Closure Templatesのオートエスケープが最強すぎる件 - teppeis blog で詳しく書きました。

簡単に書くと、x1, x2, x3で必要な処理が違うということです。

<!-- 疑似コード -->
<a href="{$x1}" onclick="alert('{$x2}')">{$x3}</a>

x3は普通にHTMLエスケープ、x1はURLとしてサニタイズjavascript:などの不正なURLを排除)した上でHTMLエスケープ、x2JavaScript文字列としてエスケープした上でHTMLエスケープ、といった処理が必要。だけど分かんないよねーと。

AngularJS 1.2では$sceがデフォルト有効に

例えば、inputの中身をdivにHTMLとして出力する以下のコード。

<input ng-model="userHtml">
<div ng-bind-html="userHtml"></div>

$sceが無いとinputに<script>alert('xss')</scrip>とか書いたらdiv.innerHTMLにぶっ込まれて実行されちゃうわけです。 これが$sceが有効な場合、HTMLコンテキストへの信頼できない文字列バインドとみなされてエラーになります。

AngularJS Security Error

HTMLとして文字列をバインドするには2通り方法があって、1つは信頼できるサーバーでサニタイズが完了してるときや文字列リテラルの場合。 そのときはJSから渡すときに$sce.trustAsHtml()で文字列に信頼マークを付ける。いわゆる型付き文字列ですね。

app.controller('SecureCtrl', ['$scope', '$sce',
  function($scope, $sce) {
    $scope.userHtml = $sce.trustAsHtml('<b>安全だと分かっているHTML</b>');
  }]);

もう1つはクライアントでサニタイズする方法。AngularJSにはHTMLサニタイズライブラリが付いてるのでそれを使う。ファイルが別なのでangular-sanitize.jsを読み込んでngSanitizeモジュールを登録。

<script src="angular.js"></script>
<script src="angular-sanitize.js"></script>
var app = angular.module('app', [ 'ngSanitize']);

これだけで最初のng-bind-htmlが動く。 プレーン文字列がHTMLコンテキストに代入されようとしているので、自動的にサニタイズされるわけです。 <script>alert('xss')</scrip><b>foo</b>とか入力すると、サニタイズ後のキレイな<b>foo</b>だけがHTMLとして代入される。

JSでやりたい場合は$sanitizeサービスを使う。AngularJS: ngSanitize.$sanitize を参照。

ng-bind-htmlの代わりにng-bind-html-unsafeを使うっていう方法もあるけど、これはテンプレートに穴を空けてるに過ぎないので非推奨。

$sanitizeの挙動

HTML以外のコンテキスト

以下の5つのコンテキストを持ってる。

  • $sce.HTML
  • $sce.URL: a.href, img.src
  • $sce.RESOURCE_URL: img以外のiframeなどのsrc, ng-src, ng-includeなど
  • $sce.JS
  • $sce.CSS

<a href="{{url}}">とか書くとurl$sce.URLとしてサニタイズされて、javascript:alert(1)とか書くとunsafe:javascript:alert(1)に変換される。

$sce.RESOURCE_URLはデフォルトでsame originに限定されてて、ng.$sceDelegateProviderのresourceUrlWhitelistで変更できる。

$sce.JS$sce.CSSについては、実は自動では使われる場所はないので使いたい人がJSで書いて明示的に使う。

AngularJS 1.2からonclick系のイベントハンドラ属性に直接代入できないので、上記のJSコンテキストの出番はほとんどなくなっている。

<!-- これはエラー -->
<div onclick="{{foo}}">

あとsrc, ng-srcで複数の変数を連結して埋め込むのも1.2から禁止になった。分けわからなくなるからね。1個まで。

<!-- これもエラー -->
<iframe src="{{baseUrl}}?a={{a}&b={{b}}">

ということで、エスケープ方法わかんないような複雑なコンテキストではそもそも変数埋め込みしない方がいいよね的な方向になってて、正しい気がします。

まとめ

3分厳しい!