PAYATHON 2026

ASP.NET Payment Form 跨服务器提交与安全验证问题

支付老李

结论

不要把 ASP.NET Web Forms 的服务器表单直接提交到第三方页面。<form runat="server"> 会带上 __VIEWSTATE、__EVENTVALIDATION 等 ASP.NET 内部字段,而这些数据只能由生成它们的应用验证。提交到另一台服务器后,对方可能会抛出 viewstate exception。

较为可靠的支付流程是:

  1. 浏览器先把支付请求提交到自己的后端。
  2. 后端根据当前登录用户、购物车和数据库中的数据重新计算订单金额,不采用浏览器传来的金额。
  3. 后端创建订单,并生成第三方要求的字段和签名。
  4. 浏览器通过普通 HTML form 自动 POST 到支付服务商。
  5. 支付后的浏览器跳转只负责展示结果。实际支付状态应以服务商发送的签名通知或后端查询结果为准。
  6. 全程使用 HTTPS。签名可以验证数据完整性,但不能代替 HTTPS。

为什么会出现 viewstate exception

ASP.NET Web Forms 的服务器表单通常包含这些字段:

<input type="hidden" name="__VIEWSTATE" value="..." />
<input type="hidden" name="__EVENTVALIDATION" value="..." />
<input type="hidden" name="__VIEWSTATEGENERATOR" value="..." />

这些值与当前页面、应用密钥和控件树相关。vendor.com 无法验证你的应用生成的 ViewState。如果第三方页面也使用 ASP.NET,并把收到的 __VIEWSTATE 当成自己的 ViewState 解析,就会报错。

只关闭几个控件的 ViewState 通常解决不了这个问题,因为服务器表单中可能还有其他 Web Forms 内部字段。支付跳转页面最好完全使用普通 HTML form,不要使用 <form runat="server">。

如何从 code behind 设置普通 hidden input

在这个场景中,使用 <%= ... %> 输出服务端模板内容是合理的,并不意味着退回 classic ASP。需要注意的是正确编码输出内容,也不要信任浏览器回传的值。

可以单独创建一个不含服务器表单的 GatewayPost.aspx:

<%@ Page Language="C#" AutoEventWireup="true"
    CodeBehind="GatewayPost.aspx.cs"
    Inherits="MyApp.GatewayPost" %>
<%@ Import Namespace="System.Web" %>

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <title>正在跳转到支付页面</title>
</head>
<body>
    <form id="paymentForm"
          method="post"
          action="https://vendor.com/getpay.aspx">
        <input type="hidden"
               name="merchant_id"
               value="<%= HttpUtility.HtmlAttributeEncode(MerchantId) %>" />

        <input type="hidden"
               name="order_id"
               value="<%= HttpUtility.HtmlAttributeEncode(OrderId) %>" />

        <input type="hidden"
               name="amount"
               value="<%= HttpUtility.HtmlAttributeEncode(AmountMinor.ToString()) %>" />

        <input type="hidden"
               name="currency"
               value="<%= HttpUtility.HtmlAttributeEncode(Currency) %>" />

        <input type="hidden"
               name="signature"
               value="<%= HttpUtility.HtmlAttributeEncode(Signature) %>" />

        <noscript>
            <button type="submit">继续前往支付页面</button>
        </noscript>
    </form>

    <script>
        document.getElementById("paymentForm").submit();
    </script>
</body>
</html>

对应的 code behind:

using System;
using System.Globalization;
using System.Web.UI;

namespace MyApp
{
    public partial class GatewayPost : Page
    {
        protected string MerchantId { get; private set; }
        protected string OrderId { get; private set; }
        protected long AmountMinor { get; private set; }
        protected string Currency { get; private set; }
        protected string Signature { get; private set; }

        protected void Page_Load(object sender, EventArgs e)
        {
            // 应从当前登录会话或一次性支付令牌中定位订单,
            // 不要直接相信 QueryString 中传入的金额和订单状态。
            PaymentOrder order = LoadPendingOrderForCurrentUser();

            MerchantId = PaymentConfiguration.MerchantId;
            OrderId = order.OrderId;
            AmountMinor = order.AmountMinor;
            Currency = order.Currency;

            Signature = PaymentSigner.CreateSignature(
                MerchantId,
                OrderId,
                AmountMinor,
                Currency);
        }

