flawopen.com/xss/Java
Java XSS:Spring MVC と Thymeleaf の出力を保護
th:utext と未エスケープの JSP 出力は、注入された JavaScript を実行させる恐れがあります。出力先の文脈でエンコードし、許可リストで HTML をサニタイズします(CWE-79)。
スタジアムのスコアボードには、通常の名前欄と別のリッチ表示モードがあります。Thymeleafのth:textは入力された名前を文字列として扱いますが、th:utextは値をボード用のHTML指示として解釈するようレンダラーに伝えます。ブラウザーはスタジアムサイトのtext/htmlを受け取り、そのサイトの権限でマークアップを表示します。ユーザー文字列はエスケープされた出力に保ち、リッチ表示が必要なら非エスケープのテンプレート枠に渡す前にサニタイズします。
主要な概念と専門用語
Thymeleaf th:text vs th:utextth: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/htmlproduces = text/htmlを指定してStringを返す@RestControllerのメソッドは、そのままレスポンスに書き込まれます。テンプレートエンジンは動かず、何もエスケープされません。ビューテンプレートを使うか、連結するすべての値をエンコードします。
フレームワーク対応
- このガイドで説明する弱点の CWE 参照: CWE-79
- OWASP Top 10:2025 — A05:2025 Injection
ステップ・バイ・ステップの攻撃フロー
入力がリクエストパラメータで届く
Spring がクエリ文字列やフォーム本文の q、user、bio を String 引数にバインドします。被害者に細工したリンクを送ることも、投稿された bio が他のユーザーに表示されることもあります。
連結がレスポンスを組み立てる
コントローラーが値を HTML や <script> ブロックに貼り付けます。無害なテスト値 <img src=x onerror=alert(1)> なら返す文字列に本物のタグが入り、';alert(1);// ならスクリプトブロックに新しい文が加わります。
ブラウザーがそれを解析する
メソッドが text/html を宣言しているため、ブラウザーは本文をページとして表示します。画像の読み込みが失敗して onerror が発火するか、スクリプトブロックが注入された文を実行します。
スクリプトがサイトとして実行される
コードはアプリケーションのオリジンで実行され、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>";
}
}
エンジニアリング&システム堅牢化チェックリスト
- ✓SpotBugs で Find Security Bugs を有効にし、
XSS_SERVLET、XSS_JSP_PRINT、XSS_REQUEST_PARAMETER_TO_SERVLET_WRITERでビルドを失敗させる。 - ✓CI でテンプレート内の
th:utext、[(、<%=、JSP のむき出しの${...}を検索する。該当箇所はすべて、サニタイズ済みか定数の内容を出力しなければならない。 - ✓コントローラーが自分でマークアップを組み立てる場合は、OWASP Java Encoder で正確な文脈に合わせてエンコードする(
forHtml、forHtmlAttribute、forJavaScript、forUriComponent)。 - ✓ユーザーが提供する HTML は、
th:utextやレスポンス本文に届く前に OWASP Java HTML Sanitizer のポリシーに通す。 - ✓Spring Security の
headers().contentSecurityPolicy(...)に nonce ベースのscript-srcを設定し、セッション cookie はHttpOnlyとSameSite=Laxにする。