自分なら『PHPでフォームに入力された文字をテキストファイルに書き込む』のコード、こんな感じで書きます

nejimawaso 氏の記事[『PHPでフォームに入力された文字をテキストファイルに書き込む』](https://qiita.com/nejimawaso/items/dc11f75bafcb817f1fd3)のコードをみて、自分ならここまでやるっていうのをやってみました。

[リポジトリは、こちら](https://github.com/ndxbn/dc11f75bafcb817f1fd3)。

この記事では、元記事の改善できそうな点を挙げ、その上で自分用に制限を明確にしたり追加して（ルールを決めて）、自分の書いたコードの解説をすることにします。

## 元記事のコードの改善できそうな点
まぁいろいろとあるので、雑に列挙しておきます。

* Model と View が入り交ざっていて、よくわからない
* [元記事の check.php](https://qiita.com/nejimawaso/items/dc11f75bafcb817f1fd3) には 壊れた HTML が出力されるパターンがある（はず）
* XSS 対策のつもりだと思うけれど、 `htmlspecialchars` するタイミングがおかしい。
    * **出力する直前にやる**もの。
    * かといって、M-V が入り乱れてるのいで、他に適切な場所も見当たらないかも。
* hoge.php （保存するやつ）、普通に CSRF できそう
* 今の御時世、テンプレートエンジン無し縛りでも `<?php print` はさすがに使わないと思う。
    * `<?=` っていう、[`<?php echo` の短縮タグ](https://secure.php.net/echo)をつかう。

## 制限事項（要件）

元記事のコードの内容から、以下のものは使用できないものと考えたほうが良さそうです。

- SESSION
- COOKIE
- クライアント側の JavaScript
- DB
- デフォルトでバンドルされていない PHP エクステンション
- 外部ライブラリ（フルスクラッチで書く）

上記に加え、以下のような制限（要件）も追加します。

- [OWASP top10](https://www.owasp.org/index.php/Category:OWASP_Top_Ten_Project)の事項をできるだけ守る
- [安全な Web サイトの作り方](https://www.ipa.go.jp/security/vuln/websecurity.html) の事項をできるだけ守る
- 最後のデータ保存以外の用途で、ファイルへの R/W しない

所感としては、

- XSS はわかっていれば余裕
- CSRF 対策がめんどくさそう
    - セッション系が全滅してる

という感じです。

CSRF が必要なのは、最後の「ファイルに保存する」ところだけです。

## 画面遷移
画面遷移は、次の通りです。

![画面遷移.png](https://qiita-image-store.s3.amazonaws.com/0/29233/35ff2a2a-3eb1-d0a1-bc00-2c9546213970.png)


```plantuml
@startuml
start

repeat
  :index.php;
  :check.php;
  note right
    * 入力値のValidation
    * 改竄を検出できるナニカが必要
  end note
repeat while (invalid?)

:store.php
ファイルに保存する;
note right
  * CSRF 対策
end note

stop
@enduml
```

## 書いたコード
書いたコードと、それぞれの解説をします。

解説用なので、HTML は適宜削っています。

### index.php
ユーザが最初に見る画面です。
専ら、データを入力ための画面です。

check.php から戻ってきたときに、エラーメッセージを表示する というサブ機能もあったりします。

```php
<?php
// 情報を入力する、メインページ
require_once 'lib.php';

// check.php から Validation エラーで戻ってきた場合に、メッセージ（複数）を表示するために使用
/** @var string[] $errorMessages */
$errorMessages = $_GET['error_messages'] ?? [];
// 一応、型のValidation しておく
if (!is_array($errorMessages)) {
    $errorMessages = (array)$errorMessages;
}
?>

<!-- エラーメッセージ表示するやつ -->
<?php foreach ($errorMessages as $message): ?>
    <div><?= e($message) ?></div>
<?php endforeach; // errorMessages ?>

<!-- 登録フォーム -->
<form action="check.php" method="post">
    <div>
        <label for="id">識別番号: </label> <input type="text" id="id" name="id" placeholder="半角英数">
    </div>
    <div>
        <label for="mail">メールアドレス: </label> <input type="text" id="mail" name="mail">
    </div>
    <div>
        <label for="mail-confirm">メールアドレス（確認用）: </label> <input type="text" id="mail-confirm" name="mail-confirm">
    </div>

    <button type="submit">OK</button>
</form>
```

check.php から戻ってきたときに、エラーメッセージを表示したいので､そのギミックを入れました。本当はセッションを使いたいのですが、今回は使えません。。PHP ではクエリのデータを配列として認識して、良さげな感じに扱える謎機能があるので、活用しましょう。エラーメッセージは漏洩しても問題ないデータなので、普通に GET のクエリで渡しました。

この画面では、エラーメッセージの表示はするものの、どちらかといえばデータを入力する用途のページなので、ほとんど HTML ファイルですね。

### check.php
`index.php` で入力されたデータを検証し、ダメならキックバック、良さげなら「ほんとに登録して良い？」と確認をするページです。

```php
<?php
// フォームの入力値の確認をするページ
require_once 'lib.php';

// GET とかでアクセスしてくると、 TypeError になるようにした。
// TypeError である理由は特になくて、TypeHinting をちゃんと入れて、まともな実装をしたら、そうなったってだけです。
// どういう実装になっているかは、 GitHub の方でも見てください。
try {
    ['id' => $id, 'mail' => $mail, 'mail-confirm' => $mailConfirm] = $inputs = getInputs();
} catch (TypeError $e) {
    header('HTTP/1.1  400 Bad Request');
    echo '不正なアクセスです。';
    return;
}

// validation
/** @var string[] $errorMessages */
$errorMessages = [];
if (empty($id)) {
    $errorMessages[] = '識別番号は空にできません。なにか入力してください。';
}
if (empty($mail)) { // sanitize なので、空になる
    $errorMessages[] = 'メールアドレスが空か、使えない文字が含まれてます。正直にメールアドレス入れてください。';
}
if ($mail !== $mailConfirm) {
    $errorMessages[] = "メールアドレスが、再入力したやつと一致してません。 ${mail} と ${mailConfirm} でした。";
}
if (kickback($errorMessages)) {
    return;
}

$hash = inputsHash($inputs);
?>

<div>この情報で登録しますが、よろしいですか？</div>
<div>
    <ul>
        <li>識別番号: <?= e($id) ?></li>
        <li>メールアドレス: <?= e($mail) ?></li>
    </ul>
</div>

<!-- 実際に保存する、 store.php へ値を渡さなければいけない -->
<!-- 改竄の検知用で、 x-hash を作った。いわゆるトークンだな。 -->
<form action="store.php" method="post">
    <input type="hidden" name="id" value="<?= e($id) ?>">
    <input type="hidden" name="mail" value="<?= e($mail) ?>">
    <input type="hidden" name="mail-confirm" value="<?= e($mailConfirm) ?>">
    <input type="hidden" name="x-hash" value="<?= e($hash) ?>">
    <button type="submit">良いので、保存する</button>
</form>
```

CSRF でこのページにアクセスした場合に、 [`getInputs()` で戻り値の型で `TypeError` になる](https://github.com/ndxbn/dc11f75bafcb817f1fd3/blob/master/lib.php#L25-L36)ことを利用して（ほんとうは良くない）、直接アクセスしてきたときに `500 Internal Server Error` になるのを防いで、ちゃんと `400 Bad Request` を返せるようにしました。

他のバリデーションは、順当に実装すればこうなりますよね。
**間違っても、元記事のように「都度、メッセージをレスポンスする」なんてことはしてはいけません**
ちゃんとバッファにためて、後から MVC で分けやすいように意識しましょう。

**元記事の方では `htmlspecialchars()` をファイルの先頭でやっていた気がしますが**、アレはたぶん「都度レスポンスする（＝ `print` する）」せいで、あそこしか場所がなかった気がしますが、`text/html` に変換するのは、出力の直前にすべきで、**最初にやるのは単なるデータ破壊**です。真似してはいけません。

[`escapeshellcmd`](http://php.net/manual/ja/function.escapeshellcmd.php) や [mysqli_real_escape_string](http://php.net/manual/ja/mysqli.real-escape-string.php) も同様です。

### store.php
データを保存するやつです。

データ保存となると、当然 CSRF 対策を必須とします。
また、セッションが使えないために、確認画面から hidden でパラメータをもらう形になるので、そこで改ざんされていないかのチェックをすることもついでにやります。

```php
<?php
require_once 'lib.php';
try {
    ['id' => $id, 'mail' => $mail, 'mail-confirm' => $mailConfirm] = $inputs = getInputs();
} catch (TypeError $e) {
    header('HTTP/1.1  400 Bad Request');
    echo '不正なアクセスです。';
    return;
}
$hashString = $_POST['x-hash'] ?? null;

// validation
/** @var string[] $errorMessages */
$errorMessages = [];
// 入力されてきたデータが、確認画面で改ざんされていないかの確認
// CSRF 対策も（副次的に）できてる（はず）
if (($hashString === null) // 直接このページにアクセスされたら、こっちにかかる
    || (!validateHash($inputs, $hashString))
) {
    $errorMessages[] = '確認画面で入力値が変更されたっぽいです。クラッキングやめてください＞＜';
}

// check.php で validation が通ったものじゃない場合は、x-hash が合わないはずなので、すでに弾かれてるはず。
// なので、check.php でやったような validation はここでは不要。
if (kickback($errorMessages)) {
    return;
}

file_put_contents("storage/${id}.txt", $mail);
```

**簡単ですね！！**。と言いたいところですが、関数に隠蔽しちゃったやつが結構重要だったりします。

SESSION が使えないのに、入力値の改竄チェックをどのようにしてやっているか？については、以下の2つの関数が要になっています。

```php
/**
 * 入力値から、ハッシュ値を生成する。
 *
 * @param array $inputs
 * @return string
 */
function inputsHash($inputs): string
{
    $inputsWithSecret = $inputs;
    $inputsWithSecret [] = SECRET;
    return hash('SHA256', implode('', $inputsWithSecret));
}
/**
 * 入力された値とそのハッシュ値を、タイミング攻撃セーフな方法で検証する。
 *
 * Session が使用できないので、フォームの改竄を検出するのに使用する。
 *
 * @param array $inputs
 * @param string $hash
 * @return bool
 */
function validateHash($inputs, $hash): bool
{
    $expectHash = inputsHash($inputs);
    // 単に `===` を使うと、タイミング攻撃が通る。
    return hash_equals($expectHash, $hash);
}
```

`check.php` で入力された値を連結させてハッシュをとり、それを `x-hash` でわたしていました。これは、よくある `X-TOKEN` みたいな CSRF トークンに似ていますが、生成するのに使っているデータが「ユーザが入力した値」＋「秘密鍵的なやつ」です。

この処理は、 SSL の自己証明書みたいなものです。

とりあえず、これで改竄のチェックができるのと、直接 `store.php` へアクセスされるような CSRF は回避できます。

また、タイミング攻撃セーフな方法でハッシュを検証しているので、サーバ側の秘密鍵に当たる文字列が十分に強固なものなら、推測されにくそうです。（このへんに脆弱性があれば、徳丸さん（ @ockeghem ）やセキュキャン勢（ @mpyw とか）がツッコミを入れてくれるでしょう。）


## できなかったこと
最後の最後で `file_put_contents()` で手抜きをしてしまっているのがいただけない気がしますが、とりあえず一晩で書ききれなかったので、許してください。

本当は、ファイルの存在チェックして、 ID が重複していないか？とか、メールアドレス重複していないか？とかを確認できる手段を作らなきゃいけない気がします。

## まとめ
なんか締まらない感じになってしましましたが、とりあえず「パット見で気になったところ」
をどうにかして、自分なら最低でもこのくらいするぞ！というコードを書いて、その解説をしてみました。

## 最後に

やあ （´・ω・｀)
ようこそ、しとりーさんの記事へ。
このテキーラはサービスだから、まず飲んで落ち着いて欲しい。

うん、「また」なんだ。済まない。
仏の顔もって言うしね、謝って許してもらおうとも思っていない。

でも、このタイトルを見たとき、君は、きっと言葉では言い表せない
「ときめき」みたいなものを感じてくれたと思う。
殺伐としたインターネットの世界で、そういう気持ちを忘れないで欲しい
そう思って、この記事を書いたんだ。

じゃあ、注文を聞こうか。

http://dic.nicovideo.jp/a/バーボンハウス