セキュリティの基本スタンス
「入力はすべて疑え」
- URLパラメータ
- フォーム入力値
- LocalStorage に保存された値
- Cookie
- 外部APIからのレスポンス
これらは全部「信頼できない入力」 とみなします。
「この画面は自分しか使わないし」
「この値は自分のコードが書き込んだだけだし」
と思っていても、
- 将来仕様が変わる
- 別の人が別の場所から書き込む
- 攻撃者が無理やりパラメータを差し込む
ことは普通に起こりえます。
「ブラウザ側だけで防御しない」
- JSでのバリデーションはユーザ体験向上用(早めのフィードバック)の意味合いが強い
- 最終防衛線は必ずサーバ側 で行う(Node.jsならサーバコード側)
フロントエンド:
⇒ 入力ミスを早めに教える、軽いチェック
サーバ:
⇒ 信頼できない入力を最後にチェック・フィルタ・サニタイズ
両方大事ですが、本物の防御は常にサーバ側と覚えておくと安全です。
XSS(クロスサイトスクリプティング)の基本
XSS とは何か
ざっくりいうと、「攻撃者が仕込んだスクリプトを、被害者のブラウザ上で実行させる攻撃」です。
よくあるイメージ:
- アプリが「ユーザーの入力をそのままHTMLに差し込む」
- 攻撃者が
<script>alert("XSS")</script>みたいな文字列を送る - それが画面に差し込まれたとき、HTMLとして解釈されてJSが実行される
結果としてできること:
- Cookieを盗む
- フォームに勝手に別の入力欄を混ぜる
- 勝手にAPI叩かせる(CSRFと組み合わせなど)
典型的な危険コード(innerHTML)
// NG例:ユーザー入力をそのままinnerHTMLに流し込む
const comment = getUserInput(); // 攻撃者が自由に操作できるとする
const div = document.querySelector("#comment");
div.innerHTML = `<p>${comment}</p>`;攻撃者が comment に次を入れるとします:
<script>alert("XSS");</script>→ 結果的に DOM は:
<div id="comment">
<p><script>alert("XSS");</script></p>
</div>となり、alert が実行されてしまいます。
ポイント:
innerHTMLは「文字列をHTMLとして解釈する」
→<script>や<img onerror=...>などもそのまま有効になる
基本防御:textContent を使う
単に「テキストとして表示したいだけ」の場合は、
div.textContent = comment; // ← こちらを使うとします。
textContentは、文字列をエスケープして表示してくれるイメージ<>などの記号はすべて普通の文字として扱われ、タグにならない
つまり、同じ攻撃文字列:
<script>alert("XSS");</script>を textContent で入れると、画面上には:
<script>alert("XSS");</script>と「ただの文字列」として表示されるだけで、スクリプトは実行されません。
どうしてもHTMLを差し込みたい場合
たとえば Markdown をHTMLに変換してレンダリングするなど、ある程度制御されたHTMLを描画したいケースもあります。
この場合は:
- 信頼できない入力は、
- サーバ側で検査・サニタイズする
- あるいはフロントでも、サニタイズ用ライブラリ(DOMPurify など)を必ず通す
innerHTMLを直接信用しない- 極力
document.createElementとappendなどでDOMノードを組み立てる - 必要ならライブラリ側に任せる
- 極力
といったアプローチが有効です。
属性値への差し込みも危険
const name = getUserInput();
const a = document.createElement("a");
a.href = `/user?name=${name}`; // ここに "aaa" onclick="..." みたいな文字列を入れられると?属性値の中に " とか ' が入ってくると、
属性境界を壊して任意の属性を追加される可能性があります。
基本方針:
- できるだけ
element.setAttribute("name", safeValue)を使う - URLなどは
encodeURIComponentでエンコードしてから埋め込む href="javascript:..."のようなパターンを弾く
DOMベースXSS というパターン
最近多いのが、DOM操作だけで発生する XSS です。
例:
- JS が
location.hashから文字列を取り出し、 - それを
innerHTMLやdocument.writeなどでDOMに書き込む
https://example.com/#<script>...</script>のように URL に仕込むだけで攻撃可能になってしまうケースもあります。
「どこから来た文字列なのか」を意識して、
- URL 由来
- localStorage由来
- サーバレスポンス由来
などは常に「信頼できない入力」として扱うのが安全です。
eval / Function コンストラクタの危険性
eval は何をする関数か
eval("alert('hi')");- 文字列を「そのまま」JavaScriptコードとして評価・実行します。
- つまり、攻撃者に“好きなコードを実行させる入り口”を渡すことになります。
const codeFromUser = getUserInput(); // 攻撃者からの文字列
eval(codeFromUser); // ← ここで何でも実行できてしまうXSSと組み合わさると、
<script>eval(location.hash.slice(1))</script>みたいなコードがあり、- URLの後ろに
#alert(document.cookie)と付けられるだけでCookie窃取される、
といった「完全終了パターン」になりがちです。
Function コンストラクタも本質的には eval
const f = new Function("a", "b", "return a + b;");
console.log(f(1, 2)); // 3- これも「文字列から関数を生成する」=ほぼeval です。
- サーバから取ってきた文字列やユーザー入力を渡すと、そのまま任意コード実行の入り口になります。
同様に、
setTimeout("doSomething()", 1000);setInterval("doSomething()", 1000);
も、内部的には eval 的に評価されます。
コールバックは必ず関数で渡すのが基本です:
setTimeout(doSomething, 1000); // OKパフォーマンス的にもデメリット
eval/Functionは- JSエンジンの最適化(JITコンパイル)を阻害しやすい
- 静的解析ツール(ESLintや型チェッカ)が内容を追えない
- つまり「遅くて危険で、ツールにも優しくない」三重苦。
特別な理由がない限り、使用禁止ルールにしてしまって良いレベルです。
代替手段の考え方
「evalを使いたくなる」例と、その代替:
- 文字列で渡された式を計算したい
⇒ NG:const expr = "1 + 2 * 3"; const result = eval(expr);
代替手段:- 専用のパーサライブラリを使う(mathjs など)
- そもそも文字列式を受け取る仕様をやめる
- APIレスポンスに「この関数を実行して」と書きたくなる
⇒ NG:// サーバから "doFoo" みたいな文字列が返ってきて… const fnName = response.fn; eval(`${fnName}()`);
代替手段:const actions = { doFoo() { /* ... */ }, doBar() { /* ... */ } };const fnName = response.fn;const action = actions[fnName];if (action) action();
⇒ディスパッチテーブル(名前→関数のマップ)を使えば安全に切り替えられます。
型チェック・入力バリデーションの重要性
ここは「仕様を満たすための防御」と「セキュリティとしての防御」が両方絡みます。
「値の形」をハッキリ決める
例:フォームで年齢を入力させる場合
- 期待するもの:
- 数値
- 0〜120の整数
- 実際に来るかもしれないもの:
"abc""-100""9999999999""<script>...</script>"
型チェックの例
function parseAge(input) {
const n = Number(input); // or parseInt
if (!Number.isInteger(n)) {
throw new Error("年齢は整数で入力してください");
}
if (n < 0 || n > 120) {
throw new Error("年齢は0〜120の範囲で入力してください");
}
return n;
}- 数値変換(
Number,parseInt) Number.isFinite/Number.isInteger/ 範囲チェック
などを組み合わせて、「不正な値は弾く」ことが大事です。
Node側(サーバ)では必須。
フロント側ではエラーメッセージをユーザーに返す役割。
ホワイトリスト方式が基本
const color = getUserInput(); // 文字列
// NG: 「なんでも許してあとで頑張ってフィルタ」
element.style.color = color;
// OK: あらかじめ許可する値を限定する(ホワイトリスト)
const allowedColors = ["red", "green", "blue"];
if (!allowedColors.includes(color)) {
throw new Error("指定できない色です");
}
element.style.color = color;- 許可するパターンを限定(ホワイトリスト)する考え方
- 「禁止するパターン(ブラックリスト)を頑張って列挙する」のはキリがなく、抜け穴が出がち
型システム(TypeScriptなど)の役割
TypeScriptなどの静的型付けは:
- 開発時 に「ありえない型の組み合わせ」を検出
- 「ここで null かもしれないよ」「ここで string じゃないよ」と教えてくれる
ただし注意点があります:
- 型チェックはコンパイル時の話
- 実行時にはユーザーからどんな値が来るか分からない
「サーバ側では ランタイムでの型チェック/バリデーション が依然として必要」という点を忘れないようにするのがポイントです。
コメント