11
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

MDNとかをみんなで編集!翻訳! Advent Calendar 2023

Day 17

全ての Web アプリケーション開発者に告げる。文脈に応じたエスケープしませんか。

Posted at

はじめに

この記事は 2023 年の MDN 翻訳 Advent Calendar 向けに作成したものです。

こんにちは。debiru です。Web は略語ではないので WEB と書かないようにしましょう委員会。

今日は、みなさんに「コンテキスト(文脈)に応じたエスケープ」という概念を習得してもらうためにやってきました。

コンテキスト(文脈)とは

一般にコンテキストという言葉は、ある物事に対して、その物事が存在する環境における状態・状況を表す言葉です。ここでは、特に「プログラミングにおける文字列(以下、文字列)」を対象に、そのコンテキストに応じたエスケープの必要性について解説していきます。

文字列のコンテキストとは何でしょうか。次の例を見てください。

<div>
  <p>ようこそ <?php echo $_GET['name']; ?> さん!</p>
  <p>メッセージ:<?php echo $_GET['message']; ?></p>
</div>

PHP で GET パラメータ(クエリストリング)を受け取って HTML ページに表示している例です。このコードには問題があります。なんだか分かりますか?

XSS 脆弱性がある!

XSS(クロスサイトスクリプティング)は、別名 HTML インジェクション、JavaScript インジェクションとも呼ばれる脆弱性の一種です。インジェクションとは「注入」を意味し、XSS に脆弱であるサイトは、第三者が任意の HTML や JavaScript コードをその Web ページに注入することができることを意味します。

インジェクション系の脆弱性には他に以下のようなものが存在します。(徳丸本第2版 p.100 より引用・加筆)

脆弱生名 悪用手口
クロスサイト・スクリプティング HTML, JavaScript などの注入
HTTP ヘッダ・インジェクション HTTP レスポンスヘッダの注入
SQL インジェクション SQL 文の注入
OS コマンド・インジェクション OS コマンドの注入
メールヘッダ・インジェクション メールヘッダ、本文の注入・改変
<div>
  <p>ようこそ <?php echo $_GET['name']; ?> さん!</p>
  <p>メッセージ:<?php echo $_GET['message']; ?></p>
</div>

例えば message 値として <script>alert(1)</script> が渡ってきたらどうなるでしょうか。上記の PHP コードは次のような HTML を生成します。

<div>
  <p>ようこそ debiru さん!</p>
  <p>メッセージ:<script>alert(1)</script></p>
</div>

<script> 要素が注入されてしまっています。任意の JavaScript コードを外部から指定することで、第三者(攻撃者)が自由に悪意あるスクリプトを実行できてしまいます。

ログインを伴う Web サイトであればアカウントの認証情報の要となるセッション ID を盗まれてしまったり、Web ページの HTML を改竄してフィッシングサイトを作ることで利用者のログイン ID とパスワードの組やクレジットカード情報などを盗聴する攻撃が為されてしまいます。

上記は $_GET['message'] のように GET パラメータを参照する例でしたが、データベースから値を取得して表示する場合も同様です。第三者が自由に HTML や JavaScript を記述することを想定していない正規の Web ページに対して、第三者が任意の HTML, JavaScript を注入できるということは、非常に危険な状態であるわけです。

では、XSS 脆弱性の心配がなければ、先程のように変数(値)を単に echo して出力するコードは問題ないのでしょうか。次の例を見てください。

予め用意した文字列なら単なる echo でも問題ない?

<?php
$items = ['<a>', '<button>', '<br>', '<dl>', '<fieldset>', '<xmp>'];
$randomItem = $items[array_rand($items)];
?>
<div>
  <p>ようこそ!</p>
  <p>今日のラッキーアイテムは <?php echo $randomItem; ?> です。</p>
</div>

この PHP コードは次のような HTML を生成します。

<div>
  <p>ようこそ!</p>
  <p>今日のラッキーアイテムは <br> です。</p>
</div>

これでは <br> という文字列を表示することができません。出力された <br> は HTML の一部として扱われ、文字列として表示されないからです。ブラウザ上では次のようなテキストとして出力されます。

ようこそ!
今日のラッキーアイテムは
です。

ではどうすればいいのでしょう。<br> という文字列を HTML 上で表示するには、メタ文字 <, > をエスケープする必要があります。<&lt; に、>&gt; として出力する必要があるのです。(厳密には HTML 要素タグ開始区切り子 < のみのエスケープで十分ですが、終了区切り子 > もセットでエスケープすべきと覚えておいたほうがよいでしょう。)

PHP にはこれを行う htmlspecialchars() 関数が用意されています。この関数は第 2 引数でエスケープする文字の種類を、第 3 引数で文字エンコーディング方式を指定できるのですが、毎回指定するのは面倒なのでラッパー関数を用意して使うようにしましょう。

function esc_html($str, $flags = ENT_QUOTES) {
  return htmlspecialchars($str ?? '', $flags, 'UTF-8');
}

