(Last Updated On: 2022年2月17日)

サーバー側のプログラムでJavaScriptの文字列にデータを出力するケースはよくあります。このような場合、エスケープ処理を行うことが必須です。

JavaScript文字リテラルは次のように定義されています。(ECMAScript 5.1)

StringLiteral ::
" DoubleStringCharactersopt "
' SingleStringCharactersopt '

DoubleStringCharacters ::
DoubleStringCharacter DoubleStringCharactersopt

SingleStringCharacters ::
SingleStringCharacter SingleStringCharactersopt

DoubleStringCharacter ::
SourceCharacter but not one of " or \ or LineTerminator
\ EscapeSequence
LineContinuation

SingleStringCharacter ::
SourceCharacter but not one of ' or \ or LineTerminator
\ EscapeSequence
LineContinuation

LineContinuation ::
\ LineTerminatorSequence

EscapeSequence ::
CharacterEscapeSequence
0 [lookahead  DecimalDigit]
HexEscapeSequence
UnicodeEscapeSequence

CharacterEscapeSequence ::
SingleEscapeCharacter
NonEscapeCharacter

SingleEscapeCharacter ::
one of ' " \ b f n r t v

NonEscapeCharacter ::
SourceCharacter but not one of EscapeCharacter or LineTerminator

EscapeCharacter ::
SingleEscapeCharacter
DecimalDigit
x
u

HexEscapeSequence ::
x HexDigit HexDigit

UnicodeEscapeSequence ::
u HexDigit HexDigit HexDigit HexDigit

上記のリテラル定義にはLineTerminatorSequenceが定義されていませんが、7.3 Line Terminators
で定義されています。

LineTerminator ::
<LF>
<CR>
<LS>
<PS>
LineTerminatorSequence ::
<LF>
<CR> [lookahead  <LF> ]
<LS>
<PS>
<CR> <LF>

行末文字

CodeUnit Value NameFormal Name
\u000ALine Feed<LF>
\u000DCarriage Return<CR>
\u2028Line separator<LS>
\u2029Paragraph separator<PS>

要するに文字リテラルは”(ダブルクオート)または’(シングルクォート)で囲み、エスケープ処理は次のようにすると定義されています。

  • ' " \ b f n r t v\ でエスケープする。
  • エスケープ文字' " \ b f n r t vと行セパレータ文字以外の場合、\ は無視する
  • \数値三桁でのエスケープも可能
  • Unicode形式でエスケープする。
  • HEX形式でエスケープする。

b f n r t vとはそれぞれUnicode(アスキーコード)のBS(後退)、FF(改頁)、LF(改行)、CR(復帰)、HT(水平タブ)、VT(垂直タブ)です。エスケープ処理は次のようになります。

エスケープ前\エスケープ後Unicodeエスケープ後HEXエスケープ後
\’\u0027\x27
\”\u0022\x22
\\\\u005C\x5C
b\b\u0008\x08
f\f\u000C\x0C
n\n\u000A\x0A
r\r\u000D\x0D
t\t\u0009\x09
v\v\u000B\x0B

これらの中で注目すべきは ‘ と ” と \ です。シングルクォート、ダブルクオートは文字リテラルを作成する為に利用され、\ でエスケープできることです。つまり、文字リテラルの最後に \ が現れると文字列の終端が無くなります。単独で不正なJavaScriptの挿入が可能になる訳ではありませんが、プログラムの構造が破壊される事を意味します。

PHPにはJavaScript文字列用のエスケープ関数が用意されていません。htmlspecialchars()やhtmlentities()で代用している場合も多いと思います。しかし、これらの関数ではJavaScript文字列のエスケープを十分に行う事ができません。

JavaScriptプログラムの構造が破壊される例

<?php
$msg1 = 'test string\\';
$msg2 = ');alert(document.cookie); //';

echo "
<script>;
alert('". $msg1 ."');alert('". $msg2 ."');
</script>
Here we go!
";

実行結果は次のようになります。

