ASP.NET Payment Form 跨服务器提交与安全验证问题
结论
不要把 ASP.NET Web Forms 的服务器表单直接提交到第三方页面。<form runat="server"> 会带上 __VIEWSTATE、__EVENTVALIDATION 等 ASP.NET 内部字段,而这些数据只能由生成它们的应用验证。提交到另一台服务器后,对方可能会抛出 viewstate exception。
较为可靠的支付流程是:
- 浏览器先把支付请求提交到自己的后端。
- 后端根据当前登录用户、购物车和数据库中的数据重新计算订单金额,不采用浏览器传来的金额。
- 后端创建订单,并生成第三方要求的字段和签名。
- 浏览器通过普通 HTML form 自动 POST 到支付服务商。
- 支付后的浏览器跳转只负责展示结果。实际支付状态应以服务商发送的签名通知或后端查询结果为准。
- 全程使用 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 就把订单标记为已支付,也不能根据返回页面显示的金额更新订单。
通常有两种可靠的确认方式:
- 支付服务商向你的后端发送签名通知,也叫 webhook、notify URL 或 server callback。
- 你的后端使用订单号或交易号调用服务商的查询接口,主动核实支付状态。
浏览器返回页可以改善用户体验,但页面应读取本地数据库中的最终状态:
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,也不提供签名、后端通知或支付查询接口,这条支付链路就无法达到可靠的安全级别。此时应要求服务商升级接口,或改用满足基本安全要求的支付方案。
备注:内容仅供参考。