function output($str) {
  echo esc_html($str);
}

これを使って書き換えると次のようになります。

<?php
function esc_html($str, $flags = ENT_QUOTES) { return htmlspecialchars($str ?? '', $flags, 'UTF-8'); }
function output($str) { echo esc_html($str); }
$items = ['<a>', '<button>', '<br>', '<dl>', '<fieldset>', '<xmp>'];
$randomItem = $items[array_rand($items)];
?>
<div>
  <p>ようこそ!</p>
  <p>今日のラッキーアイテムは <?php output($randomItem); ?> です。</p>
</div>
<div>
  <p>ようこそ!</p>
  <p>今日のラッキーアイテムは &lt;br&gt; です。</p>
</div>

ブラウザ上では次のようなテキストとして出力されます。

ようこそ!
今日のラッキーアイテムは <br> です。

この PHP コードには XSS の恐れはありませんが、単に echo するだけでは問題があったのです。これを本質的に解決する方法が「コンテキスト(文脈)に応じたエスケープ」だったのです。

HTML エスケープは XSS 対策のために行うものではないのです。その文字列(変数値)を HTML コンテキスト(つまり HTML 文字列として)で出力したいのか、それとも単なるテキストとして出力したいのかによって、出力時に文字列(変数値)を加工(エスケープ)する必要があったというわけです。

ただし、このエスケープを行わないことで XSS 脆弱性が生じるという事実もあります。コンテキストに応じたエスケープが常に必要であるということを忘れずに実装していれば、XSS 脆弱性を生じさせる危険性を軽減できます(なお、他の原因による XSS 脆弱性の存在があります;後述)。

コンテキスト(文脈)に応じたエスケープは、XSS 対策のためのものではない」ということを覚えていただければと思います。

どこまでエスケープを行うべきか

例えば次のコードを見てください。

<?php
$count = get_access_counter();
?>
<p>ようこそ!あなたは <?php echo $count ?> 人目のお客様です!</p>

$count には整数値が入ります。ということは、HTML メタ文字が入る可能性がありません。すなわち、エスケープが必要となるケースがないのです。

では、上記のような変数値であればエスケープしなくてよいのでしょうか。

答えは No です。

何故か。それは、変数値の具体的な中身に応じてエスケープの有無を判断するという発想自体が愚かだからです。値の出力時には常にエスケープしておけばよいのです。逆に、HTML コンテキストで値を出力したいという例外時のみ、明示的にエスケープを介さずに出力すべきです。その際も、PHP であれば echo を使うのではなく、「意図して HTML コンテキストで出力していますよ」ということが分かるようなラッパー関数 output_html() でも用意してそれを使うようにすべきです。こうした工夫が、セキュアな Web アプリケーション開発を実現するためには有益なのです。

ここで、「'No.' や '題名:' や '名前:' や '日時:' の定数文字列まで htmlspecialchars に通すなんて無駄じゃないか」などということを考えてはいけない。いまどき、そんな貧民的プログラミング思考をするのはプログラム職人として恥ずかしいことだ。

サニタイズ言うなキャンペーン

ユーザー入力値をデータベースに保存して、その値を出力するようなケースでは、いつ値の加工(エスケープ等)を行うべきでしょうか。――答えは入力時ではなく出力時です。

昔々、Web アプリケーションがデータを受け取った直後(データベースに保存する前)に受け取った値をサニタイズすればよいと考える人々がいました。サニタイズとは何でしょうか?それは例えば <script> という文字列を入力値に見つけたら、その文字列を削除あるいは <xscript> のように加工してしまうといった種類の加工(サニタイズ、消毒)を行うというものです。

サニタイズは本質的には誤った対策です。そもそもサニタイズが何を指すのか不明です。にもかかわらず、「コンテキストに応じたエスケープ」という発想が浸透していなかった時代の一部の人々は、サニタイズこそが適切な対策だと信じていたのです。

サニタイズなんてしなくとも、出力時に適切なエスケープを施せばよいのです。しかし、サニタイズという発想があると、「サニタイズしたから大丈夫だ」とエスケープの必要性に気付く機会が失われてしまいます。そういう意味で「サニタイズ言うな」なのです。

……という話は、20年ほど前に偉大なる高木浩光氏が口を酸っぱくして指摘していた内容です。詳しくは以下の記事を参照ください。

余談

エスケープを徹底していても XSS 脆弱性を生むケースがある

XSS 脆弱性を生む原因は「エスケープ漏れ」だけではありません。コンテキストに応じたエスケープを徹底していても XSS 脆弱性が生じるケースがあります。それは例えば URL 値をユーザー入力値から生成する場合です。

<a href="<?php output($_GET['url']); ?>">おすすめのページ</a>

このような PHP コード、あるいは次のような Markdown コードを考えてみましょう。

