表單重複提交是 Web 開發中的經典問題:用戶雙擊提交按鈕、網絡延遲重試、瀏覽器刷新/後退、惡意刷單等均會導致數據重複入庫。本文系統梳理客戶端攔截、Cookie/Session Token、PRG 模式、表單過期處理、按鈕區分等五大防護層,並給出生產級最佳實踐代碼。
<form method="post" action="submit.php" id="regForm">
<input name="username" required>
<button type="submit" id="submitBtn">提交</button>
</form>
<script>
document.getElementById('regForm').addEventListener('submit', function(e) {
const btn = document.getElementById('submitBtn');
btn.disabled = true;
btn.textContent = '提交中...';
// 不阻止默認提交,表單正常發送
});
</script>
優點: 簡單、無依賴、用戶體驗好。缺點: 可被禁用 JS 繞過、無法防止刷新重複提交。
<script>
let submitted = false;
document.querySelectorAll('form').forEach(form => {
form.addEventListener('submit', function() {
if (submitted) {
alert('正在處理,請勿重複提交');
return false;
}
submitted = true;
});
});
</script>
<?php
// form.php - 生成頁面
session_start();
$token = bin2hex(random_bytes(32));
$_SESSION['form_token'] = $token;
$_SESSION['token_time'] = time();
?>
<form method="post" action="submit.php">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($token) ?>">
<!-- 其他字段 -->
<button type="submit">提交</button>
</form>
<?php
// submit.php - 處理頁面
session_start();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$token = $_POST['csrf_token'] ?? '';
$valid = !empty($_SESSION['form_token'])
&& hash_equals($_SESSION['form_token'], $token)
&& (time() - ($_SESSION['token_time'] ?? 0) < 3600); // 1小時有效
if ($valid) {
// 業務邏輯:入庫、發郵件等
// 成功後銷毀 token,防止刷新重複處理
unset($_SESSION['form_token'], $_SESSION['token_time']);
header('Location: success.php'); // PRG 模式
exit;
} else {
// Token 無效:重複提交、過期、偽造
http_response_code(400);
echo '請勿重複提交或表單已過期,請重新操作';
}
}
關鍵點:
hash_equals() 防時序攻擊random_bytes() 生成高熵 Token// 生成
$token = bin2hex(random_bytes(32));
$pdo->prepare('INSERT INTO form_tokens (token, expire_at) VALUES (?, DATE_ADD(NOW(), INTERVAL 1 HOUR))')
->execute([$token]);
// 驗證並消費(原子操作)
$stmt = $pdo->prepare('DELETE FROM form_tokens WHERE token = ? AND expire_at > NOW()');
$stmt->execute([$token]);
if ($stmt->rowCount() === 1) {
// 有效且首次使用
}
處理完 POST 請求後,必須返回 302/303 重定向到 GET 頁面:
if ($valid) {
// 處理業務
header('Location: success.php', true, 303);
exit;
}
原理: 瀏覽器地址欄變為 GET 請求 URL,用戶刷新(F5)只會重新請求成功頁,不會重發 POST 數據。
<?php
// 必須在 session_start() 之前
session_cache_limiter('private, must-revalidate'); // 支持回退不清空表單
session_start();
Cache-Control 含義:
| 指令 | 含義 |
|---|---|
private | 僅客戶端緩存,禁止代理/CDN 緩存(包含用戶敏感信息) |
must-revalidate | 過期後必須向服務器驗證,不得使用舊副本 |
no-store | 完全禁止緩存(敏感頁面如支付結果) |
<?php
// 提交失敗時,將 POST 數據存入 Session 供回填
$_SESSION['old_input'] = $_POST;
header('Location: form.php');
exit;
// form.php 讀取並清理
$old = $_SESSION['old_input'] ?? [];
unset($_SESSION['old_input']);
?>
<input name="email" value="<?= htmlspecialchars($old['email'] ?? '') ?>">
<form method="post">
<input name="title" required>
<button type="submit" name="action" value="save">保存草稿</button>
<button type="submit" name="action" value="publish">發布</button>
<button type="submit" name="action" value="preview">預覽</button>
</form>
<?php
$action = $_POST['action'] ?? '';
match ($action) {
'save' => saveDraft($data),
'publish' => publish($data),
'preview' => preview($data),
default => http_response_code(400),
};
原理: 只有被點擊的 type="submit" 按鈕的 name/value 會隨表單提交,服務端據此分發邏輯。
| 層級 | 措施 | 防護目標 | 實現成本 |
|---|---|---|---|
| 1 客戶端 | 禁用按鈕 + 提交鎖 | 誤操作、雙擊 | 極低 |
| 2 服務端 | Session Token + PRG | 重複提交、CSRF、刷新 | 低 |
| 3 數據庫 | 唯一索引(如 email/訂單號) | 並發寫入、繞過邏輯 | 低 |
| 4 業務 | 冪等性設計(Token/流水號去重) | 重試、網關重發 | 中 |
| 5 基礎設施 | Nginx 限流、WAF 防刷 | 惡意攻擊、爬蟲 | 中高 |
mt_rand() 生成 Token → 熵不足,可預測防重複提交是系統工程,單一手段皆有漏洞。推薦組合拳:JS 防誤觸 + Session Token 一次性驗證 + PRG 重定向 + 數據庫唯一約束兜底。對於高併發/分布式場景,引入 Redis 分布式鎖或數據庫原子操作消費 Token。始終記住:客戶端防禦是體驗優化,服務端校驗才是安全底線。