有個古老 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 小安

感謝黑哥

Post a comment