PHP

正規表現でPHPを脆弱にする (1) 「^ と $」

みなさん正規表現は好きですか? 私は好きです。簡単に脆弱性を作り込めて、とても便利ですからね。

この記事ではPCRE関数(preg_match(), preg_replace()など)を利用して、みなさまにPHP正規表現のバッドノウハウを教示いたします。

先に結論を書きます

単独の文字列のマッチに $ を用いるのは予期しないパターンになるのでやめましょう。^は特に危険ではありませんが、^$の組み合せではなく\A\zの組み合せを覚えましょう。ただし、m修飾子で複数行(マルチラインモード)で、行頭と行末にマッチさせたい場合を除きます。

文字列のマッチに ^$ のペアを利用する

そうですね、例としてQiitaのようなユーザー登録サイトの表示名 @tadsan のような文字列を登録する前の検査をするとします。

要件としては、使用可能な文字は abcdefghijklmnopqrstuvwxyz0123456789_ で、3〜32文字とします。

if (checkUserName($input['user_name'])) {
    return register_user($input);
}

return error_message("不正な名前です");

以下の判定関数 checkUserName() には穴があります。

function checkUserName(string $user_name): bool
{
    return preg_match('/^@[_a-z0-9]{3,32}$/i', $user_name) === 1;
}

テストコードを書いてみましょうね (最初にやれ)

foreach ([
    '@tadsan' => true, // ふつうの名前
    '@a' => false,     // 1文字は短すぎる
    '@abc' => true,    // 3文字はok
    "@tadsan\n" => false,   // 末尾がLF
    "@tadsan\r\n" => false, // 末尾がCR+LF
    "@tad\nsan" => false,   // 途中にLF
    "@tadsan\n\n" => false, // 末尾にLFが二個
] as $input => $expected) {
    printf("%s => %s\n", json_encode($input, 1),
           checkUserName($input) === $expected ? "✓" : "❌");
}

結果はどうなりますか?

"@tadsan" => ✓
"@a" => ✓
"@abc" => ✓
"@tadsan\n" => ❌
"@tadsan\r\n" => ✓
"@tad\nsan" => ✓
"@tadsan\n\n" => ✓

えっ、まじで? はい、まじです。

なぜこんなことが起きるのか

PHP: メタ文字 - Manualには、このように書いてあります。

^
検索対象(複数行モードでは行)の始まりを言明
$
検索対象の終わりあるいは終端の改行文字の前(複数行モードでは行の終わり)を言明

PHP: メタ文字 - Manualより抜萃、2018年11月23日閲覧

終わりあるいは終端の改行文字の前」です。複数行モードでは単なる行頭行末ですが、単一行モード(つまり、デフォルトの動作)では牙を剥きます。

対策

D 修飾子

このPCREのパターン修飾子の存在はマニュアルに、きっちり書いてあります。

D (PCRE_DOLLAR_ENDONLY)
この修飾子を設定すると、パターン内のドルメタ文字は、検索対象文字列の 終わりにのみマッチします。この修飾子を設定しない場合、ドル記号は、 検索対象文字列の最後の文字が改行文字であれば、その直前にもマッチします。 この修飾子は、m を設定している場合に無視されます。 Perl には、この修飾子に等価なものはありません。

そんなの知らねーよ」とおっしゃるかもしれませんが、まあその通りですね。このパターン修飾子を付けなかったときの挙動は、$\Zの挙動に変化します。 私も実用的に使ったことないので覚えなくてよいです。

\A\z

こちらを利用するのが本命です。

\A
検索対象文字列の始端(複数行モードとは独立)
\Z
検索対象文字列の終端、または終端の改行(複数行モードとは独立)
\z
検索対象文字列の終端(複数行モードとは独立)

PHP: エスケープシーケンス - Manualより抜萃、2018年11月23日閲覧

^^$は動作モードによって挙動が変化するのに対して、\A\zは常に同じ挙動です。

\Zは単一行モードの$と同じ挙動ですが、意図的に利用するべき場面は基本的にありません。

よって、覚えるべきは \A\zのペアです。本日は\A\zを頭に叩き込んで帰ってください。

余談

JavaScript

JavaScriptはPCRE正規表現ではありませんので、この問題の影響を受けません。

function checkUserName(user_name)
{
    return /^@[_a-z0-9]{3,32}$/.test(user_name);
}

const test = {
    '@tadsan': true, // ふつうの名前
    '@a': false,     // 1文字は短すぎる
    '@abc': true,    // 3文字はok
    "@tadsan\n": false,   // 末尾がLF
    "@tadsan\r\n": false, // 末尾がCR+LF
    "@tad\nsan": false,   // 途中にLF
    "@tadsan\n\n": false, // 末尾にLFが二個
};

for (const x of Object.keys(test)) {
    console.log(`${JSON.stringify(x)} => ${checkUserName(x) === test[x] ? "✓" : "❌"}`);
}

Wandboxで実行したところ https://wandbox.org/permlink/re8Dn97473bPovof

詳しくは正規表現パターンの記述 - JavaScript | MDNを読んでください。

^$を使ってよろしい場合

m修飾子を使って複数行モードで「行頭」と「行末」にマッチさせたい場合は$を使ってください。

典型的にはpreg_match_all()を使って各行マッチするようなパターンです。

<?php

$text = <<<TXT
11111
これはコメント
22222
てすてす
TXT;

preg_match_all('/^[1-9][0-9]*$/m', $text, $matches);
var_dump($matches[0]);
// => [
//    "11111",
//    "22222",
// ]

また、preg_replace()で各行に対して操作したいときにも有効です。

「すべての行頭に//を付けたい(コメントアウトしたい)」場合は、これだけで書けます。

echo preg_replace('/^/m', '//', $text);

「すべての行末に記号を付けたい」であれば、もちろんこうです。

echo preg_replace('/$/m', '⏎', $text);

「空行以外の行を『』で括りたい」であれば、こう書くこともできます。

echo preg_replace('/^(.+)$/m', '『\1』', $text);

ね、簡単でしょ? これらのパターンは\A\zのような記法で代用することはできません。

まとめ

正規表現は楽しいよ

  • 正規表現には複数の方言があるよ
  • 本来の^$は複数行モードで効果を発揮するよ
  • この記事でやったように、パターンを書くときはフリーハンドで書くのではなく、どんなものにマッチするのかテストを書くのはとても大事だよ。

tadsan|pixivFANBOXの支援者が増えたらこのシリーズは続きます