PAYATHON 2026

ASP.NET 使用 HttpHandler 向支付服务提交数据

支付老李

结论

不建议通过 HttpHandler 把支付服务返回的 HTML 原样代理给浏览器。页面中的相对 URL、Cookie、表单提交地址、脚本安全策略和跳转逻辑都会改用你的网站作为基准,既容易失效,也会增加安全及合规风险。

建议采用下面的流程:

  1. 用户访问 HttpHandler。
  2. Handler 根据订单编号在服务端读取并校验订单。
  3. Handler 返回一个独立的 HTML 页面,其中包含提交给支付服务的表单和隐藏字段。
  4. 浏览器自动将表单直接 POST 到支付服务。
  5. 浏览器进入支付服务域名,相对资源也就能正常加载。

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、SameSite Cookie 和防伪机制失效;
  • 支付服务发现会话与请求来源不一致;
  • 用户的支付信息需要经过你的代理,扩大安全风险和 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();

具体采用哪种方式,应以支付服务的接口文档为准。

如果支付服务提供”创建支付会话”接口

较新的支付接口通常分为以下步骤:

  1. 你的服务器调用支付服务 API,提交订单并创建支付会话;
  2. 支付服务返回支付页面 URL;
  3. 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 代码改动较小,也能让支付页面正确加载所需资源。

备注:内容仅供参考。