241
218

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

PHPAdvent Calendar 2022

Day 24

なぜ出力時のHTMLエスケープを省略してはならないのか

Last updated at Posted at 2023-01-01

メリークリスマス! 週末もPHPを楽しんでますか? :santa_tone2:
ところでWebセキュリティはWebアプリケーションを公開する上で基礎中の基礎ですよね! :innocent:

メジャーな脆弱性を作り込まないことはWeb開発においては専門技術ではなく、プロとしての基本です。
中でもXSS (Cross-Site Scriptingクロスサイトスクリプティング)やインジェクションについての考慮は常に絶対に欠いてはならないものです。

現実にはプログラミングには自動車のような運転免許制度がないため、自動車学校に通わず独学で公道に出ることができてしまいます。つまりは基礎知識がないままにWebプログラマとして就職したり、フリーランスとして案件を請けることも現実には罷り通っています。それは一時停止標識も赤信号も知らずにタクシー営業しているようなものです。

このような事情により、体系的な理解のないWeb開発初心者は (時にはn年のキャリアを持つ人でも) 雰囲気による曖昧な基準でHTMLエスケープをしたりしなかったりするのが現実です。

XSSについて以下のような認識を持っているとしたら、おそらく誤解している可能性があります。

  • XSS対策は悪意ある不届き者ハッカーの対策のために実施するものだ
  • 送信側のHTMLフォームでバリデーションすることでエスケープは省略しても問題ない
  • XSS対策には入力値のバリデーションとサニタイズが重要である
  • 善良なユーザーは '"><script>alert(1)</script> のような入力をしないので積極的にサニタイズすべきだ
  • XSS対策として、あらゆるWebアプリにWeb Application Firewallを導入することで根本的な対処が期待できる
  • 社内でしか利用されないようなものであればエスケープは省略できる
  • 単なる数字や日時のようなものは安全であり無駄なのでエスケープすべきではない
  • データをDBに記録するときにサニタイズ/エスケープしておけば安全である

これらの認識はすべて、以下のいずれかのパターンに分類できます
• ロジックに考慮漏れがあり、不具合、脆弱性、データの不整合をもたらす
• XSSを抑止できたとしても意図しない不合理な挙動や不必要に複雑な実装を要求する
• 実施しても差し支えないが、単体ではXSS対策として根本的に不足している

用語について確認

前もって、この記事で扱う用語について整理しておきましょう。

XSS (クロスサイトスクリプティング)

クロスサイトスクリプティング (XSS) とは、悪意あるクライアントサイドのコードをウェブサイトに挿入するセキュリティ攻撃です。挿入されたコードは被害者のブラウザー上で実行され、アクセス制限の回避やユーザーへのなりすましなどにつながります。

字句通りに解釈するとWebサイトをまたいでスクリプトを実行できることを指していそうに見えますが、歴史的経緯により攻撃者が任意のHTMLやJavaScriptをインジェクションできる脆弱性一般を指します。つまりSQLインジェクションOSコマンドインジェクションと同様のエスケープ不備によるインジェクションの一種です。

エスケープ

あるコンピュータ言語の文脈で、文法的に特殊な意味を持っているために通常の方法では表せない文字を表記するための記法のことです。たとえばC言語に影響されたプログラミング言語では "str" のように " で括ることで文字列を表現するため、Don't say "lazy"のような " を内包する文字列をそのままでは表記できません。そのため、"Don't say \"lazy\"" のように \ を前置することで " という文字そのものを表せます。

エスケープのルールは言語によって異なります。たとえばSQL標準の識別子では "Don't say ""lazy"""、同じくSQL標準の文字列では'"Don''t say "lazy"' のように同じものを重ねることでエスケープします。HTMLにおいては文字実体参照という表記を用いることで < > " ' & のようなHTMLの文法的に意味を持つ文字をエスケープできます。

HTMLで(ひいてはQiitaのようなWebサイトで)<div>のようなタグを文字列として表示できるのは&lt;div&gt;のようにエスケープしているからです。そして&を表示できているのは&amp;とエスケープしているからで、それが表示できているのは&amp;amp;と書いているからです。

