PHP

PHPでhiddenフィールドを送信・取得する方法|改ざん・CSRF対策も解説

PHPフォームでinput type="hidden"の値をPOSTし、安全に取得・検証する方法を解説します。改ざんされる前提の設計、商品IDと価格の扱い、CSRF token、配列、disabled・readonlyとの違い、XSS・認可・DB更新時の注意点まで確認できます。

この記事の目次
  1. hiddenフィールドとは
  2. hidden inputの特徴
  3. PHPでhidden値をPOSTして取得する
  4. index.php:formを作る
  5. receive.php:POST値を検証する
  6. hidden値は文字列として届く
  7. hidden値を信用してはいけない理由
  8. 送ってはいけない値
  9. 価格・税率はserver側から取得する
  10. record IDを送る場合は認可も確認する
  11. CSRF tokenをhiddenへ入れる
  12. form表示時にtokenを生成する
  13. 受信時にhash_equals()で照合する
  14. hidden値を配列として送る
  15. hidden・readonly・disabledの違い
  16. GET formでもhidden値は送信できる
  17. $_POSTと$_REQUESTのどちらを使うか
  18. DB更新では受信keyをallowlist化する
  19. output時はcontextに合わせてescapeする
  20. よくあるエラー
  21. Undefined array keyが出る
  22. hidden値が送信されない
  23. 数字を掛けても期待した結果にならない
  24. CSRF tokenが一致しない
  25. hiddenならpasswordやAPI keyを入れてもよいですか?
  26. まとめ

HTMLの<input type="hidden">を使うと、画面に入力欄を表示せず、ほかのform dataと一緒に値を送信できます。

<form action="update.php" method="post">
  <input type="hidden" name="post_id" value="42">

  <label>
    タイトル
    <input type="text" name="title" required>
  </label>

  <button type="submit">更新する</button>
</form>

PHPではname="post_id"の値を$_POST['post_id']またはfilter_input()で取得できます。

<?php

$postId = filter_input(
    INPUT_POST,
    'post_id',
    FILTER_VALIDATE_INT,
    ['options' => ['min_range' => 1]],
);

if ($postId === false || $postId === null) {
    http_response_code(400);
    exit('post_idが正しくありません');
}

ただし、hiddenは「画面に見えない」だけです。利用者はDevToolsやHTTP requestを使って値を変更できます。価格、税率、権限、所有者IDなど重要な値をhiddenから受け取って、そのまま信用してはいけません。

hiddenフィールドとは

input type="hidden"は、form送信時に値を含めたいものの、利用者が画面上で直接編集するcontrolとして表示しない場合に使います。

代表例は次のとおりです。

  • 編集対象のrecord ID
  • 複数step formの識別子
  • 選択したtabや遷移元などのUI状態
  • serverが発行したCSRF token
  • 同じnameを持つ配列値

nameがないinputはform dataへ含まれません。送信するにはnamevalueを設定します。

hidden inputの特徴

  • rendered pageでは入力controlとして表示されない
  • keyboardやJavaScriptのfocus()でfocusできない
  • inputchangeeventの対象にならない
  • constraint validationへ参加しない
  • 値はsource・DevTools・requestから確認・変更できる

requiredminを付けてhidden値を保護することはできません。server側のvalidationが必要です。

PHPでhidden値をPOSTして取得する

2つのfileを使う最小例です。

index.php
receive.php

index.php:formを作る

<form action="receive.php" method="post">
  <input type="hidden" name="article_id" value="42">

  <label>
    コメント
    <textarea name="comment" required></textarea>
  </label>

  <button type="submit">送信する</button>
</form>

HTML内に固定値を直接書く代わりにPHP変数を出力する場合は、attribute contextに合わせてescapeします。

<input
  type="hidden"
  name="article_id"
  value="<?= htmlspecialchars(
      (string) $articleId,
      ENT_QUOTES | ENT_SUBSTITUTE,
      'UTF-8'
  ) ?>"
>

整数だと分かっていても、HTMLへ出力する境界でescapeする方針を統一すると、後で値の種類が変わったときの事故を防ぎやすくなります。

receive.php:POST値を検証する

<?php

$articleId = filter_input(
    INPUT_POST,
    'article_id',
    FILTER_VALIDATE_INT,
    ['options' => ['min_range' => 1]],
);

$comment = filter_input(INPUT_POST, 'comment', FILTER_UNSAFE_RAW);

if ($articleId === false || $articleId === null) {
    http_response_code(400);
    exit('article_idが正しくありません');
}

if (!is_string($comment) || trim($comment) === '') {
    http_response_code(400);
    exit('コメントを入力してください');
}

$comment = trim($comment);

FILTER_DEFAULTFILTER_UNSAFE_RAWのaliasであり、既定で安全なvalidationが行われるわけではありません。IDは整数、statusはallowlist、emailはemailというように、用途に合うruleを明示します。

hidden値は文字列として届く

HTTP formから届くscalar valueは、基本的にstringとして扱います。HTMLでvalue="42"と書いても、PHPへ整数42として保証されるわけではありません。

var_dump($_POST['article_id'] ?? null);