        private PaymentOrder LoadPendingOrderForCurrentUser()
        {
            // 示例占位:实际代码应查询数据库,并检查订单归属和当前状态。
            throw new NotImplementedException();
        }
    }
}

金额使用以分为单位的整数。例如,1999 表示 19.99 元,这样可以避开二进制浮点数的精度问题。具体格式仍须遵循支付服务商的协议。

如果现有页面已有一个 <form runat="server">,不要在其中嵌套第二个支付 form,因为 HTML 不允许嵌套 form。可以通过单独的跳转页面、Handler、Controller 或其他专用端点输出普通表单。

HttpWebRequest 为什么不能让浏览器跳转

HttpWebRequest 发起的请求路径是:

你的服务器 → vendor.com

普通 form POST 的请求路径则是:

用户浏览器 → vendor.com

第三方返回的 302、Cookie 和响应内容会先到达你的服务器,不会自动传给用户浏览器。即使 HttpWebRequest 自动跟随重定向,也只是你的服务器继续请求 Payment Gateway,浏览器仍会停在原页面。

如果第三方协议明确说明,服务器提交后会返回一个可供浏览器访问的 URL,可以禁止自动重定向,再把该地址返回给浏览器:

var request = (HttpWebRequest)WebRequest.Create(vendorUrl);
request.Method = "POST";
request.AllowAutoRedirect = false;

// 写入请求体的代码省略

using (var response = (HttpWebResponse)request.GetResponse())
{
    string location = response.Headers["Location"];

    if (response.StatusCode == HttpStatusCode.Redirect &&
        Uri.TryCreate(location, UriKind.Absolute, out Uri redirectUri) &&
        IsAllowedPaymentHost(redirectUri))
    {
        Response.Redirect(redirectUri.AbsoluteUri, false);
        Context.ApplicationInstance.CompleteRequest();
        return;
    }
}

必须校验 Location 的协议和目标域名,否则可能产生开放重定向漏洞。

只有服务商明确支持时,这种方式才可靠。如果服务商需要在用户浏览器中设置 Cookie、接收 POST、执行 JavaScript 或建立支付会话,就必须让浏览器直接提交。不要盲目代理整个支付页面。

后端验证的正确位置

后端验证应在生成第三方支付请求之前完成。流程如下:

用户选择支付
    ↓
自己的后端接收订单标识
    ↓
从数据库加载商品、价格、优惠和用户信息
    ↓
重新计算应付金额并创建待支付订单
    ↓
生成一次性支付请求及签名
    ↓
输出普通 HTML form,让浏览器 POST 到支付服务商

后端不能直接接受下面的数据并据此收费:

order_id=123
amount=1

即使 amount 来自 hidden input,也能被浏览器开发者工具、代理软件或脚本修改。服务器必须根据可信数据重新计算金额:

PaymentOrder order = orderRepository.Get(orderId);

if (order == null)
    throw new InvalidOperationException("订单不存在。");

if (order.UserId != currentUser.Id)
    throw new UnauthorizedAccessException();

if (order.Status != PaymentStatus.Pending)
    throw new InvalidOperationException("订单状态不允许支付。");

long amountMinor = pricingService.CalculateAmountMinor(order);

此外还要检查:

  • 当前用户是否有权支付该订单;
  • 商品价格、数量、优惠和运费是否仍然有效;
  • 订单是否已经支付、取消或过期;
  • 币种和商户号是否正确;
  • 同一订单是否正在重复发起支付;
  • 支付请求是否包含过期时间和唯一随机值;
  • 创建订单和更新支付状态是否具备幂等性。

如何防止支付字段被篡改

首先要使用 HTTPS。HTTP 无法保证机密性和传输完整性。中间人可以读取或修改请求、替换页面、窃取会话,甚至把用户引向伪造的支付站点。

如果服务商支持请求签名,还应为重要字段生成 HMAC。以下代码只演示签名结构,实际使用的字段顺序、编码方式和签名算法必须严格遵守服务商协议:

using System;
using System.Security.Cryptography;
using System.Text;

public static class PaymentSigner
{
    private static readonly byte[] Secret =
        Convert.FromBase64String(PaymentConfiguration.SigningSecretBase64);

