近況共有アプリを作っていて、ユーザーが入力した文章を他の人に表示する場面が出てきます。
今回のアプリでは、同級生が以下のような情報を投稿できるようにしています。
- 名前
- 今住んでいる地域
- 仕事・活動
- 近況コメント
- SNS URL
つまり、典型的な Stored XSS の入口があります。
この記事では、React + Hono + Cloudflare Workers + D1 で作ったアプリで、XSS に対してどこをどう守ったかをまとめます。
前提
構成は以下です。
React SPA
|
| fetch('/api/...')
v
Cloudflare Workers + Hono
|
v
D1
ユーザー入力は Workers API で受け取り、D1 に保存します。
表示は React SPA 側で行います。
XSS 対策の基本方針
最初に決めた方針は以下です。
- HTML を投稿させない
- 投稿本文を HTML として描画しない
- URL として使う値は URL として検証する
- もし実装ミスがあっても CSP で被害を抑える
- セッション Cookie は JavaScript から読ませない
「入力時に全部エスケープして保存する」よりも、「データは文字列として保存し、HTML として出さない」ことを中心にしています。
React の自動エスケープに乗る
React では、JSX の {...} に文字列を渡すと HTML として解釈されず、テキストとして描画されます。
近況コメントの表示はこの形にしています。
<p className="mt-4 whitespace-pre-wrap text-sm leading-7">
{classmate.comment}
</p>
たとえばユーザーが以下を投稿しても、
<img src=x onerror=alert(1)>
React はこれをタグとして実行せず、文字として表示します。
このアプリでは dangerouslySetInnerHTML を使っていません。
rg "dangerouslySetInnerHTML|innerHTML" src worker shared
リッチテキストや Markdown を表示しできるようにもしようかと考えましたが、難易度が上がることや、そもそもリッチテキストや Markdownを使えるユーザーは多くなさそうで費用対効果が悪いと思いやめました。
今回は近況共有が目的なので、プレーンテキストだけにしています。
改行は CSS で表現する
コメント欄では改行を残したいですが、改行を <br> に変換して HTML を組み立てると危険な実装になりやすいです。
そこで、文字列はそのまま JSX に渡し、見た目だけ CSS で調整しています。
<p className="whitespace-pre-wrap">
{classmate.comment}
</p>
white-space: pre-wrap 相当なので、改行は表示されます。
HTML 文字列を作らずに済むのがポイントです。
Zod で入力値を絞る
入力値はフロントと API で同じ Zod schema を使っています。
export const classmateInputSchema = z.object({
name: z
.string()
.trim()
.min(1, '名前を入力してください')
.max(40, '名前は40文字以内で入力してください'),
currentLocation: z
.string()
.trim()
.min(1, '今住んでいる地域を入力してください')
.max(80, '地域は80文字以内で入力してください'),
job: z
.string()
.trim()
.min(1, '仕事・活動を入力してください')
.max(80, '仕事・活動は80文字以内で入力してください'),
comment: z
.string()
.trim()
.min(1, '近況を入力してください')
.max(300, '近況は300文字以内で入力してください'),
snsUrl: z
.string()
.trim()
.max(200, 'SNS URLは200文字以内で入力してください')
.optional()
.default('')
.refine((value) => value === '' || isValidUrl(value), {
message: 'SNS URLの形式を確認してください',
}),
visibility: z.enum(['public', 'organizer_only']).default('public'),
})
フロントのバリデーションは UX のためで、セキュリティ境界ではありません。
API 側でも必ず parse します。
const input = classmateInputSchema.parse(await c.req.json())
これで、長すぎる文字列や、想定していない型の値を DB に入れないようにしています。
ただし、Zod の文字数制限は XSS の本質的な防御ではなく、本命はあくまで「HTML として出力しないこと」です。
href に入る URL はプロトコルを制限する
投稿には SNS URL を持たせています。
URL は <a href="..."> に入るので、ここは文字列表示より注意が必要です。
危険なのはこういう値です。
javascript:alert(1)
そのため、URL として parse したうえで、許可する protocol を http: と https: に絞っています。
function isValidUrl(value: string) {
try {
const url = new URL(value)
return url.protocol === 'https:' || url.protocol === 'http:'
} catch {
return false
}
}
表示側では以下のように使っています。
{classmate.snsUrl ? (
<a
href={classmate.snsUrl}
rel="noreferrer noopener"
target="_blank"
>
SNSを見る
</a>
) : null}
target="_blank" を使うので、rel="noreferrer noopener" も付けています。
なお、今の実装では http: も許可しています。
SNS URL 用途なら https: だけにしてもよいので、ここは運用方針に合わせてさらに絞れます。
returnTo はアプリ内パスだけ許可する
ログイン後に元の画面へ戻すため、returnTo という値を扱っています。
これは XSS そのものというより open redirect 対策ですが、認証フローで外部入力をリダイレクトに使うので同じく注意します。
実装では、/ で始まるアプリ内パスだけを許可し、//evil.example.com のような値は落としています。
function sanitizeReturnTo(value: string | undefined) {
if (!value || !value.startsWith('/') || value.startsWith('//')) {
return `/g/${defaultSlug}`
}
return value
}
フロント側にも同じ考え方の関数を置いています。
function sanitizeReturnTo(value: string | null) {
if (!value || !value.startsWith("/") || value.startsWith("//")) {
return `/g/${defaultSlug}`;
}
return value;
}
CSP を設定する
XSS は「実装で防ぐ」が基本ですが、最後の防波堤として CSP も設定しています。
Workers 側では middleware でレスポンスヘッダーを付けています。
app.use('*', async (c, next) => {
await next()
c.header(
'Content-Security-Policy',
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'",
)
c.header('X-Content-Type-Options', 'nosniff')
c.header('Referrer-Policy', 'strict-origin-when-cross-origin')
c.header('Permissions-Policy', 'camera=(), microphone=(), geolocation=()')
})
特に XSS 観点で効くのはこのあたりです。
script-src 'self'object-src 'none'base-uri 'self'frame-ancestors 'none'
script-src 'self' により、外部ドメインの script を読み込ませにくくします。
object-src 'none' は古い埋め込み系の攻撃面を閉じます。
base-uri 'self' は <base> タグを差し込まれた場合の影響を抑えます。
frame-ancestors 'none' はクリックジャッキング対策です。
今の CSP では style-src 'unsafe-inline' を許可しています。
これは XSS の理想形としては外したいですが、現状のスタイル構成との兼ね合いで残しています。将来的には nonce/hash やスタイル設計の見直しで狭められます。
Cookie は HttpOnly にする
XSS が起きた場合に一番まずいもののひとつがセッション情報の流出です。
このアプリでは、セッション Cookie を HttpOnly にしています。
function setSessionCookie(c: Parameters<typeof getCookie>[0], value: string) {
setCookie(c, sessionCookieName, value, {
httpOnly: true,
secure: isSecureCookie(c),
sameSite: 'Lax',
path: '/',
maxAge: sessionMaxAgeSeconds,
})
}
HttpOnly を付けると、JavaScript から document.cookie でセッション Cookie を読めません。
これだけだと、ユーザーのブラウザ上で API 操作を実行される可能性は残りますが、セッション値そのものを盗まれにくくする意味があります。
XSS 対策としてやらなかったこと
今回は以下はやっていません。
- 投稿本文の HTML 許可
- Markdown の HTML 埋め込み許可
- 入力値を保存前に HTML エスケープ
- 独自の sanitizer 実装
HTML を許可するなら、DOMPurify のような実績のある sanitizer を使い、許可タグ・属性・URL protocol をかなり慎重に設計する必要があります。
今回はアプリ要件的にプレーンテキストで十分だったので、HTML を扱わない設計にしました。
確認したこと
実装後、最低限以下を確認しました。
rg "dangerouslySetInnerHTML|innerHTML" src worker shared
ユーザー投稿の表示に HTML 注入系 API が使われていないことを確認します。
また、テスト投稿として以下のような文字列を入れて、タグとして実行されず文字として表示されることを確認しました。
<script>alert(1)</script>
<img src=x onerror=alert(1)>
SNS URL には以下のような値を入れて、validation で弾かれることを確認します。
javascript:alert(1)
data:text/html,<script>alert(1)</script>
まとめ
今回の XSS 対策は、特別なことをたくさん入れたというより、危ない形にしないことを優先しました。
- ユーザー投稿は React の JSX でテキストとして表示する
-
dangerouslySetInnerHTMLを使わない - 改行表示は
white-space: pre-wrapで行う - API 側でも Zod で入力を検証する
-
hrefに入る URL は protocol を制限する -
returnToはアプリ内パスだけ許可する - CSP を設定する
- セッション Cookie は
HttpOnlyにする
小さなアプリでも、ユーザー入力を他人に見せる時点で Stored XSS の入口になります。
そこでまずは HTML として出さない設計にして、URL・リダイレクト・CSP・Cookie 属性で周辺を固めました。