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

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

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

## 先に結論を書きます

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

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

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

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

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

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

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

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

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

```php
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](http://php.net/manual/ja/regexp.reference.meta.php)には、このように書いてあります。

<blockquote><dl><dt><code>^</code><dd>検索対象（複数行モードでは行）の始まりを言明</dd><dt><code>$</code></dt><dd>検索対象の<strong>終わりあるいは終端の改行文字の前</strong>（複数行モードでは行の終わり）を言明</dd></dl></blockquote>

*[PHP: メタ文字 - Manual](http://php.net/manual/ja/regexp.reference.meta.php)より抜萃、2018年11月23日閲覧*

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

## 対策

### `D` 修飾子

このPCREの[パターン修飾子](http://php.net/manual/ja/reference.pcre.pattern.modifiers.php)の存在はマニュアルに、きっちり書いてあります。

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

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

### `\A` と `\z`

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

<blockquote><dl><dt><code>\A</code></dt><dd>検索対象文字列の始端（複数行モードとは独立）</dd><dt><code>\Z</code></dt><dd>検索対象文字列の終端、または終端の改行（複数行モードとは独立）</dd><dt><code>\z</code></dt><dd>検索対象文字列の終端（複数行モードとは独立）</dd></dt></blockquote>


*[PHP: エスケープシーケンス - Manual](http://php.net/manual/ja/regexp.reference.escape.php)より抜萃、2018年11月23日閲覧*

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

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

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

## 余談

### JavaScript

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

```js
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](https://developer.mozilla.org/ja/docs/Web/JavaScript/Guide/Writing_a_Regular_Expression_Pattern)を読んでください。

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

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

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

```php
<?php

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

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

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

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

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

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

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

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

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

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

## まとめ

正規表現は楽しいよ

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

[tadsan｜pixivFANBOX](https://www.pixiv.net/fanbox/creator/105589)の支援者が増えたらこのシリーズは続きます

[PCRE]: http://php.net/manual/ja/ref.pcre.php
[`preg_match()`]: http://php.net/manual/ja/function.preg-match.php
[`preg_match_all()`]: http://php.net/manual/ja/function.preg-match-all.php
[`preg_replace()`]: http://php.net/manual/ja/function.preg-replace.php