みなさん正規表現は好きですか? 私は好きです。簡単に脆弱性を作り込めて、とても便利ですからね。
この記事では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の支援者が増えたらこのシリーズは続きます