FlawOpen

flawopen.com/xss/Java

● CWE-79 · 高
セキュリティ研究 · FlawOpen

Java XSS:Spring MVC と Thymeleaf の出力を保護

th:utext と未エスケープの JSP 出力は、注入された JavaScript を実行させる恐れがあります。出力先の文脈でエンコードし、許可リストで HTML をサニタイズします(CWE-79)。

💡 わかりやすい解説 (ELI5)

スタジアムのスコアボードには、通常の名前欄と別のリッチ表示モードがあります。Thymeleafのth:textは入力された名前を文字列として扱いますが、th:utextは値をボード用のHTML指示として解釈するようレンダラーに伝えます。ブラウザーはスタジアムサイトのtext/htmlを受け取り、そのサイトの権限でマークアップを表示します。ユーザー文字列はエスケープされた出力に保ち、リッチ表示が必要なら非エスケープのテンプレート枠に渡す前にサニタイズします。

主要な概念と専門用語

Thymeleaf th:text vs th:utext
th:text は値を HTML エスケープし、th:utext(エスケープなしのテキスト)はマークアップとして挿入します。Thymeleaf のインライン記法では [[${x}]] がエスケープし、[(${x})] はしません。ユーザー入力に結び付いた th:utext は、値を事前にサニタイズしていない限り XSS です。
JSP ${} vs <c:out>
JSP のテンプレートテキストでは、${param.q} はエスケープされずに出力されます。<c:out value="${param.q}"/> や ${fn:escapeXml(param.q)} ならエスケープされます。<%= request.getParameter("q") %> のようなスクリプトレットは決してエスケープしません。
Encode.forHtml / forJavaScript
OWASP Java Encoder には出力文脈ごとのメソッドがあります。要素の内容には Encode.forHtml、引用符付き属性には forHtmlAttribute、JavaScript の文字列リテラルには forJavaScript、URL パラメータには forUriComponent を使います。スクリプト内に置く値を HTML エンコードするだけでは不十分です。
OWASP Java HTML Sanitizer
ユーザーが提供する HTML には、Sanitizers.FORMATTING.and(Sanitizers.LINKS) のようなポリシーを使います。タグと属性の許可リストを持ち、スクリプト、イベントハンドラー、javascript: URL を取り除きます。エンコードするとタグが文字として表示されますが、サニタイズなら安全なタグは機能したまま残ります。
@ResponseBody returning text/html
produces = text/html を指定して String を返す @RestController のメソッドは、そのままレスポンスに書き込まれます。テンプレートエンジンは動かず、何もエスケープされません。ビューテンプレートを使うか、連結するすべての値をエンコードします。

フレームワーク対応

ステップ・バイ・ステップの攻撃フロー

ステップ 1

入力がリクエストパラメータで届く

Spring がクエリ文字列やフォーム本文の q、user、bio を String 引数にバインドします。被害者に細工したリンクを送ることも、投稿された bio が他のユーザーに表示されることもあります。

ステップ 2

連結がレスポンスを組み立てる

コントローラーが値を HTML や <script> ブロックに貼り付けます。無害なテスト値 <img src=x onerror=alert(1)> なら返す文字列に本物のタグが入り、';alert(1);// ならスクリプトブロックに新しい文が加わります。

ステップ 3

ブラウザーがそれを解析する

メソッドが text/html を宣言しているため、ブラウザーは本文をページとして表示します。画像の読み込みが失敗して onerror が発火するか、スクリプトブロックが注入された文を実行します。

ステップ 4

スクリプトがサイトとして実行される

コードはアプリケーションのオリジンで実行され、DOM を読み、被害者のセッション cookie でアプリケーションのエンドポイントを呼び、ユーザーに見える内容を書き換えられます。

ソースコード比較:脆弱 vs 堅牢化

脆弱な実装
// ProfileController.java: 各エンドポイントがリクエストのデータを HTML に連結している
@RestController
public class ProfileController {

    // HTML の文脈: クエリの値がマークアップとして挿入される
    @GetMapping(value = "/search", produces = MediaType.TEXT_HTML_VALUE)
    public String search(@RequestParam String q) {
        return "<h1>Results for " + q + "</h1>";
    }

    // JavaScript の文脈: user の引用符が文字列リテラルを終わらせる
    @GetMapping(value = "/widget", produces = MediaType.TEXT_HTML_VALUE)
    public String widget(@RequestParam String user) {
        return "<script>const user = '" + user + "';</script>";
    }

    // リッチテキストがタグとハンドラーを残したまま返される
    @PostMapping(value = "/preview", produces = MediaType.TEXT_HTML_VALUE)
    public String preview(@RequestParam String bio) {
        return "<div class=\"bio\">" + bio + "</div>";
    }
}
堅牢化されたセキュアパッチ
// ProfileController.java: 各値を文脈に合わせてエンコードするかサニタイズする
@RestController
public class ProfileController {

    private static final PolicyFactory BIO_POLICY = Sanitizers.FORMATTING.and(Sanitizers.LINKS);

    // forHtml は要素の内容向けに < > & " ' をエンコードする
    @GetMapping(value = "/search", produces = MediaType.TEXT_HTML_VALUE)
    public String search(@RequestParam String q) {
        return "<h1>Results for " + Encode.forHtml(q) + "</h1>";
    }

    // forJavaScript は引用符、バックスラッシュ、</script> の並びをエスケープする
    @GetMapping(value = "/widget", produces = MediaType.TEXT_HTML_VALUE)
    public String widget(@RequestParam String user) {
        return "<script>const user = '" + Encode.forJavaScript(user) + "';</script>";
    }

    // サニタイザーは書式とリンクを残し、それ以外をすべて捨てる
    @PostMapping(value = "/preview", produces = MediaType.TEXT_HTML_VALUE)
    public String preview(@RequestParam String bio) {
        return "<div class=\"bio\">" + BIO_POLICY.sanitize(bio) + "</div>";
    }
}

エンジニアリング&システム堅牢化チェックリスト

参考資料