何を行っているのかわかりにくいと思いますが、Chromeの開発者ツールなどでページのHTML構造を調べると、これらの文字をエスケープの有無などによって明確に色分けして表示してくれます。

スクリーンショット 2022-12-31 19.11.36.png

このようにプログラミング言語間で文字列を受け渡して適切に評価・エスケープするためには、文脈によって適切に処理する必要があります。

エスケープは文字列を相互運用するための唯一の方法とは限らないことに気をつけてください。
たとえばDB製品によってはプリペアドステートメント機能でエスケープせず文字列を直接送信できます。

サニタイズ

これが今回の用語でもっとも物議を醸す用語であることに気をつけてください。
サニタイズが具体的に何を指すのかという定義を求めることは極めてナンセンスです。

まず、辞書には以下のように記述があります。

sanitize (三単現: sanitizes, 現在分詞: sanitizing, 過去形: sanitized, 過去分詞: sanitized)

  1. (他動詞) 消毒、掃除して雑菌を部分的に殺す。殺菌する。衛生処理をする。清潔にする。
  2. (他動詞, 転じて) 不愉快な要素を取り除いて受け入れられるものを作る。不適切な部分を除去する(演劇作品など)。
  3. (他動詞, 情報技術) 問題のある情報を不特定多数がアクセスするデータベースやファイルから取り除く。機密情報を除去する。サニタイズする。
  4. (他動詞) 情報源を特定できないように文書を修正する。

(sanitize - ウィクショナリー日本語版 2018年6月29日(金) 23:37‎の版より引用)

sanitizeという言葉で表わさんとしているのは「消毒」「衛生処理」などというニュアンスでしょう。 3で説明されている意味が近いと感じたかもしれませんが、これは明確に似て非なるものであり、混同してはいけません。

インジェクション攻撃に対するサニタイズであれば、極論すれば攻撃が成立しなくなる方法であれば、どんなことをしてもサニタイズだと呼べうるでしょうか。前述したエスケープもサニタイズの一般的な方法のひとつだといえますが、この記事においては単に「サニタイズ」というときはエスケープ以外の方法を指すことにしましょう。

問題は、サニタイズという用語で想像される処理がばらばらで、コミュニケーションの役に立たないことです。

  • 入力値をHTMLエスケープすることをサニタイズと呼ぶ例
  • 入力値をURLエンコードすることをサニタイズと呼ぶ例
  • 入力値からHTMLとして機能する文字を除去することをサニタイズと呼ぶ例
  • 入力値からディレクトリトラバーサルを起こしうる ../ を除去する例
  • 入力値から意図しないHTMLタグや属性を除去する例

……などなど、まったく一貫性がありません。

危険なものを取り除きたいと思うのは結構ですが、「なんとなく危なそうな文字を当てずっぽうで除去する」というのは極めてナンセンスであり、それをセキュリティとは言いません。ましてやその「サニタイズ」とかいう処理がセキュリティ対策だと思い込んで中途半端に文字を削った結果として攻撃文字列が完成してしまうと目も当てられません。

サニタイズと呼ばれる手法を使うとしても、なぜサニタイズが必要で、どのように有効なのかを考えて適用する必要があるでしょう。

でたらめなサニタイズ (たとえばSQLインジェクションを防ぐ目的でHTMLエスケープする) などでも攻撃を防げることがありますが、プログラムを不必要に複雑にします。そんな偶然に頼った当てずっぽうをセキュリティとは到底呼べません。

ユーザーの入力をサニタイズ(除去)することが本当に許されることなのか慎重に判断してみてください。
たとえば、あなたがQiitaのようなブログサービスを運営するとしたら、HTMLやJavaScriptの仕様についての議論を投稿する際にHTMLのような文字列を片っぱしからサニタイズすることに妥当性はあるのでしょうか。
あなたのWebサイト上で技術者を相手にするものではなかったとしても、文字を不必要に除去することは多くの場合において利用者を混乱させます。