// string(2) "42"

整数として使う前にFILTER_VALIDATE_INTやstrictなapplication validationを通します。単に(int)へcastすると、"abc"も0になり、invalid inputを見分けにくくなります。

hidden値を信用してはいけない理由

利用者は次のような方法でhidden値を変更できます。

  • DevToolsのElements panelでvalueを書き換える
  • JavaScriptでinput.valueを変更する
  • network requestを再送・編集する
  • 独自のHTTP clientから同じendpointへ送る

OWASPは、hidden control、disabled control、JavaScriptが設定したfieldなど、UIで編集できないように見える値もすべて外部入力として扱い、server側でbusiness ruleを検証するよう求めています。

送ってはいけない値

次の値をhiddenから受け取り、そのまま確定処理へ使わないでください。

  • 商品価格、割引率、税率、送料
  • is_admin、role、permission
  • 利用者本人を表すuser ID
  • 注文済み・支払済みなどのstatus
  • 任意のredirect URLやfile path

clientからは対象を特定するIDや選択内容だけを受け取り、serverでdatabase・session・認証情報から正しい値を再取得します。

価格・税率はserver側から取得する

旧記事ではquantity、price、taxをすべてformから受け取り、さらに税額を減算していました。価格と税率をclientへ計算のsource of truthとして渡す設計は、改ざんに弱くなります。

formではproduct IDとquantityだけ送ります。

<form action="calculate.php" method="post">
  <input type="hidden" name="product_id" value="42">

  <label>
    購入数量
    <input type="number" name="quantity" min="1" max="20" required>
  </label>

  <button type="submit">金額を確認する</button>
</form>

serverで両方を検証し、product recordから価格と税率を取得します。

<?php

$productId = filter_input(
    INPUT_POST,
    'product_id',
    FILTER_VALIDATE_INT,
    ['options' => ['min_range' => 1]],
);

$quantity = filter_input(
    INPUT_POST,
    'quantity',
    FILTER_VALIDATE_INT,
    ['options' => [
        'min_range' => 1,
        'max_range' => 20,
    ]],
);

if ($productId === false || $productId === null ||
    $quantity === false || $quantity === null) {
    http_response_code(400);
    exit('入力値が正しくありません');
}

// 実際はprepared statementなどでdatabaseから取得する
$product = findProductById($productId);

if ($product === null || !$product['is_available']) {
    http_response_code(404);
    exit('商品が見つかりません');
}

$subtotal = $product['unit_price'] * $quantity;
$tax = intdiv($subtotal * $product['tax_rate_percent'], 100);
$total = $subtotal + $tax;

金額の丸めruleは通貨・契約・会計要件に合わせ、整数の最小通貨単位などで統一します。hidden値よりserver側の最新データを優先します。

record IDを送る場合は認可も確認する

IDのformatが正しくても、現在の利用者がそのrecordを編集できるとは限りません。IDを変更して他人のrecordへアクセスする攻撃を防ぐには、認証後のuser IDとrecord ownershipをserverで照合します。

$post = findPostById($postId);

if ($post === null) {
    http_response_code(404);
    exit('記事が見つかりません');
}

if ($post['owner_id'] !== $currentUserId) {
    http_response_code(403);
    exit('この操作は許可されていません');
}

「hiddenだから利用者はIDを変えない」という前提を置かないでください。format validationとauthorizationは別の確認です。

CSRF tokenをhiddenへ入れる

CSRF対策のsynchronizer token patternでは、serverが予測困難なtokenをsessionへ保存し、同じ値をhidden fieldでformへ含めます。送信時にserver側の値とconstant-time比較します。

form表示時にtokenを生成する

<?php

session_start();

if (empty($_SESSION['csrf_token'])) {
    $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}

$csrfToken = $_SESSION['csrf_token'];
?>

<form action="update.php" method="post">
  <input
    type="hidden"
    name="csrf_token"
    value="<?= htmlspecialchars(
        $csrfToken,
        ENT_QUOTES | ENT_SUBSTITUTE,
        'UTF-8'
    ) ?>"
  >

  <button type="submit">更新する</button>
</form>

受信時にhash_equals()で照合する

<?php

session_start();

$sessionToken = $_SESSION['csrf_token'] ?? '';
$requestToken = $_POST['csrf_token'] ?? '';

if (!is_string($requestToken) ||
    $sessionToken === '' ||
    !hash_equals($sessionToken, $requestToken)) {
    http_response_code(403);
    exit('不正なrequestです');
}

hidden fieldそのものが安全だからCSRF対策になるのではありません。攻撃者が予測できないtokenをserver側sessionと照合する設計が重要です。tokenをURLへ含めると履歴・log・Refererへ漏れる可能性があるため、GETではなくstate-changing POST formへ含めます。

SameSite cookie、Origin / Referer確認、framework標準機能なども組み合わせます。独自実装より、使用frameworkのCSRF protectionを優先してください。

sessionの基本はPHP SESSIONの使い方で解説しています。

hidden値を配列として送る

同じnameへ[]を付けるとPHPでarrayになります。

