9
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Last updated at Posted at 2018-02-26

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

リポジトリは、こちら

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

元記事のコードの改善できそうな点

まぁいろいろとあるので、雑に列挙しておきます。

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

制限事項(要件)

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

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

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

所感としては、

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

という感じです。

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

画面遷移

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

画面遷移.png

書いたコード

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

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

index.php

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

check.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
// フォームの入力値の確認をするページ
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 になることを利用して(ほんとうは良くない)、直接アクセスしてきたときに 500 Internal Server Error になるのを防いで、ちゃんと 400 Bad Request を返せるようにしました。

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

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

escapeshellcmdmysqli_real_escape_string も同様です。

store.php

データを保存するやつです。

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

<?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つの関数が要になっています。

/**
 * 入力値から、ハッシュ値を生成する。
 *
 * @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 が重複していないか?とか、メールアドレス重複していないか?とかを確認できる手段を作らなきゃいけない気がします。

まとめ

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

最後に

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

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

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

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

9
4
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?