2022年12月現在議論が進められているHTML Sanitizer APIはHTMLをパースした上で適切に処理されるように設計されているようで、ここまでに挙げた単純な文字除去処理と一線を画しています。ただしこれは安全なHTML組み立てを行った上で、意図しない要素が本当に含まれないかを最後の水際で保障するのが適切な用法であり、通常の動的ページのレンダリングのために使うものではありません。

とても残念なことに、世の中のほとんどの場で言及される「サニタイズ」はナンセンスなものです。
PHP組み込みの除去フィルタ(sanitize filters)も例外ではなく、明確な意図なしに使うべきではありません。

そもそも、中途半端に「サニタイズ」をして処理を続行、永続化することに意味はあるのでしょうか。意図しない入力があった場合は処理を続行させるのではなく、可能な限り早期に処理を打ち切ってエラーとして表示するか、ユーザーに適切なデータの入力を促すことが重要です。そのときに行うのべきなのは入力値検証(validationバリデーション)であってサニタイズではありません。また、XSSやSQLインジェクションのようなインジェクション攻撃の対策とは別問題です。

一般的な対策

まずは安全なウェブサイトの作り方を参照しましょう。これは2006年から更新され続けており (内容にやや古さがあり、今となってはサーバーサイド偏重だと感じるものの) 、Webプログラマーがプロを名乗るならば、セキュリティ専門でなくとも最低限押さえておくべき知識の基準線を示す資料だといえましょう。

XSSに関しても以下の考えかたが原則であり、これを徹底することが肝要です。

根本的解決
5-(i) ウェブページに出力する全ての要素に対して、エスケープ処理を施す。

ウェブページを構成する要素として、ウェブページの本文やHTMLタグの属性値等に相当する全ての出力要素にエスケープ処理を行います。エスケープ処理には、ウェブページの表示に影響する特別な記号文字(「<」、「>」、「&」等)を、HTMLエンティティ(「&lt;」、「&gt;」、「&amp;」等)に置換する方法があります。また、HTMLタグを出力する場合は、その属性値を必ず「"」(ダブルクォート)で括るようにします。そして、「"」で括られた属性値に含まれる「"」を、HTMLエンティティ「&quot;」にエスケープします。

脆弱性防止の観点からエスケープ処理が必須となるのは、外部からウェブアプリケーションに渡される「入力値」の文字列や、データベースやファイルから読み込んだ文字列、その他、何らかの文字列を演算によって生成した文字列等です。しかし、必須であるか不必要であるかによらず、テキストとして出力するすべてに対してエスケープ処理を施すよう、一貫したコーディングをすることで、対策漏れ(*3)を防止することができます。

なお、対象となる出力処理はHTTPレスポンスへの出力に限りません。JavaScriptのdocument.writeメソッドやinnerHTMLプロパティ等を使用して動的にウェブページの内容を変更する場合も、上記と同様の処理が必要です。

(安全なウェブサイトの作り方 - 1.5 クロスサイト・スクリプティング:IPA 独立行政法人 情報処理推進機構より引用、2022年12月25日閲覧)

コードの実例については言語やフレームワークによって異なるので次の章以降で実例を示します。

重要なのは出力する全ての要素に対して一貫したコーディングをすることです。

フレームワークを使わないPHPの例

ここで言及されているエスケープ処理としては、プレーンなPHPではhtmlspecialchars()を利用できます。

例として、ブログの記事のようなものを表示する /article.php?id=12345 のようなスクリプトを考えてみましょう。

扱いやすくするため、配列は以下のような構造をとることにしましょう。 (array-shape記法)

array{
    id: positive-int,
    title: string,
    body: string,
    created_at: DateTimeImmutable,
    updated_at: ?DateTimeImmutable
}

以下のようなコードはドキュメントルート以下に直接置かれて起動される article.php としましょう。

❌ 不適切な例
<?php

const SITENAME = '俺のホームページ'; // 定数をHTMLと同じファイルで定義している

function get_article($id) {
    // この記事の本筋ではないので省略
}

error_reporting(0); // すべてのエラーを無視
$id = $_GET['id']; // バリデーションなしでの入力受け取り
$article = get_article($id);
$title = htmlspecialchars($article['title']); // 記事が存在