    public static string CreateSignature(
        string merchantId,
        string orderId,
        long amountMinor,
        string currency)
    {
        ValidateField(merchantId);
        ValidateField(orderId);
        ValidateField(currency);

        string canonical =
            merchantId + "\n" +
            orderId + "\n" +
            amountMinor.ToString(System.Globalization.CultureInfo.InvariantCulture) + "\n" +
            currency;

        using (var hmac = new HMACSHA256(Secret))
        {
            byte[] data = Encoding.UTF8.GetBytes(canonical);
            return Convert.ToBase64String(hmac.ComputeHash(data));
        }
    }

    private static void ValidateField(string value)
    {
        if (string.IsNullOrEmpty(value) ||
            value.Contains("\r") ||
            value.Contains("\n"))
        {
            throw new ArgumentException("签名字段格式无效。");
        }
    }
}

签名至少应覆盖:

  • 商户号;
  • 本地订单号;
  • 支付金额;
  • 币种;
  • 时间戳或过期时间;
  • 随机数;
  • 回调地址标识;
  • 协议版本。

HMAC 密钥只能保存在后端,不能写入 HTML、JavaScript、移动端代码或 query string。

签名可以让接收方判断字段是否被修改,但不能隐藏数据,也不能自动防止重放。防止重放还需要设置时间窗口和唯一随机值,并在服务端记录它们是否已被使用。

浏览器 redirect data 能信任多少

未经验证的 redirect query string 都应视为不可信。用户可以自行访问:

https://example.com/payment/result.aspx?order_id=123&status=success

所以,不能只凭 status=success 就把订单标记为已支付,也不能根据返回页面显示的金额更新订单。

通常有两种可靠的确认方式:

  1. 支付服务商向你的后端发送签名通知,也叫 webhook、notify URL 或 server callback。
  2. 你的后端使用订单号或交易号调用服务商的查询接口,主动核实支付状态。

浏览器返回页可以改善用户体验,但页面应读取本地数据库中的最终状态:

protected void Page_Load(object sender, EventArgs e)
{
    string orderId = Request.QueryString["order_id"];

    // 先校验格式、长度,再查询当前用户有权查看的本地订单。
    PaymentOrder order =
        orderRepository.GetForUser(orderId, CurrentUser.Id);

    if (order == null)
    {
        ShowInvalidResult();
        return;
    }

    // 显示数据库中的状态,而不是直接显示 query string 中的 status。
    ShowPaymentStatus(order.Status);
}

如果返回参数带有签名,需要验证签名,同时核对:

  • 商户号与当前配置一致;
  • 订单号在本地存在;
  • 金额和币种与本地订单完全一致;
  • 交易号格式有效,并且没有绑定到其他订单;
  • 回调时间在允许范围内;
  • 随机值没有被重复使用;
  • 支付状态符合允许的状态迁移;
  • 签名使用恒定时间比较;
  • 同一通知重复到达时,不会重复发货或重复记账。

即使浏览器返回参数的签名正确,也不应把浏览器跳转作为唯一的支付凭据。用户可能关闭页面,网络可能中断,跳转也可能被拦截。最终支付状态应通过后端通知或服务端查询确认。

需要特别注意的事项

  • 不要通过 HTTP 传输支付信息、签名令牌、登录 Cookie 或个人数据。
  • 不要把 ViewState 当成跨系统的数据签名机制。
  • 不要信任 hidden input。“隐藏”只影响页面显示,并不代表安全。
  • 不要让银行卡号、CVV 等敏感卡数据经过自己的服务器,除非业务确有需要,并且已经满足相应的 PCI DSS 要求。
  • 不要记录完整的支付凭据、签名密钥或敏感回调参数。
  • 支付通知处理必须具备幂等性。只有可信状态从”未支付”变为”已支付”时,才能执行一次发货或入账。
  • 使用固定的支付服务商地址,并校验所有外部重定向目标,避免开放重定向和 SSRF。
  • 如果服务商既不支持 HTTPS,也不提供签名、后端通知或支付查询接口,这条支付链路就无法达到可靠的安全级别。此时应要求服务商升级接口,或改用满足基本安全要求的支付方案。

备注:内容仅供参考。