ASP.NET 使用 HttpHandler 向支付服务提交数据
结论
不建议通过 HttpHandler 把支付服务返回的 HTML 原样代理给浏览器。页面中的相对 URL、Cookie、表单提交地址、脚本安全策略和跳转逻辑都会改用你的网站作为基准,既容易失效,也会增加安全及合规风险。
建议采用下面的流程:
- 用户访问
HttpHandler。 - Handler 根据订单编号在服务端读取并校验订单。
- Handler 返回一个独立的 HTML 页面,其中包含提交给支付服务的表单和隐藏字段。
- 浏览器自动将表单直接 POST 到支付服务。
- 浏览器进入支付服务域名,相对资源也就能正常加载。
Web Forms 页面只能包含一个服务器端表单,但这不影响上述做法。.ashx 返回的是一个新的独立页面,并不是在原来的 Web Forms 页面中添加第二个表单。
直接输出远程 HTML 为什么会出问题
下面这段代码发起的是服务端请求:
var response = client.UploadString(URI, data.ToString());
context.Response.Write(response);
支付服务接收到的请求来自你的服务器,但最终 HTML 会通过你的域名返回给浏览器。假设支付页面中包含:
<link rel="stylesheet" href="/css/payment.css">
<script src="scripts/payment.js"></script>
<form action="confirm.action">
浏览器会尝试从你的网站加载这些地址,例如:
https://your-site.example/css/payment.css
https://your-site.example/path/scripts/payment.js
这些请求不会发往支付服务。
即使重写了这些 URL,仍然可能出现以下问题:
- 支付服务的 Cookie 无法正确写入浏览器;
- 相对表单地址和后续跳转仍然指向错误的域名;
- JavaScript 依赖当前域名或页面来源;
- CSP、CORS、
SameSiteCookie 和防伪机制失效; - 支付服务发现会话与请求来源不一致;
- 用户的支付信息需要经过你的代理,扩大安全风险和 PCI DSS 合规范围。
所以,增加一个代理库并不能可靠地解决这类问题。
推荐实现:输出自动提交的表单
Handler 不再代替浏览器调用支付服务,而是生成一个表单,由浏览器直接提交:
using System;
using System.Collections.Generic;
using System.Text;
using System.Web;
namespace Web
{
public class BeginInvoice : IHttpHandler
{
public void ProcessRequest(HttpContext context)
{
var orderNumber = context.Request.QueryString["invoice-number"];
if (string.IsNullOrWhiteSpace(orderNumber))
{
context.Response.StatusCode = 400;
context.Response.Write("缺少订单编号。");
return;
}
// 必须根据当前登录用户和订单编号,从服务端读取订单。
// 不要直接相信 query string 中的金额、币种或商户信息。
var order = GetAndValidateOrder(orderNumber);
if (order == null)
{
context.Response.StatusCode = 404;
context.Response.Write("订单不存在或无权访问。");
return;
}
var paymentUrl = "https://somepaymentservice/start.action";
var fields = new Dictionary<string, string>
{
["merchant"] = "666111",
["amount"] = order.Amount,
["lang"] = "sv",
["currency"] = "752",
["orderid"] = order.OrderNumber,
["accepturl"] = "https://www.example.com/Complete-invoice.ashx",
["test"] = "1"
// 如果支付服务要求签名、取消地址或通知地址,
// 应按其接口文档增加相应字段。
};
var html = new StringBuilder();
html.AppendLine("<!doctype html>");
html.AppendLine("<html lang=\"sv\">");
html.AppendLine("<head>");
html.AppendLine(" <meta charset=\"utf-8\">");
html.AppendLine(" <meta name=\"referrer\" content=\"no-referrer\">");
html.AppendLine(" <title>正在进入支付页面</title>");
html.AppendLine("</head>");
html.AppendLine("<body>");
html.AppendLine(" <p>正在进入支付页面,请稍候……</p>");
html.AppendFormat(
" <form id=\"payment-form\" method=\"post\" action=\"{0}\">{1}",
HttpUtility.HtmlAttributeEncode(paymentUrl),
Environment.NewLine);
foreach (var field in fields)
{
html.AppendFormat(
" <input type=\"hidden\" name=\"{0}\" value=\"{1}\">{2}",
HttpUtility.HtmlAttributeEncode(field.Key),
HttpUtility.HtmlAttributeEncode(field.Value ?? string.Empty),
Environment.NewLine);
}
html.AppendLine(" <noscript>");
html.AppendLine(" <button type=\"submit\">继续支付</button>");
html.AppendLine(" </noscript>");
html.AppendLine(" </form>");
html.AppendLine(" <script>");
html.AppendLine(" document.getElementById('payment-form').submit();");
html.AppendLine(" </script>");
html.AppendLine("</body>");
html.AppendLine("</html>");
context.Response.StatusCode = 200;
context.Response.ContentType = "text/html; charset=utf-8";
context.Response.Cache.SetCacheability(HttpCacheability.NoCache);
context.Response.Cache.SetNoStore();
context.Response.Write(html.ToString());
}
private static PaymentOrder GetAndValidateOrder(string orderNumber)
{
// 示例代码。实际项目中应从数据库或内部服务获取,
// 并验证订单归属、状态、金额和是否允许重复发起支付。
return new PaymentOrder
{
OrderNumber = orderNumber,
Amount = "1"
};
}
public bool IsReusable
{
get { return false; }
}
private sealed class PaymentOrder
{
public string OrderNumber { get; set; }
public string Amount { get; set; }
}
}
}
实际的请求顺序如下:
浏览器 GET /BeginInvoice.ashx?invoice-number=123
浏览器 POST https://somepaymentservice/start.action
支付服务向浏览器返回支付页面
此时,浏览器地址栏显示的是支付服务域名。页面中的相对 CSS、JavaScript、图片和后续表单地址都会以支付服务为基准。
为什么不能用 Location 保留这些 POST 数据
普通重定向响应只能指定目标 URL,例如:
HTTP/1.1 302 Found
Location: https://somepaymentservice/start.action
重定向响应无法附带新的 POST 请求体。浏览器处理 302 或 303 时,通常会向目标地址发起 GET 请求,因此不会提交支付字段。
307 Temporary Redirect 和 308 Permanent Redirect 可以要求浏览器保留原请求的方法和请求体,但也不适用于这里。用户访问 Handler 时发出的是 GET,请求体中没有支付字段。即使返回 307,浏览器仍会使用 GET 请求支付地址。
如果支付服务先返回专用支付 URL、会话 ID 或 token,并明确要求通过 GET 跳转,才适合直接重定向:
context.Response.Redirect(paymentPageUrl, false);
context.ApplicationInstance.CompleteRequest();
具体采用哪种方式,应以支付服务的接口文档为准。
如果支付服务提供”创建支付会话”接口
较新的支付接口通常分为以下步骤:
- 你的服务器调用支付服务 API,提交订单并创建支付会话;
- 支付服务返回支付页面 URL;
- Handler 将用户重定向到该 URL。
示意代码如下:
var paymentPageUrl = CreatePaymentSessionOnServer(order);
context.Response.Redirect(paymentPageUrl, false);
context.ApplicationInstance.CompleteRequest();
这种方式一般比自动提交表单更清楚,也能避免在 HTML 中暴露不必要的接口字段。不过,只有支付服务明确提供这类 API 时才能使用,不能自行假设它支持该流程。
必须注意的安全问题
不要相信查询字符串中的订单数据
查询字符串只应包含订单标识:
/BeginInvoice.ashx?invoice-number=123
金额、币种、商户号和支付状态必须由服务端根据订单重新读取。服务端还需要检查:
- 当前用户是否拥有该订单;
- 订单是否尚未支付;
- 金额是否与数据库一致;
- 订单是否已经取消或过期;
- 是否允许再次发起支付请求。
按支付接口要求生成签名
如果支付服务要求 MAC、HMAC、摘要或其他签名,必须严格遵守接口规定的字段顺序、字符编码和密钥规则。签名密钥只能保存在服务器端,不能写入页面或查询字符串。
返回地址必须能被支付服务访问
示例中的地址:
http://localhost:50113/Complete-invoice.ashx
通常只能用于本机调试。远程支付服务无法访问用户电脑上的 localhost。生产环境必须使用公开可访问的 HTTPS 地址。
用户返回页不代表支付成功
用户可能会关闭页面,也可能伪造对返回地址的请求。因此,不能只因为 accepturl 被访问,就把订单标记为已支付。
如果支付服务提供服务端通知或 webhook,应根据通知结果更新订单,并校验:
- 通知签名;
- 商户号;
- 订单号;
- 金额和币种;
- 支付状态;
- 重复通知的幂等性。
用户返回页面只负责读取本地订单状态。必要时,再调用支付服务的查询接口确认支付结果。
正确编码输出
把表单字段写入 HTML 属性时,应使用:
HttpUtility.HtmlAttributeEncode(value)
UrlEncode 用于构造 application/x-www-form-urlencoded 请求体,不能直接代替 HTML 属性编码。
不建议自行实现完整的反向代理
从技术上说,可以代理支付页面,并重写 HTML、CSS、表单地址、重定向、Cookie 和响应头,但这样的实现很脆弱。只要支付页面修改资源路径、增加 CSP、改用动态脚本或调整 Cookie 策略,代理就可能失效。
除非支付服务官方明确支持代理或嵌入模式,否则应优先使用以下方式:
- 让浏览器直接 POST 表单;
- 创建支付会话后重定向;
- 使用支付服务官方提供的 Hosted Payment Page、SDK 或嵌入组件。
对于当前场景,让独立 Handler 输出自动提交的表单,对现有 Web Forms 代码改动较小,也能让支付页面正确加载所需资源。
备注:内容仅供参考。