// HTML断片を文字列として扱っている
$datetime = '投稿日: <time datetime="' . $article['published_at']->format(DATE_ATOM) . '">'
    . $article['published_at']->format('Y年m月d日 H時i分s秒') . '</time>';
if ($article['updated_at']) {
    $datetime .= '<br>更新日: <time datetime="' . $article['updated_at']->format(DATE_ATOM) . '">'
        . $article['updated_at']->format('Y年m月d日 H時i分s秒') . '</time>';
}
?>
<!DOCTYPE html>
<title><?= $title ?> | <?= SITENAME ?></title> <!-- エスケープ有無が混在している -->
<h1><?= $title ?></h1> <!-- ここではエスケープされているがこの箇所だけを見ても判別できない -->
<p><?= $datetime ?></p> <!-- HTML断片をエスケープなしで出力しており安全かどうか判別できない -->
<article><?= nl2br($article['body']) ?></article> <!-- エスケープせずに nl2br() に渡し出力している -->
<p><a href="/comment?id=<?= $id ?>&amp;mode=1">この記事へのコメントはこちら</a></p> <!-- 明確にXSSの危険がある、URLエンコードもしていない -->
<?php /* そもそも <!-- でコメントを書くとユーザーに見えてしまうので不適。コメントは出力されないようにすべき */ 

より良い例に書き換えてみましょう。

⭕️ ベターな例
<?php

// 定義とスクリプトは分割する
require __DIR__ . '/../src/bootstrap.php';

// IDとして 1 以上の整数だけを許容する
$id = filter_var($_GET['id'] ?? null, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);

// 入力が不適切(false)、または存在しない記事にアクセスされたら、404 Not Foundとして処理を打ち切る
if ($id === false || ($article = get_article($id)) === null) {
    html_response_code(404);
    include __DIR__ . '/../template/_notfound.php';
    return;
}

