| 場景 | 觸發原因 | 後果 |
|---|---|---|
| 用戶連點提交按鈕 | 網路延遲、無回饋 | 重複訂單、重複扣款 |
| 瀏覽器重新整理 (F5) | POST 頁面重新整理重送請求 | 重複業務邏輯執行 |
| 瀏覽器後退鍵返回表單頁 | 快取頁面再次提交 | 資料不一致、Token 失效 |
| 惡意腳本/爬蟲重放 | 自動化工具 | 資源耗盡、資料汙染 |
採用纵深防禦:客戶端攔截(體驗優化)+ 伺服器端驗證(核心保障)+ 業務冪等設計(根本解決)。
<input type="submit" value="提交"
onclick="this.disabled=true; this.form.submit();" />
<script>
var submitted = false;
function checkSubmit() {
if (submitted) return false;
submitted = true;
return true;
}
// 防雙擊
document.ondblclick = function(e) { e.preventDefault(); };
</script>
<form onsubmit="return checkSubmit();">...</form>
伺服器端處理完 POST 請求後,不直接渲染頁面,而是 sendRedirect 到 GET 頁面。
// Servlet/Action 處理完業務
response.sendRedirect("success.jsp"); // 或列表頁
return; // 必須 return
<forward name="success" path="/list.jsp" redirect="true"/>"redirect:list.action" 或使用 redirectAction 結果類型。
[首次 GET] → 產生 Token 存 Session + 隱藏欄位輸出
[用戶 POST] → 比對 Session Token 與表單 Token
├─ 一致 → 執行業務、刷新 Session Token、回傳結果
└─ 不一致 → 拒絕、提示「重複提交」、重新發 Token
<!-- JSP -->
<html:form action="/order">
<!-- 自動產生 <input type="hidden" name="org.apache.struts.taglib.html.TOKEN" value="..."/> -->
</html:form>
// Action
public ActionForward execute(ActionMapping mapping, ActionForm form,
HttpServletRequest req, HttpServletResponse resp) {
if (!isTokenValid(req, true)) { // true = 驗證後重置
saveToken(req); // 重新產生 Token 供下次使用
return mapping.findForward("duplicate"); // 導向錯誤頁
}
// 正常業務邏輯
return mapping.findForward("success");
}
@WebFilter("/secure/*")
public class TokenFilter implements Filter {
private static final String TOKEN_KEY = "csrf_token";
private static final String HEADER_NAME = "X-CSRF-Token";
@Override
public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) resp;
if ("POST".equalsIgnoreCase(request.getMethod())) {
HttpSession session = request.getSession(false);
String sessionToken = session != null ? (String) session.getAttribute(TOKEN_KEY) : null;
String requestToken = request.getParameter(TOKEN_KEY);
// 也支援 Header 傳遞 (AJAX)
if (requestToken == null) requestToken = request.getHeader(HEADER_NAME);
if (sessionToken == null || !sessionToken.equals(requestToken)) {
response.setStatus(HttpServletResponse.SC_CONFLICT); // 409
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"error\":\"重複提交或 Token 失效,請重新整理頁面\"}");
return;
}
// 驗證通過:消費 Token,產生新 Token
session.setAttribute(TOKEN_KEY, generateToken());
}
chain.doFilter(req, resp);
}
private String generateToken() {
return UUID.randomUUID().toString().replace("-", "");
}
}
<% String token = (String) session.getAttribute("csrf_token");
if (token == null) { token = UUID.randomUUID().toString().replace("-", ""); session.setAttribute("csrf_token", token); }
%>
<form method="post" action="...">
<input type="hidden" name="csrf_token" value="<%= token %>"/>
...
</form>
即使請求重複到達,業務邏輯天然冪等,則無需額外防禦。
UNIQUE (user_id, order_no),重複插入拋異常回滾。NEW → PAID → SHIPPED,僅允許合法轉換。idempotency_key (UUID),Redis SETNX 檢查是否已處理。| 方案 | 原理 | 優/缺點 |
|---|---|---|
history.pushState() + popstate 攔截 | 替換歷史記錄,阻止回退 | 現代瀏覽器支援,但可被繞過 |
頁面 onbeforeunload 提示 | 用戶嘗試離開時確認 | 體驗較差,僅作輔助 |
| Token 機制 (推薦) | 後退頁面 Token 已失效,提交被攔截 | 核心防禦,不依賴瀏覽器行為 |