こんにちは、かつコーチです。
前回は、Vueアプリに認証機能を実装するパターンを解説しました。
認証・セキュリティ編の2本目となる今回は、Webアプリの代表的な脆弱性であるXSS(クロスサイトスクリプティング)への対策を扱います。
XSSとは?
悪意あるスクリプトが実行されてしまう脆弱性
XSSは、攻撃者が仕込んだスクリプトが、他のユーザーのブラウザ上で意図せず実行されてしまう脆弱性です。
たとえば、コメント欄に<script>タグを含む文字列を投稿できてしまい、それを他のユーザーが閲覧したタイミングで悪意あるスクリプトが実行される、といったケースが典型です。
実行されたスクリプトによって、Cookieに保存されたログイン情報が盗まれたり、意図しないページに遷移させられたりする被害につながります。
VueはXSSに強いのか?
Vueのテンプレートは、{{ }}(マスタッシュ構文)で出力する値をデフォルトで自動的にエスケープします。
つまり、ユーザーが<script>alert(1)</script>のような文字列を入力しても、そのままではHTMLタグとして解釈されず、文字列としてそのまま画面に表示されます。
この点で、Vueは何も対策しなくてもある程度の安全性が確保された設計になっています。
ただし、「デフォルトで安全」なのはあくまで通常の{{ }}を使った場合に限られる、という点には注意が必要です。
危険なポイント:v-htmlの扱い
v-htmlはエスケープをバイパスする
Vueには、HTML文字列をそのままレンダリングするためのv-htmlディレクティブが用意されています。
CMSから取得したリッチテキストや、マークダウンを変換したHTMLを表示したいときに使われますが、このv-htmlは自動エスケープの対象外です。
つまり、v-htmlに渡す文字列にユーザー入力がそのまま含まれていると、XSSの入口になってしまいます。
❌ Before:ユーザー入力をそのままv-htmlに渡してしまう
<script setup lang="ts">
import { ref } from 'vue'
const comment = ref('')
const comments = ref<string[]>([])
const postComment = () => {
comments.value.push(comment.value)
comment.value = ''
}
</script>
<template>
<textarea v-model="comment"></textarea>
<button @click="postComment">投稿</button>
<!-- ユーザーが入力した文字列をそのままHTMLとして描画してしまっている -->
<div v-for="(c, index) in comments" :key="index" v-html="c"></div>
</template>
このコードでは、コメント欄に<img src=x onerror="alert('XSS')">のような文字列を投稿すると、そのままブラウザ上でスクリプトが実行されてしまいます。
私が過去にレビューしたコードでも、「マークダウンをHTMLに変換して表示したい」という要件のためにv-htmlを導入したものの、変換元のテキストがユーザー入力そのものだった、というケースに遭遇したことがあります。
一見「マークダウン変換ライブラリを使っているから安全」と思い込んでしまいがちですが、変換ライブラリ自体がHTMLタグの混入を許してしまう設定になっていると、対策として不十分です。
✅ After:サニタイズライブラリで無害化してからv-htmlに渡す
npm install dompurify
npm install -D @types/dompurify
<script setup lang="ts">
import { ref, computed } from 'vue'
import DOMPurify from 'dompurify'
const comment = ref('')
const comments = ref<string[]>([])
const postComment = () => {
comments.value.push(comment.value)
comment.value = ''
}
// v-htmlに渡す直前で必ずサニタイズする
const sanitize = (html: string) => DOMPurify.sanitize(html)
</script>
<template>
<textarea v-model="comment"></textarea>
<button @click="postComment">投稿</button>
<div v-for="(c, index) in comments" :key="index" v-html="sanitize(c)"></div>
</template>
DOMPurify.sanitize()を通すことで、<script>タグやonerrorのようなイベントハンドラ属性が除去され、安全なHTMLだけが残ります。
「v-htmlを使う箇所は、必ずサニタイズとセットで実装する」というルールをチーム内で徹底しておくと安心です。
属性へのバインディングにも注意する
href属性への動的な値の埋め込み
リンク先URLをユーザー入力やAPIレスポンスから動的に組み立てる場合、javascript:スキームを使った攻撃にも注意が必要です。
<script setup lang="ts">
import { ref, computed } from 'vue'
const userInputUrl = ref('javascript:alert("XSS")')
// 危険なスキームかどうかをチェックしてから使う
const safeUrl = computed(() => {
const url = userInputUrl.value.trim()
if (url.startsWith('javascript:')) {
return '#'
}
return url
})
</script>
<template>
<a :href="safeUrl">リンク</a>
</template>
外部から受け取ったURLをそのままhrefに渡すのではなく、javascript:のような危険なスキームでないかを確認したうえでバインドするようにしましょう。
応用:Content Security Policy(CSP)で多層防御する
アプリ側の対策とサーバー側の対策を組み合わせる
v-htmlのサニタイズはアプリ側でできる対策ですが、それだけに頼らず、サーバー側でもCSP(Content Security Policy)というHTTPヘッダーを設定しておくと、防御を多層化できます。
Content-Security-Policy: default-src 'self'; script-src 'self'
このヘッダーを設定しておくと、たとえ何らかの形で悪意あるスクリプトタグが埋め込まれてしまっても、外部から読み込まれるスクリプトの実行がブラウザ側でブロックされます。
「アプリのコードだけで完璧に防ぐ」のではなく、「複数の防御層を重ねてリスクを下げる」という考え方が、実務のセキュリティ対策では重要です。
まとめ
この記事のポイント
- Vueの
{{ }}によるテキスト出力はデフォルトで自動エスケープされ、XSSに対して比較的安全 v-htmlは自動エスケープの対象外で、ユーザー入力を含む文字列をそのまま渡すとXSSの入口になるv-htmlを使う場合は、DOMPurifyのようなサニタイズライブラリを必ず経由させるhref属性など、URLを動的にバインドする箇所ではjavascript:スキームなどの危険な値にも注意する- アプリ側の対策に加え、CSPのようなサーバー側の設定も組み合わせて多層防御を意識する
次に読むべき記事
次回は「Vitestの基本:Vueでテストを書く準備」を解説します。
認証・セキュリティ編で扱った実装が正しく動いているかを確認するためにも、テストの基礎を押さえていきましょう。
→ 次の記事:Vitestの基本:Vueでテストを書く準備