Mobile Safari 偶发丢失 AntiForgeryToken cookie
结论
这通常不是 MVC 随机”忘记”发送 AntiForgeryToken,而是浏览器没有保存或携带 cookie,或者请求超出了该 cookie 的作用域。ASP.NET_SessionId 仍然存在,也不能排除 cookie 策略问题。两个 cookie 的创建时间、Domain、Path、SameSite、Secure、过期时间和浏览器存储状态可能完全不同。
对于同时支持 redirect 和 iframe 的 checkout,应优先排查:
- iframe 中的第三方 cookie 限制,以及 Safari 的隐私策略;
SameSite、Secure、Domain、Path是否一致;- checkout 过程中是否切换了 hostname、scheme 或应用虚拟目录;
- 页面中的表单令牌是否来自旧页面,而配对 cookie 已被清理、覆盖或拒收;
- 多个应用或部署槽是否使用了相互冲突的防伪 cookie 配置;
- Safari 页面缓存、跨标签页流程或并发 checkout 是否造成令牌对失配;
- cookie 数量或总大小是否达到浏览器限制,导致 cookie 被淘汰。
P3P 无法解决现代 Safari 或 Samsung Internet 的第三方 cookie 限制。它主要用于历史兼容,不应作为修复方案。
_L3Yx0 后缀是什么
经典 ASP.NET MVC 默认生成的防伪 cookie 名不一定只是 __RequestVerificationToken。框架可以根据应用路径等信息添加后缀,避免同一域名下的不同虚拟应用使用相同的 cookie 名。
因此:
__RequestVerificationToken_L3Yx0
应视为完整的 cookie 名。后缀的具体计算方式取决于 MVC/System.Web 版本和应用部署路径,不能只看字符串就判断它的准确来源。
这个后缀通常没有问题。真正的异常是:
- HTML 已生成对应的表单 token;
- 提交时,浏览器却没有携带与其配对的 cookie token。
为什么 Session cookie 存在,防伪 cookie 却不存在
这两个 cookie 相互独立,不能用其中一个证明另一个一定可用。
创建时间不同
ASP.NET_SessionId 可能在用户首次进入第一方页面时就已创建,防伪 cookie 则可能要等到渲染 @Html.AntiForgeryToken() 时才会设置。
例如:
- 用户先以顶层页面访问支付域名,浏览器接受了 Session cookie;
- 随后,checkout 在第三方商户页面的 iframe 中加载;
- 浏览器拒绝在 iframe 环境中设置新的防伪 cookie;
- HTML 中仍包含 hidden form token;
- 提交时只有 Session cookie,没有防伪 cookie。
这与日志中观察到的现象完全一致。
Cookie 属性不同
即使两个 cookie 来自同一个响应,其属性也可能不同:
ASP.NET_SessionId:
Path=/
Secure
SameSite=None
__RequestVerificationToken_xxx:
Path=/checkout
SameSite=Lax
如果表单提交到了其他路径、子域或跨站 iframe,浏览器发送两个 cookie 的结果就可能不同。
Cookie 被清理或淘汰
防伪 cookie 不是 Session cookie 的同义副本。移动浏览器可能因为隐私策略、存储压力、cookie 数量上限或站点数据清理而删除其中一个,同时保留另一个。
页面来自缓存
Safari 的页面缓存可能恢复一个含有旧 hidden token 的页面,但与该页面配对的 cookie 已不存在或发生变化。此时,请求体中仍有:
__RequestVerificationToken=...
请求头中却没有对应的 cookie。
首先完善日志
不要只记录”cookie 缺失”。至少要关联整个流程,分别记录表单渲染和提交时的状态。
建议记录:
- checkout correlation ID;
- 页面通过 top-level navigation 还是 iframe 打开;
- 当前 URL、提交 URL 和顶层站点来源;
Host、scheme、端口及应用虚拟路径;Origin、Referer、Sec-Fetch-Site、Sec-Fetch-Mode、Sec-Fetch-Dest;- User-Agent;
- 服务器收到的 cookie 名列表;
- 服务端响应中所有
Set-Cookie的属性; - checkout 是否跨越多个子域;
- 请求是否经过 CDN、WAF、反向代理或负载均衡器;
- 页面生成时间和提交时间;
- 部署版本、节点及配置版本。
出于安全考虑,不要记录完整的防伪 token、Session ID 或其他 cookie 值。只记录 cookie 是否存在、名称、属性和值的不可逆摘要。
可以在全局错误处理中记录必要的上下文:
protected void Application_Error()
{
var exception = Server.GetLastError();
var request = HttpContext.Current?.Request;
if (exception == null || request == null)
return;
var cookieNames = request.Cookies.AllKeys
.Where(name => !string.IsNullOrEmpty(name))
.ToArray();
// 使用应用现有的结构化日志组件。
Log.Error(exception, new
{
request.HttpMethod,
request.Url,
request.UrlReferrer,
UserAgent = request.UserAgent,
Host = request.Headers["Host"],
Origin = request.Headers["Origin"],
SecFetchSite = request.Headers["Sec-Fetch-Site"],
SecFetchMode = request.Headers["Sec-Fetch-Mode"],
SecFetchDest = request.Headers["Sec-Fetch-Dest"],
CookieNames = cookieNames
});
}
同时,还要通过浏览器远程调试确认:
- 返回表单的响应是否包含防伪 cookie 的
Set-Cookie; - 浏览器是否提示该
Set-Cookie被阻止; - cookie 是否真正写入存储;
- 提交表单前,cookie 是否仍然存在;
- 请求的 hostname 和 path 是否处于 cookie 的作用域内。
仅看服务器端日志,无法判断究竟是服务器没有设置 cookie,还是浏览器拒绝保存。
统一 cookie 和站点边界
应尽量让以下步骤使用同一个 HTTPS origin:
- checkout 初始化;
- 表单渲染;
- 用户提交;
- 防伪验证。
尤其要检查以下地址之间是否发生切换:
https://pay.example.com
https://checkout.example.com
https://www.example.com
http://pay.example.com
https://pay.example.com/app1
https://pay.example.com/app2
这些地址即使”属于同一家公司”,cookie 也不会自动共享。浏览器只会根据 Domain、Path、Secure 和 SameSite 判断是否发送 cookie。
如果需要固定经典 MVC 的防伪 cookie 名,可以显式设置:
using System.Web.Helpers;
public class MvcApplication : System.Web.HttpApplication
{
protected void Application_Start()
{
AntiForgeryConfig.CookieName =
"__Host-CheckoutRequestVerificationToken";
// 其他 MVC 初始化代码……
}
}
不过,__Host- 前缀有严格要求:cookie 必须设置 Secure,不能设置 Domain,而且必须使用 Path=/。具体使用的经典 MVC 版本未必能让防伪 cookie 满足这些条件,因此不能在未经验证的情况下直接采用这个名字。更稳妥的普通名称是:
AntiForgeryConfig.CookieName =
"__CheckoutRequestVerificationToken";
固定名称便于排查多应用冲突,但无法绕过 Safari 的第三方 cookie 策略。
正确处理 SameSite 和 HTTPS
当 iframe checkout 属于跨站嵌入时,相关 cookie 通常需要:
SameSite=None; Secure
但 web.config 能否正确影响 MVC 防伪 cookie,取决于 .NET Framework、System.Web 和 MVC 的版本及补丁状态。必须检查实际响应中的 Set-Cookie,不能只看配置文件。
如果运行环境支持相关配置,可以参考:
<configuration>
<system.web>
<httpCookies
requireSSL="true"
httpOnlyCookies="true"
sameSite="None" />
<sessionState cookieSameSite="None" />
</system.web>
</configuration>
需要注意:
SameSite=None必须与Secure一起使用;- 整个 checkout 必须使用 HTTPS;
sessionState cookieSameSite只配置 Session cookie,不代表防伪 cookie 也已配置;- 老版本运行时可能无法识别这些属性,或者输出不兼容的值;
- 即使属性全部正确,Safari 仍可能按照第三方存储策略阻止 iframe cookie。
不要为了兼容而关闭 Secure,将防伪 cookie 放宽到不必要的父域,或移除防伪验证。
iframe 场景不能只依赖第三方 cookie
对于支付流程,更可靠的做法是先建立第一方上下文。
一种常见流程是:
- 商户后端向支付平台创建 checkout session;
- 支付平台返回短时、一次性的 checkout 标识;
- 浏览器以顶层页面跳转到支付平台域名;
- 支付平台在第一方上下文中建立必要状态;
- 如果业务确实需要,再返回商户页面并加载 iframe;
- 支付完成后,通过签名回调或后端查询确认结果。
如果必须直接使用跨站 iframe,就要把浏览器禁止第三方 cookie 视为正常运行条件,而不是偶发异常。可以检测 iframe 中能否成功写入并读回测试 cookie。如果检测失败,则切换为顶层 redirect checkout。
示意逻辑:
function canUseCheckoutCookie() {
const name = "__checkout_cookie_test";
const attributes = [
"Path=/",
"SameSite=None",
"Secure",
"Max-Age=60"
].join("; ");
document.cookie = `${name}=1; ${attributes}`;
const found = document.cookie
.split("; ")
.some(item => item.startsWith(`${name}=`));
document.cookie =
`${name}=; Path=/; SameSite=None; Secure; Max-Age=0`;
return found;
}
if (!canUseCheckoutCookie()) {
window.top.location.assign(redirectCheckoutUrl);
}
这段逻辑只能检测当前脚本能否读回非 HttpOnly 测试 cookie,不能证明真正的 HttpOnly 防伪 cookie 一定会随后续请求发送,但可以作为触发降级的信号。
不要为了检测,把真正的防伪 cookie 改成 JavaScript 可读。
检查多实例和多应用部署
如果 checkout 运行在多个节点上,所有节点应使用一致且持久的 machineKey。machineKey 不一致通常表现为”token 无法解密或验证”,而不是”cookie 不存在”,所以它不是当前日志的首要解释,但仍需要排查。
还要确认:
- 不同部署槽是否使用了相同的 cookie 名,却配置了不同密钥;
- 同一父域下的其他应用是否删除或覆盖了该 cookie;
- CDN/WAF 是否删除了部分
Set-Cookie或请求中的Cookie; - 代理转发的 hostname 和 scheme 是否与应用认知一致;
- 所有节点上的应用虚拟路径是否完全一致。
显式配置 machineKey 时,必须使用安全生成并妥善保管的密钥,不能复制示例密钥:
<configuration>
<system.web>
<machineKey
validationKey="YOUR_SECURELY_GENERATED_VALIDATION_KEY"
decryptionKey="YOUR_SECURELY_GENERATED_DECRYPTION_KEY"
validation="HMACSHA256"
decryption="AES" />
</system.web>
</configuration>
具体算法和密钥长度应符合实际 .NET Framework 版本及组织的安全基线。
对失败请求进行安全恢复
防伪验证失败后,不能直接忽略异常,也不能因为用户”看起来合法”就继续扣款。可以将 cookie 缺失视为 checkout 状态已经失效:
- 中止当前 POST;
- 不执行任何支付副作用;
- 返回明确的恢复页面;
- 重新建立 checkout session;
- 重新渲染表单和一对新的防伪 token;
- 如果 iframe 环境无法建立 cookie,则切换到顶层 redirect;
- 使用幂等键,避免用户重试造成重复支付。
捕获异常时应保持失败关闭:
[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult ConfirmPayment(ConfirmPaymentModel model)
{
// 只有通过防伪验证后才进入这里。
// 支付操作还应使用服务端 checkout session 和幂等键。
return ExecutePayment(model);
}
可以通过全局异常过滤器将 HttpAntiForgeryException 转换为恢复响应,但不能在过滤器中绕过验证并继续处理原请求:
public sealed class AntiForgeryRecoveryFilter : IExceptionFilter
{
public void OnException(ExceptionContext filterContext)
{
if (!(filterContext.Exception is HttpAntiForgeryException))
return;
filterContext.ExceptionHandled = true;
filterContext.Result = new RedirectToRouteResult(
new System.Web.Routing.RouteValueDictionary(
new
{
controller = "Checkout",
action = "Restart",
reason = "security-state-expired"
}));
}
}
重定向目标必须重新校验服务端 checkout 状态,不能信任原 POST 中的金额、商户或订单信息。
还需要注意的安全边界
AntiForgeryToken 只处理请求伪造问题,不能单独保护整个支付业务。支付确认还需要:
- 在服务端保存订单金额、币种和商户身份;
- 使用短时且一次性的 checkout session;
- 使用操作幂等键;
- 对回调进行签名验证;
- 校验
Origin或Referer,将其作为纵深防御,而不是唯一防线; - 严格校验 iframe
postMessage的 origin; - 配置合理的 CSP
frame-ancestors白名单; - 防止同一个 checkout session 被不同浏览器上下文重复使用。
实际排查时,先记录并比较两个 cookie 的真实 Set-Cookie 属性,再确认失败请求是否来自跨站 iframe,以及流程中是否发生域名切换。之后,为 iframe 增加 cookie 能力检测和 redirect 降级。如果浏览器明确阻止第三方存储,调整 P3P 或反复重发同一个防伪 cookie 都无法从根本上解决问题。
备注:内容仅供参考。
