PAYATHON 2026

Mobile Safari 偶发丢失 AntiForgeryToken cookie

支付阿杰

结论

这通常不是 MVC 随机”忘记”发送 AntiForgeryToken,而是浏览器没有保存或携带 cookie,或者请求超出了该 cookie 的作用域。ASP.NET_SessionId 仍然存在,也不能排除 cookie 策略问题。两个 cookie 的创建时间、Domain、Path、SameSite、Secure、过期时间和浏览器存储状态可能完全不同。

对于同时支持 redirect 和 iframe 的 checkout,应优先排查:

  1. iframe 中的第三方 cookie 限制,以及 Safari 的隐私策略;
  2. SameSite、Secure、Domain、Path 是否一致;
  3. checkout 过程中是否切换了 hostname、scheme 或应用虚拟目录;
  4. 页面中的表单令牌是否来自旧页面,而配对 cookie 已被清理、覆盖或拒收;
  5. 多个应用或部署槽是否使用了相互冲突的防伪 cookie 配置;
  6. Safari 页面缓存、跨标签页流程或并发 checkout 是否造成令牌对失配;
  7. cookie 数量或总大小是否达到浏览器限制,导致 cookie 被淘汰。

P3P 无法解决现代 Safari 或 Samsung Internet 的第三方 cookie 限制。它主要用于历史兼容,不应作为修复方案。

_L3Yx0 后缀是什么

经典 ASP.NET MVC 默认生成的防伪 cookie 名不一定只是 __RequestVerificationToken。框架可以根据应用路径等信息添加后缀,避免同一域名下的不同虚拟应用使用相同的 cookie 名。

因此:

__RequestVerificationToken_L3Yx0

应视为完整的 cookie 名。后缀的具体计算方式取决于 MVC/System.Web 版本和应用部署路径,不能只看字符串就判断它的准确来源。

这个后缀通常没有问题。真正的异常是:

  • HTML 已生成对应的表单 token;
  • 提交时,浏览器却没有携带与其配对的 cookie token。

这两个 cookie 相互独立,不能用其中一个证明另一个一定可用。

创建时间不同

ASP.NET_SessionId 可能在用户首次进入第一方页面时就已创建,防伪 cookie 则可能要等到渲染 @Html.AntiForgeryToken() 时才会设置。

例如:

  1. 用户先以顶层页面访问支付域名,浏览器接受了 Session cookie;
  2. 随后,checkout 在第三方商户页面的 iframe 中加载;
  3. 浏览器拒绝在 iframe 环境中设置新的防伪 cookie;
  4. HTML 中仍包含 hidden form token;
  5. 提交时只有 Session cookie,没有防伪 cookie。

这与日志中观察到的现象完全一致。

即使两个 cookie 来自同一个响应,其属性也可能不同:

ASP.NET_SessionId:
Path=/
Secure
SameSite=None

__RequestVerificationToken_xxx:
Path=/checkout
SameSite=Lax

如果表单提交到了其他路径、子域或跨站 iframe,浏览器发送两个 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
    });
}

同时,还要通过浏览器远程调试确认:

  1. 返回表单的响应是否包含防伪 cookie 的 Set-Cookie;
  2. 浏览器是否提示该 Set-Cookie 被阻止;
  3. cookie 是否真正写入存储;
  4. 提交表单前,cookie 是否仍然存在;
  5. 请求的 hostname 和 path 是否处于 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 放宽到不必要的父域,或移除防伪验证。

对于支付流程,更可靠的做法是先建立第一方上下文。

一种常见流程是:

  1. 商户后端向支付平台创建 checkout session;
  2. 支付平台返回短时、一次性的 checkout 标识;
  3. 浏览器以顶层页面跳转到支付平台域名;
  4. 支付平台在第一方上下文中建立必要状态;
  5. 如果业务确实需要,再返回商户页面并加载 iframe;
  6. 支付完成后,通过签名回调或后端查询确认结果。

如果必须直接使用跨站 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 状态已经失效:

  1. 中止当前 POST;
  2. 不执行任何支付副作用;
  3. 返回明确的恢复页面;
  4. 重新建立 checkout session;
  5. 重新渲染表单和一对新的防伪 token;
  6. 如果 iframe 环境无法建立 cookie,则切换到顶层 redirect;
  7. 使用幂等键,避免用户重试造成重复支付。

捕获异常时应保持失败关闭:

[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 都无法从根本上解决问题。

备注:内容仅供参考。