<input type="hidden" name="tag_ids[]" value="2">
<input type="hidden" name="tag_ids[]" value="5">
<input type="hidden" name="tag_ids[]" value="8">

受信側では「arrayであること」と「各要素の型・許可範囲」を確認します。

$rawTagIds = $_POST['tag_ids'] ?? [];
$tagIds = [];

if (is_array($rawTagIds)) {
    foreach ($rawTagIds as $value) {
        $id = filter_var(
            $value,
            FILTER_VALIDATE_INT,
            ['options' => ['min_range' => 1]],
        );

        if ($id !== false) {
            $tagIds[] = $id;
        }
    }
}

$tagIds = array_values(array_unique($tagIds));

最終的には、現在の利用者が指定可能なtag IDのallowlistと照合します。配列formのname構文はPHPでフォーム値を配列として送る方法を参照してください。

hidden・readonly・disabledの違い

指定 表示 編集 form送信
type="hidden" controlとして非表示 UIから不可 nameがあれば送信
readonly 表示 UIから不可 送信
disabled 通常は表示 不可 送信しない
CSSでdisplay: none 非表示 UIから困難 enabledでnameがあれば送信

どの方法でもHTTP requestを直接作れば値は変更できます。readonlyやdisabledもsecurity境界として信用しません。

GET formでもhidden値は送信できる

<form action="search.php" method="get">
  <input type="hidden" name="category" value="php">
  <input type="search" name="keyword">
  <button type="submit">検索</button>
</form>

送信後のURLは次のようになります。

search.php?category=php&keyword=form

GETではquery stringとしてURLへ出るため、秘密情報やCSRF tokenを含めません。POSTでもbodyが見えにくいだけで秘密にはならず、HTTPSとserver-side validationが必要です。詳しい使い分けはGETとPOSTの違いで解説しています。

$_POSTと$_REQUESTのどちらを使うか

POST formを受けるendpointでは$_POSTまたはfilter_input(INPUT_POST, ...)を使い、入力元を明確にします。

$_REQUESTは設定によりGET・POST・Cookieなど複数sourceを含み得るため、同名keyがあると処理意図が分かりにくくなります。methodも確認します。

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    header('Allow: POST');
    http_response_code(405);
    exit('POSTで送信してください');
}

method確認だけではCSRF対策やvalidationになりません。各対策を別々に行います。

DB更新では受信keyをallowlist化する

$_POST全体をmodelやSQL updateへそのまま渡すと、攻撃者がhiddenに存在しないkeyも追加できるmass assignmentの危険があります。

$allowed = [
    'title' => is_string($_POST['title'] ?? null)
        ? trim($_POST['title'])
        : '',
    'body' => is_string($_POST['body'] ?? null)
        ? trim($_POST['body'])
        : '',
];

// $allowedだけをprepared statementへ渡す

is_adminowner_idがrequestへ追加されても、update対象に入りません。SQLへ値を渡す際はprepared statementを使います。

output時はcontextに合わせてescapeする

hidden値を確認画面へ再表示したり、validation error後にformへ戻したりする場合、外部入力をHTMLへそのまま出力しません。

$value = is_string($_POST['step'] ?? null)
    ? $_POST['step']
    : '';

echo htmlspecialchars(
    $value,
    ENT_QUOTES | ENT_SUBSTITUTE,
    'UTF-8'
);

validationは値が業務ruleに合うかを確認し、escapeはHTML contextでcodeとして解釈されないようにします。目的が違うため両方必要です。

よくあるエラー

Undefined array keyが出る

inputにnameがない、formが未送信、checkboxと同様に条件でfieldが存在しない、違うmethodを見ている可能性があります。$_POST['key'] ?? nullfilter_input()で存在を確認します。

hidden値が送信されない

nameがあるか、form elementの内側にあるか、controlや祖先のfieldsetがdisabledになっていないかを確認します。

数字を掛けても期待した結果にならない

文字列のまま暗黙変換へ頼らず、整数・小数のformat、範囲、通貨の丸めruleをvalidationします。価格はclient値ではなくserver側のrecordから取得します。

CSRF tokenが一致しない

form表示と送信で同じsessionが継続しているか、session_start()が先に呼ばれているか、複数tab・token更新policyを確認します。framework標準実装があればそのlifecycleへ従います。

hiddenならpasswordやAPI keyを入れてもよいですか?

いけません。HTML source、DevTools、browser extension、network requestから読めます。clientへ送る必要のないsecretはserverだけに保持します。

まとめ

PHPでhidden値を受け取る基本は簡単ですが、hiddenはsecurity機能ではありません。

  • namevalueを設定してformと一緒に送る
  • 受信値は存在・型・範囲・allowlistをserverで検証する
  • 価格・税率・権限・所有者はserver側の値を使う
  • record IDはformatだけでなくauthorizationも確認する
  • CSRF tokenはsessionなどserver側の値と安全に照合する
  • DB更新keyをallowlist化し、output時はescapeする

hiddenは「見せずに送るためのform control」であり、「変更できない値」ではありません。すべてのrequest fieldを攻撃者が変更できる外部入力として扱うことが、安全なPHP formの基本です。