// 記事ページのURL。 http_build_query() で id=123&mode=1 のようなクエリパラメータを組み立てる
$comment_url = '/comment?' . http_build_query(['id' => $id, 'mode' => '1']);
?>
<!DOCTYPE html>
<?php /* 以下の変数と定数出力すべて <?= h() となることで、エスケープの漏れがないとわかる */ ?>
<title><?= h($article['title']) ?> | <?= h(SITENAME) ?></title>
<h1><?= h($article['title']) ?></h1>
<?php /* 日時の出力を中途半端に文字列に分けたりしない */ ?>
<p>
    投稿日: <time datetime="<?= h($article['published_at']->format(DATE_ATOM)) ?>">
        <?= h($article['published_at']->format('Y年m月d日 H時i分s秒')) ?></time>
    <?php if ($article['updated_at']): ?>
        <br>更新日: <time datetime="<?= h($article['updated_at']->format(DATE_ATOM)) ?>">
            <?= h($article['updated_at']->format('Y年m月d日 H時i分s秒') ?></time>
    <?php endif ?>
</p>
<?php /* nl2br() は例外、エスケープしてから改行を <br> にしなければならない */ ?>
<article><?= nl2br(h($article['body'])) ?></article>
<?php /* URLエンコード済みの値であってもHTMLエスケープは省略しない */ ?>
<p><a href="<?= h($comment_url) ?>">この記事へのコメントはこちら</a></p>

基本形は <?= h(...) ?> です。
<?= ... ?> の中に h() が入っていなければ、エスケープが漏れてリスクがあるとわかります。
属性値は attr="<?= h(...) ?>" の構造になるようにします。

  • 定義ファイルを明確に分離するのはPSR-1で推奨されている(SHOULD)ベストプラクティスです
  • filter_var(filter: FILTER_VALIDATE_INT) で入力値を検証できます
  • $id が不正、もしくは記事が取得できない場合には確実に処理が打ち切られるようにします
    • html_response_code(404) で検索エンジンなどにコンテンツが存在しないURLであることを伝えます
  • http_build_query()で配列をクエリパラメータにまとめて変換できます
    • これもXSS対策ではありませんが、適切なURLに確実にリンクするために重要です

?key=val&key2=val2 にリンクする際、 <a href="?key=val&key2=val2"> と書かれがちです。
HTMLでは&&amp;とエスケープする必要があるため、より適切には<a href="?key=val&amp;key2=val2">と書かなくてはなりません。<a href="<?= h($url) ?>"> のように書けば適切にエスケープされます。

bootstrap.php
/** サイト名 */
const SITENAME = '俺のホームページ';

/**
 * htmlspecialchars() って書くのは長いから用意するラッパー
 *
 * @pure
 */
function h(string $s): string {
    return htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
}

/**
 * @param positive-int $id 1以上の整数
 * @return ?array{
 *     id: positive-int,
 *     title: string,
 *     body: string,
 *     created_at: DateTimeImmutable,
 *     updated_at: ?DateTimeImmutable
 * }
 */
function get_article(int $id): ?array {
    // この記事の本筋ではないので省略
}

ここまでのコードは記事本文がプレーンテキストであり、マークアップされていないことを前提としています。

1.5.2 HTMLテキストの入力を許可する場合の対策
根本的解決
5-(vi) 入力されたHTMLテキストから構文解析木を作成し、スクリプトを含まない必要な要素のみを抽出する
入力されたHTMLテキストに対して構文解析を行い、「ホワイトリスト方式」で許可する要素のみを抽出します。ただし、これには複雑なコーディングが要求され、処理に負荷がかかるといった影響もあるため、実装には十分な検討が必要です。

前述したHTML Sanitizer APIは、この目的で利用できるものです。また、PHPではHTML Purifierというよく知られた実装があります。

Laravel (Blade)

LaravelにはBladeというHTMLテンプレートエンジンが組み込まれています。

Bladeに限らず、新しめのモダンなテンプレートエンジンはシンプルに出力するとデフォルトでエスケープしてくれるようになっています。

Laravelでは {!! ... !!} のように記述するとHTMLエスケープを迂回できますが、危険なので原則として避けるべきです。基本的には @section + @yield@include@component + @slot などで別テンプレートを再利用したり、Htmlable な値として扱うようにしてください。
Htmlableな値を自作すること自体にもリスクがあるので、細心の注意をもって組み立てたり、動的な値の場合は適切にHTML Purifierなどで検査することで安全性を担保できるようにするとよいでしょう。

JavaScript/JSONによるデータ受け渡し

PHPからJavaScriptにデータを受け渡すというのもよくあるユースケースです。話が長くなるので別記事に分けました。

この記事の背景

そのむかし「サニタイズ言うな」という議論がありましたが2022年現在では忘れられつつあります。
この記事の初版は「サニタイズとエスケープは違う。汝エスケープを怠るなかれ。」として書かれました。

基本的な話は2007年に書かれた以下の記事にまとまっています。

さらにこの記事に至るまでの「議論」は以下の記事にまとめられています。

われわれは2022年の未来人なので15年前の議論について「不毛な反論が多いな」と感じますが、それだけCGIの時代の常識(とされていた実装パターン)と、現代のフレームワークで守られた環境では前提が異なります。

Laravelのようなフレームワークでは何もしなくても出力時でデフォルトでエスケープされるため、現代はこのような問題があることが気付きにくい環境になったとも言えます。

まとめ

冒頭で「すべて誤解だと」述べた項目を再掲しておきましょう。

  • XSS対策は悪意ある不届き者ハッカーの対策のために実施するものだ
    • エスケープは不届き者とは関係なく、文字列を適切にHTMLに出力するために行ないます
    • これを怠ると攻撃とは関係なく < > を使った文字列が意図通りに表示されません
  • 送信側のHTMLフォームでバリデーションすることでエスケープは省略しても問題ない
    • HTMLフォームバリデーションはユーザー向け入力支援であり、XSS対策ではありません
    • また、HTMLフォームは書き換え可能なのでリクエストのバリデーションを省略することにも問題があります
  • XSS対策には入力値のバリデーションとサニタイズが重要である
    • 入力値のバリデーションはXSSの根本的な対策ではありません
    • サニタイズ(削除または置換)はユーザーの入力した値を破損するものなので
  • 善良なユーザーは '"><script>alert(1)</script> のような入力をしないので積極的にサニタイズすべきだ
    • 関係ありません。ユーザーがどんな入力をしようと特別扱いはしない方が懸命です。
    • XSSについての記事を書いてるからといってQiitaにXSSを試みてるとか言われても…
  • XSS対策として、あらゆるWebアプリにWeb Application Firewallを導入することで根本的な対処が期待できる
    • できません。
    • ただし、基本的な対策を実施した上で蟻の一穴を塞ぎたい場合や未知のパターンによるゼロデイ攻撃の予防が期待できる可能性もあります
    • また、セキュリティ水準が信用できないアプリケーションを運用せざるを得ない場合には有効かもしれません
  • 社内でしか利用されないようなものであればエスケープは省略できる
    • エスケープの必要性の有無と利用者の信頼性は無関係です
  • 単なる数字や日時のようなものは安全であり無駄なのでエスケープすべきではない
    • 必要はありませんが、コード上で一貫した記述をすることでエスケープの漏れをなくすことが重要です
    • 現代のコンピュータでは短い文字列を出力時にその都度エスケープするコストは十分無視できるもので、Webアプリケーションの処理時間のボトルネックになることはありません
  • データをDBに記録するときにサニタイズ/エスケープしておけば安全である
    • エスケープ済み/未エスケープの値が混在するので永続化データはエスケープすべきではありせまん
    • また、HTMLでエスケープしたデータを永続化してしまうと、JSONなどHTMLとは無関係な言語に変換する際に不要な変換の手間がかる、本来不適なエスケープを適用したデータとして出力してしまうなど不必要に複雑な処理やデータの不整合の原因になります

というわけでこの記事で言いたかったことは「入力サニタイズとか小賢しいことは言わないで適切な箇所でエスケープだけきっちりやっとけ」ということでした。いい加減に飽きてきたのでこの辺で終らせておきましょう。とっぴんぱらりのぷう。

おまけ: <?=h() について

<?= ... ?><?php echo ... ?> の短縮形である。また、htmlspecialchars()h()と略すのを最初に発明したのは誰かは知らないが、CakePHPなどでよく知られており、歴史の荒波に耐えた公知のパターンだと言っていいだろう。ちなみにRubyのテンプレートエンジンであるERBでもhメソッドとして利用できる。

<?php echo h($obj->format()) ?><?= h($obj->format()) ?> となり、キーボードの打鍵数としても人間の視覚的に処理しなければならない文字数としても、 php echo の2単語は決してばかにならない削減であると考えている。

なんでもかんでも略語で短縮することは考えものだが、HTMLエスケープは(デフォルトエスケープされないテンプレートエンジンにおいては)HTMLに全ての値を出力する際に避けて通れない(通るべきではない)ものなので、 <?= h(...) ?> のように極端に短縮することには合理性があると考える。

せっかくなので関数をもう一度再掲しておこう。

/**
 * htmlspecialchars() って書くのは長いから用意するラッパー
 *
 * @pure
 */
function h(string $s): string {
    return htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
}

@pureの部分はこの「おまけ」節を加筆する際にしれっと付け足したものだが、割と重要だ。@pureとマークすると、その関数が純粋関数であることを表す。純粋関数をひとことでいうと「結果となる値を返す以外に何もしない」関数のことだ。

<?= h('A&W') ?> ← ふつうの出力
<?php h('P&G') ?> ← echoに渡し忘れている

純粋関数であるということは、以下のように関数呼び出しの結果(戻り値)で置き換えてもまったく同じになるということだ。

<?php
echo 'A&amp;W'; // ← ふつうの出力
'P&amp;G'; // ← echoに渡し忘れている

PhpStormやPHPStanのようなツールは賢いので、@pureとマークしておくことで下の行のような無意味な行を検出してくれる。

関数名は h() でも html_escape() でも e() でもチーム内で誤解されず、判読しやすく、ほどほどに打ちやすければなんでもいいのだが、人間が繰り返さなければいけないルールは単純であればあるほどいい。

241
218
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
241
218

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?