[Qiita の記事に書けるリンク構文](https://example.com/)

これはどちらも HTML エスケープという面では問題がありません。(後者はエスケープが内部的に行われるので上記コードを見てもエスケープ処理の有無は分かりませんが。)

例えば、URL として "><script>alert(1)</script> という文字列を渡したからといって、スクリプトが実行されるわけではありません。上記は、次のような HTML として展開されます。

<a href="&quot;&gt;&lt;script&gt;alert(1)&lt;/script&gt;">...</a>

しかし、URL として javascript:alert(1); が渡されると、次のような HTML が生成されてしまいます。

<a href="javascript:alert(1);">...</a>

第三者が URL 文字列を指定できるような Web ページが存在する場合は、その URL のスキームが http または https であるか、あるいは相対 URL であるかをチェックする対策が有効です。このケースの XSS 脆弱性は、http または https 以外の URL スキームを許容してしまったことで生じます。javascript スキームを禁止するだけでは対策になりません。なぜなら data スキームなど他のスキームでも XSS 攻撃が可能だからです。

XSS の種類

GET パラメータのような URL から渡されるパラメータや Cookie のような HTTP リクエストヘッダから渡されるパラメータを出力することによって生じる XSS を「反射型 XSS」といい、一度サーバーサイドのデータベース等のストレージに保存された値をデータベースから取り出して出力することによって生じる XSS を「蓄積型 XSS」といいます。また、クライアントサイドの JavaScript で動的に値を構築してその値を出力することによって生じる XSS を「DOM Based XSS」といいます。

反射型 XSS

Reflected XSS, 非持続型 XSS とも呼ばれます。

典型的には URL のクエリストリングで渡された GET パラメータを Web アプリケーションが受け取って、その値を出力することによって XSS 攻撃が成立してしまうケースを「反射型 XSS」といいます。

反射型 XSS は、攻撃者が仕込んだコードを含む URL にアクセスすることによって生じます。そのため、ユーザーはアクセスする URL を注意深くチェックすることで、反射型 XSS を回避することができます。

蓄積型 XSS

Stored XSS, Persistent XSS, 持続型 XSS とも呼ばれます。

典型的には HTML フォームから送信された値を Web アプリケーションが受け取って、その値をデータベースに保存し、保存された値を出力することによって XSS 攻撃が成立してしまうケースを「蓄積型 XSS」といいます。

蓄積型 XSS は、攻撃者が一度コードを仕込んでしまえば、その後、蓄積型 XSS に脆弱な正規のサイトにユーザーがアクセスしただけで被害が生じてしまいます。つまり、ユーザーはこの蓄積型 XSS を事前に防ぐ手段がありません。

DOM Based XSS

クライアントサイドの JavaScript コード内で、ユーザー入力値を(反射型、蓄積型問わず)受け取って、その値を出力することによって XSS 攻撃が成立してしまうケースを「DOM Based XSS」といいます。

このケースでは、サーバーサイドの HTML 出力時点まででは XSS 攻撃が成立していない(サーバーサイドの HTML 出力には問題がない)ため、サーバーサイドのログ調査やデバッグを行うだけではこのケースの XSS を防ぐことができません。

セキュリティの知見のあるバックエンド(サーバサイド)開発者と、セキュリティに疎いフロントエンド開発者が協力して Web アプリケーション開発を行う際に生じやすい XSS 脆弱性です。

しばしば、こんな JavaScript コードが書かれることがあります。

(function() {
  async function main() {
    const API_URL = 'https://jsonplaceholder.typicode.com/users';
    const data = await fetch(API_URL).then(response => response.json());
    const container = document.querySelector('.container');
    const ul = document.createElement('ul');
    for (const person of data) {
      const li = document.createElement('li');
      li.innerHTML = `名前: <span class="name">${person.name}</span>`;
      ul.append(li);
    }
    container.append(ul);
  }

  document.addEventListener('DOMContentLoaded', main);
}());

DOM の要素内容を、ユーザー入力値が含まれるにも拘わらず文字列結合で構築してしまっています。これはいけません。ユーザー入力値に HTML メタ文字が含まれていた場合に正しく表示できないばかりか、HTML あるいはスクリプトコードが渡された際に XSS 攻撃が成立してしまいます。

ここでは敢えて修正方法は示しませんが、修正方法がわからないようなフロントエンドエンジニアの方がこの記事を読んでいるのであれば、今すぐこの記事にコメントを付けてください。対策について詳しくお教えします。

関連記事

さいごに

全ての Web アプリケーション開発者に「コンテキスト(文脈)に応じたエスケープ」 という言葉を胸に刻んでいただければ幸いです。この概念がない技術者と開発を共にすると Web アプリケーションのセキュリティは地の底に落ちてしまいます。

大事なことなのでもう一度いいます。「コンテキスト(文脈)に応じたエスケープ」。覚えてくださいね。

2023 年の MDN 翻訳 Advent Calendar も残すところあと 1 週間となりました。最後まで書ききれるか分かりませんが、残る日数も記事を書き続けてみたいと思います。

おわり。

11
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?