Edited at

正規表現で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の支援者が増えたらこのシリーズは続きます