古蹟維修筆記 - WebForm 防止重複送單
| | | 1 | |
有個古老 ASP.NET WebForm 存在一個問題,使用者若按瀏覽器回上頁,可能會使用相同內容重複送出表單。(延伸閱讀:瀏覽器回上頁能回到表單送出前狀態的原理說明)
我寫了一個範例網頁重現問題,頁面使用經典的 TextBox、Button WebControl 輸入及控制,按鈕動作也遵循古法由 C# btnXXX_Click 事件處理。網頁有一個時間戳、一個訊息文字欄位,送出的訊息簡單保存在靜態 List<string>,並在送出表單後顯示。(資安提醒:範例為展示用途,省略實務應用該有的身分識別、權限檢查及 CSRF 防護)
<%@ Page Language="C#" %>
<script runat="server">
void Page_Load(object sender, EventArgs e)
{
if (!string.IsNullOrEmpty(Request.QueryString["nocache"]))
{
Response.Cache.SetCacheability(HttpCacheability.NoCache);
Response.Cache.SetNoStore();
}
}
void btnRenew_Click(object sender, EventArgs e)
{
hdnTimestamp.Value = DateTime.Now.ToString("ssfff");
}
static List<string> submittedTexts = new List<string>();
void btnSubmit_Click(object sender, EventArgs e)
{
if (string.IsNullOrEmpty(txtData.Text) || string.IsNullOrEmpty(hdnTimestamp.Value)) {
GoBack("Invalid input");
}
else {
submittedTexts.Add(hdnTimestamp.Value + ": " + txtData.Text);
Response.Write("<ul>");
foreach(var submittedText in submittedTexts)
{
Response.Write("<li>" + Server.HtmlEncode(submittedText) + "</li>");
}
Response.Write("</ul>");
}
Response.End();
}
void GoBack(string msg)
{
var strJson = new System.Web.Script.Serialization.JavaScriptSerializer().Serialize(msg);
Response.Write("<script>alert(" + strJson + ");history.back();</" + "script>");
}
</script>
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-size: 9pt;}
td { padding: 2px; }
[type=submit] { padding: 0 4px; }
</style>
</head>
<body>
<form method="post" runat="server">
<table>
<tr>
<td>時間戳記:</td>
<td>
<input type="text" id="hdnTimestamp" runat="server" readonly size="6" />
<asp:Button ID="btnRenew" runat="server" Text="更新" OnClick="btnRenew_Click" />
</td>
</tr>
<tr>
<td>訊息文字:</td>
<td>
<asp:TextBox ID="txtData" runat="server" autocomplete="off"></asp:TextBox>
</td>
</tr>
<tr>
<td></td>
<td>
<asp:Button ID="btnSubmit" runat="server" Text="送出表單" OnClick="btnSubmit_Click" />
</td>
</tr>
</table>
</form>
</body>
</html>
問題情境發生於使用者按【更新】產生新的時間戳記並填入訊息,送出後按回上頁,改了訊息用相同時間戳記送出第二筆時間戳記相同的內容:

我想做到:一旦成功送出表單,就禁止使用者回上一頁。最直覺的做法是 ASP.NET 程式加上以下設定:(也是問 AI 最常得到的答案)
Response.Cache.SetCacheability(HttpCacheability.NoCache);
Response.Cache.SetNoStore();
如此,送出表單後就無法回上頁重新送出。

但使用 NoStore 會完全停用回上頁功能,WebForm 靠 ViewState 機制記憶欄位內容,介面原本允許使用者上一頁下一在不同輸入狀態間來回移動,此一調整將破壞原有使用習慣。

AJAX、HTML5 pushState() 等現代技巧無法套用在上古時代的 WebForm ViewState + PostBack 機制,原本該一杖擊斃的史萊姆變成阿烏拉...
經過一番研究,我想到的解法是用 Cookie 註記表單已送出,在前後端都加上檢查邏輯阻止重複送單。
後端在成功送出表單時寫入一個名稱包含時間戳的 Cookie,若重複送單便會因偵測到同名 Cookie 而被阻止。
void btnSubmit_Click(object sender, EventArgs e)
{
if (string.IsNullOrEmpty(txtData.Text) || string.IsNullOrEmpty(hdnTimestamp.Value)) {
GoBack("Invalid input");
}
else {
// 檢查是否已提交過
var cookieName = "NoReSubmit-" + hdnTimestamp.Value;
if (Request.Cookies[cookieName] != null) {
GoBack("請勿重複提交");
}
else {
// 註:Cookie 檢查在未正常回傳網頁結果時會失效,也無法阻止惡意重複送單
// 實務上仍需靠 Primary Key、Unique Index 等做為最後防線
submittedTexts.Add(hdnTimestamp.Value + ": " + txtData.Text);
// 寫入 Cookie 註記本筆資料已提交
var cookie = new System.Web.HttpCookie("NoReSubmit-" + hdnTimestamp.Value, "Y");
cookie.HttpOnly = false; // 允許客戶端讀取,不設 Expires,瀏覽器關閉後失效
Response.Cookies.Add(cookie);
Response.Write("<ul>");
foreach(var submittedText in submittedTexts)
{
Response.Write("<li>" + Server.HtmlEncode(submittedText) + "</li>");
}
Response.Write("</ul>");
}
}
Response.End();
}
Cookie 沒設 Expires 期間,故關閉瀏覽器時會清除不致長期殘留。另外,Cookie 刻意設了 HttpOnly = false 允許 JavaScript 讀取,於是乎前端也能用以下程式碼偵測到 Cookie 存在時停用送出鈕。
const cookieName = "NoReSubmit-" + document.getElementById("<%= hdnTimestamp.ClientID %>").value;
if (document.cookie.split(';').some((item) => item.trim().startsWith(cookieName + '='))) {
document.getElementById("<%= btnSubmit.ClientID %>").disabled = true;
}
最後的效果如下,可以回上頁,但若發現該時間戳已被送過,送出按鈕將被停用。甚至也可採取更積極的手段,偵測到已送單換掉網頁內容或導到其他網頁防止後續其他動作。

但要提醒:以上的重複送單檢查只對正常的回上頁再重送有效,無法抵抗惡意攻擊,若網頁傳輸出錯也會失效,實務上仍需 Primary Key、Unique Index 等機制築建最後防線。
這就是我在避免大興土木與保留古蹟原貌前題下想到的做法,若大家有其他點子,也歡迎分享。
Preventing form resubmission after browser back in classic ASP.NET WebForms: use cookies to mark submitted data and disable the submit button, preserving navigation experience.
Comments
# by 小安
感謝黑哥