<script>
alert('test string\')alert('test string\');
</script>
Here we go!

つまり、SQLインジェクションの文字エンコーディングベースの攻撃のような形でフォーマットが破壊され、攻撃可能になってしまいます。htmlspecialchar()/htmlentities()はHTMLテキスト用のエスケープ関数なのでJavaScript文字リテラルのエスケープ処理に適さない事が分かります。

一方、addslashes関数は最低限エスケープ処理が必要な ‘ ” \ を\でエスケープします。しかしaddslashesは & < > などをエスケープ処理しません。JavaScript文字リテラルにはこれらのHTML特殊文字のエスケープ処理は必要ではありません。しかし、HTML文書に埋め込まれるJavaScriptの文字列データにHTML特殊文字がそのまま現れると、HTML文書の処理の仕様のためインジェクションが可能になってしまいます。この為、addslashes関数も利用できません。

HTML文書の処理の流れ

  1. HTMLタグの解析
  2. 解析したタグ内容のデコード(HTMLエンティティのデコード)
  3. デコードした内容をタグに応じて、それぞれ処理(JavaScript、CSSなど)

この順序で処理されるためJavaScript文字リテラルにタグがあるとHTML文書の構造が破壊され、JavaScriptインジェクションが可能になります。これを防ぐにJavaScript文字リテラルであってもHTML文書中に記載されているJavaScript文字リテラルにもエスケープ処理が必要になります。

HTMLパーサーに誤ってJavaScript文字リテラルが解釈されないようにしつつ、JavaScript文字リテラルをエスケープ処理するには、英数字を除く256未満の文字コードをHEX形式でエンコーディングします。

JavaScript文字列を安全に出力する為のエスケープ処理は以下の処理になります。

<?php
function escape_javascript_string($str) {
  $map = [
          1,1,1,1,1,1,1,1,1,1,
          1,1,1,1,1,1,1,1,1,1,
          1,1,1,1,1,1,1,1,1,1,
          1,1,1,1,1,1,1,1,1,1,
          1,1,1,1,1,1,1,1,0,0, // 49
          0,0,0,0,0,0,0,0,1,1,
          1,1,1,1,1,0,0,0,0,0,
          0,0,0,0,0,0,0,0,0,0,
          0,0,0,0,0,0,0,0,0,0,
          0,1,1,1,1,1,1,0,0,0, // 99
          0,0,0,0,0,0,0,0,0,0,
          0,0,0,0,0,0,0,0,0,0,
          0,0,0,1,1,1,1,1,1,1,
          1,1,1,1,1,1,1,1,1,1,
          1,1,1,1,1,1,1,1,1,1, // 149
          1,1,1,1,1,1,1,1,1,1,
          1,1,1,1,1,1,1,1,1,1,
          1,1,1,1,1,1,1,1,1,1,
          1,1,1,1,1,1,1,1,1,1,
          1,1,1,1,1,1,1,1,1,1, // 199
          1,1,1,1,1,1,1,1,1,1,
          1,1,1,1,1,1,1,1,1,1,
          1,1,1,1,1,1,1,1,1,1,
          1,1,1,1,1,1,1,1,1,1,
          1,1,1,1,1,1,1,1,1,1, // 249
          1,1,1,1,1,1,1, // 255
          ];
  // 文字エンコーディングはUTF-8
  $mblen = mb_strlen($str, 'UTF-8');
  $utf32 = bin2hex(mb_convert_encoding($str, 'UTF-32', 'UTF-8'));
  for ($i=0, $encoded=''; $i < $mblen; $i++) {
      $u = substr($utf32, $i*8, 8);
      $v = base_convert($u, 16, 10);
      if ($v &lt; 256 &amp;&amp; $map[$v]) {
        $encoded .= '\\x'.substr($u, 6,2);
      } else if ($v == 0x2028) {
        $encoded .= '\\u2028';
      } else if ($v == 0x2029) {
        $encoded .= '\\u2029';
      } else {
        $encoded .= mb_convert_encoding(hex2bin($u), 'UTF-8', 'UTF-32');
      }
   }
   return $encoded;
}

// テストデータ作成
$convmap = [ 0x0, 0xffff, 0, 0xffff ];
$msg = '';
for ($i=0; $i < 1000; $i++) {
  // chr()では正しいUTF-8の128以上の文字を生成できないのでmb_decode_numericentity()を利用
  $msg .= mb_decode_numericentity('&amp;#'.$i.';', $convmap, 'UTF-8');
}

// var_dump($msg);
var_dump(escape_javascript_string($msg));

HTMLパーサ、JavaScriptで特殊な意味を持つ文字は全てHEX形式でエスケープ処理されているのでJavaScriptインジェクションを確実に防止できます。

JavaScriptの仕様ではHEX形式に利用できる文字には小文字も含まれるので、効率を優先して大文字には変換していません。

HexDigit ::: one of                 See 9.3.1
0 1 2 3 4 5 6 7 8 9 a b c d e f A B C D E F

ユーザー定義メソッドを呼んでいない、などの理由でescape_javascript_stringはOWASPのESAPI実装よりは効率が良いとは思いますが、このエスケープ処理にはまだまだ改善の余地があります。本来であればPHP本体にこのような関数が用意されていれば良いのですが今のところは用意されていません。(一応、提案(RFC)を議論中ではあります)

出力結果は表示が乱れるので一部のみ貼り付けます。

string(2326) "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f\x20\x21\x22\x23\x24\x25\x26\x27\x28\x29\x2a\x2b\x2c\x2d\x2e\x2f0123456789\x3a\x3b\x3c\x3d\x3e\x3f\x40ABCDEFGHIJKLMNOPQRSTUVWXYZ\x5b\x5c\x5d\x5e\x5f\x60abcdefghijklmnopqrstuvwxyz\x7b\x7c\x7d\x7e\x7f\x80\x81\x82\x83\x84\x85\x86\x87\x88\x89\x8a\x8b\x8c\x8d\x8e\x8f\x90\x91\x92\x93\x94\x95\x96\x97\x98\x99\x9a\x9b\x9c\x9d\x9e\x9f\xa0\xa1\xa2\xa3\xa4\xa5\xa6\xa7\xa8\xa9\xaa\xab\xac\xad\xae\xaf\xb0\xb1\xb2\xb3\xb4\xb5\xb6\xb7\xb8\xb9\xba\xbb\xbc\xbd\xbe\xbf\xc0\xc1\xc2\xc3\xc4\xc5\xc6\xc7\xc8\xc9\xca\xcb\xcc\xcd\xce\xcf\xd0\xd1\xd2\xd3\xd4\xd5\xd6\xd7\xd8\xd9\xda\xdb\xdc\xdd\xde\xdf\xe0\xe1\xe2\xe3\xe4\xe5\xe6\xe7\xe8\xe9\xea\xeb\xec\xed\xee\xef\xf0\xf1\xf2\xf3\xf4\xf5\xf6\xf7\xf8\xf9\xfa\xfb\xfc\xfd\xfe\xffĀāĂ㥹ĆćĈĉĊċČčĎďĐđĒēĔĕĖėĘę

PHPのセキュリティ入門書に記載するコンテンツのレビューも兼ねてPHP Securityカテゴリでブログを書いています。コメント、感想は大歓迎です。

追記:
JavaScript文字列エスケープの解説を書きました。

JavaScript文字列エスケープはDOMを利用して回避してはどうか?という提案がありました。これに対する見解を記載しました。

サンプル関数を環境(mbstring.internal_encoding)に影響されないようにしました。
ついでにビット演算の方が速いと思ったのでビット演算を使うバージョンも作ってみました。予想通り4割ほど高速でした。

<?php
function escape_javascript_string($str) {
  $map = [
          1,1,1,1,1,1,1,1,1,1,
          1,1,1,1,1,1,1,1,1,1,
          1,1,1,1,1,1,1,1,1,1,
          1,1,1,1,1,1,1,1,1,1,
          1,1,1,1,1,1,1,1,0,0, // 49
          0,0,0,0,0,0,0,0,1,1,
          1,1,1,1,1,0,0,0,0,0,
          0,0,0,0,0,0,0,0,0,0,
          0,0,0,0,0,0,0,0,0,0,
          0,1,1,1,1,1,1,0,0,0, // 99
          0,0,0,0,0,0,0,0,0,0,
          0,0,0,0,0,0,0,0,0,0,
          0,0,0,1,1,1,1,1,1,1,
          1,1,1,1,1,1,1,1,1,1,
          1,1,1,1,1,1,1,1,1,1, // 149
          1,1,1,1,1,1,1,1,1,1,
          1,1,1,1,1,1,1,1,1,1,
          1,1,1,1,1,1,1,1,1,1,
          1,1,1,1,1,1,1,1,1,1,
          1,1,1,1,1,1,1,1,1,1, // 199
          1,1,1,1,1,1,1,1,1,1,
          1,1,1,1,1,1,1,1,1,1,
          1,1,1,1,1,1,1,1,1,1,
          1,1,1,1,1,1,1,1,1,1,
          1,1,1,1,1,1,1,1,1,1, // 249
          1,1,1,1,1,1,1, // 255
          ];

  // 文字エンコーディングはUTF-8
  $mblen = mb_strlen($str, 'UTF-8');
  $utf32 = mb_convert_encoding($str, 'UTF-32BE', 'UTF-8');
  $convmap = [ 0x0, 0xffffff, 0, 0xffffff ];
  for ($i=0, $encoded=''; $i < $mblen; $i++) {
    // Unicodeの仕様上、最初のバイトは無視してもOK
    $c =  (ord($utf32[$i*4+1]) << 16 ) + (ord($utf32[$i*4+2]) << 8) + ord($utf32[$i*4+3]);
    if ($c < 256 && $map[$c]) {
      if ($c < 0x10) {
        $encoded .= '\\x0'.base_convert($c, 10, 16);
      } else {
        $encoded .= '\\x'.base_convert($c, 10, 16);
      }
    } else if ($c == 0x2028) {
      $encoded .= '\\u2028';
    } else if ($c == 0x2029) {
      $encoded .= '\\u2029';
    } else {
      $encoded .= mb_decode_numericentity('&#'.$c.';', $convmap, 'UTF-8');
    }
  }
  return $encoded;
}

// テストデータ作成
$convmap = [ 0x0, 0xffff, 0, 0xffff ];
$msg = '日本語';
for ($i=0; $i < 1000; $i++) {
  // chr()では正しいUTF-8の128以上の文字を生成できないのでmb_decode_numericentity()を利用
  $msg .= mb_decode_numericentity('&#'.$i.';', $convmap, 'UTF-8');
}
$msg .= 'あいうえお';

// var_dump($msg);
var_dump(escape_javascript_string($msg));

追記2:
2028, 2029もエスケープしないとJavaScriptがシンタックスエラーになります。LineTerminaterに指定されているので完全にバグでした。修正したので新しいスクリプトを利用してください。

参考リンク:

投稿者: yohgaki