PAYATHON 2026

如何在 ASP.NET Core Controller 中安全实现远程 POST

支付小周

明确结论

HTTP 重定向只能让浏览器发起新的 GET 请求。无论使用 Redirect()、RedirectToAction() 还是返回 302 响应,都不能把用户“重定向为 POST”。

在 ASP.NET Core 3.1 中,通常按下面的流程处理:

  1. Controller 在服务端创建订单并生成签名。
  2. 返回一个只包含隐藏字段的 Razor View。
  3. 浏览器加载页面后,自动提交 <form method="post"> 到支付网关。

如果支付网关支持由服务端创建支付会话,并返回付款 URL,应优先使用该接口。网关不提供这类接口时,再使用自动提交表单。

Controller 实现

先定义支付跳转页面使用的 ViewModel:

public sealed class PaymentPostViewModel
{
    public string PaymentUrl { get; set; }
    public string Amount { get; set; }
    public string Currency { get; set; }
    public string TxId { get; set; }
    public string SuccessUrl { get; set; }
    public string FailedUrl { get; set; }
    public string AccessKey { get; set; }
    public string FieldHash { get; set; }
}

Controller 负责校验订单、计算金额和生成签名:

public class OrderController : Controller
{
    private readonly IOrderService _service;
    private readonly IPaymentSignatureService _signatureService;

    public OrderController(
        IOrderService service,
        IPaymentSignatureService signatureService)
    {
        _service = service;
        _signatureService = signatureService;
    }

    [HttpGet]
    public IActionResult Index()
    {
        return View();
    }

    [HttpPost]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> ProcessOrder(OrderModel model)
    {
        if (!ModelState.IsValid)
        {
            return View("Index", model);
        }

        // 金额、币种、商品价格和库存必须在服务端重新确认,
        // 不应直接信任客户端提交的数据。
        var order = await _service.CreatePendingOrderAsync(model);

        var amount = order.TotalAmount.ToString(
            "0.00",
            System.Globalization.CultureInfo.InvariantCulture);

        const string currency = "USD";
        var transactionId = order.Id.ToString();

        var successUrl = Url.Action(
            "Success",
            "Order",
            values: null,
            protocol: Request.Scheme);

        var failedUrl = Url.Action(
            "Fail",
            "Order",
            values: null,
            protocol: Request.Scheme);

        const string accessKey = "your-public-access-key";

        // 签名规则必须严格按照支付网关文档实现。
        // 真正的签名密钥只能保存在服务端,不能放入 ViewModel。
        var fieldHash = _signatureService.CreateHash(
            amount,
            currency,
            transactionId,
            successUrl,
            failedUrl);

        var payment = new PaymentPostViewModel
        {
            PaymentUrl = "https://pgdomain.com/paynow",
            Amount = amount,
            Currency = currency,
            TxId = transactionId,
            SuccessUrl = successUrl,
            FailedUrl = failedUrl,
            AccessKey = accessKey,
            FieldHash = fieldHash
        };

        return View("PostToPaymentGateway", payment);
    }
}

示例中的 CreatePendingOrderAsync 和 CreateHash 需要根据实际业务及支付网关协议实现。此时订单通常应处于“待支付”状态,不能仅因页面跳转成功就标记为已付款。

自动提交表单的 Razor View

创建 Views/Order/PostToPaymentGateway.cshtml:

@model PaymentPostViewModel

@{
    Layout = null;
}

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="utf-8" />
    <meta name="robots" content="noindex, nofollow" />
    <title>正在跳转到支付页面</title>
</head>
<body>
    <p>正在跳转到支付页面,请稍候……</p>

    <form id="payment-form"
          method="post"
          action="@Model.PaymentUrl"
          accept-charset="UTF-8">

        <input type="hidden" name="Amount" value="@Model.Amount" />
        <input type="hidden" name="Currency" value="@Model.Currency" />
        <input type="hidden" name="TxId" value="@Model.TxId" />
        <input type="hidden" name="SuccessUrl" value="@Model.SuccessUrl" />
        <input type="hidden" name="FailedUrl" value="@Model.FailedUrl" />
        <input type="hidden" name="Access_Key" value="@Model.AccessKey" />
        <input type="hidden" name="Field_Hash" value="@Model.FieldHash" />

        <noscript>
            <p>浏览器未启用 JavaScript,请点击下面的按钮继续。</p>
        </noscript>

        <button type="submit">前往支付</button>
    </form>

    <script>
        document.getElementById("payment-form").submit();
    </script>
</body>
</html>

Razor 会自动对属性值进行 HTML 编码,不要使用 Html.Raw() 拼接这些字段。表单字段的名称和大小写必须与支付网关的要求完全一致。

如果站点启用了严格的 Content Security Policy,内联 <script> 可能无法执行。这种情况下,可以把提交代码移到允许加载的外部 JavaScript 文件中,或者按照站点现有策略使用 CSP nonce。

为什么不能直接使用 HttpClient

下面的代码确实可以让服务器向支付网关发送 POST:

await httpClient.PostAsync(paymentUrl, content);

但这个请求来自应用服务器,而非用户浏览器。支付网关返回的付款页面也会回到服务器,用户的浏览器不会自动打开它。除非网关提供“服务端创建支付会话并返回跳转 URL”的 API,否则 HttpClient 无法代替浏览器提交表单。

也不建议在 ASP.NET Core 中直接通过 Response.Body.WriteAsync() 拼接整段 HTML。这种写法容易漏掉 HTML 编码、响应状态处理和安全策略,后续维护也更麻烦。使用强类型 Razor View 更稳妥。

安全注意事项

不要信任客户端金额

Amount、币种、商品价格、折扣和订单编号都必须由服务端根据数据库中的订单重新生成。隐藏字段仍对用户完全可见,而且可以被修改。

不要把签名密钥发送给浏览器

如果支付网关区分 Access_Key 和私有密钥,表单中只能包含协议要求公开传输的标识。生成 Field_Hash 所需的私钥必须留在服务端,例如保存在环境变量、密钥管理服务或受保护的配置源中。

使用支付网关规定的签名算法

生成签名时,必须严格遵守网关对以下内容的规定:

  • 字段顺序;
  • 字符编码;
  • 金额格式;
  • URL 编码规则;
  • 哈希或 HMAC 算法;
  • 大小写格式。

不要自行设计 Field_Hash 算法,也不要用普通哈希替代网关要求的 HMAC。

固定支付目标地址

PaymentUrl 应读取自受信任的服务端配置,并限制为支付网关的 HTTPS 地址。不要通过查询字符串或用户提交内容接收任意目标 URL,否则可能造成开放重定向或数据泄露。

例如,下面这个地址:

https://pg@pgdomain.com/paynow

其中的 pg@ 在 URL 语法中会被解释为用户信息,实际主机仍然是 pgdomain.com。如果不是有意使用这种语法,通常应该写成:

https://pgdomain.com/paynow

同样,https://test@domain.com/order/success 的主机是 domain.com,test 并不是子域名。需要使用子域名时,应写为:

https://test.domain.com/order/success

不要以返回页面作为付款凭证

用户浏览器访问了 SuccessUrl,只代表浏览器回到了站点,不能证明付款成功。攻击者也可以直接访问这个地址。

订单的最终状态应根据支付网关的服务端通知、Webhook 或主动查询结果更新。处理通知时还需要:

  • 验证网关签名;
  • 核对商户号、订单号、金额和币种;
  • 防止通知重放;
  • 以幂等方式更新订单;
  • 记录网关交易编号;
  • 不记录私钥及完整的敏感支付数据。

防止重复创建订单

用户刷新或重复提交 checkout 页面时,ProcessOrder 可能执行多次。应使用幂等键、唯一订单号或数据库约束,避免重复扣减库存和创建订单。库存通常更适合先预留,付款成功后再确认,超时后则释放。

因此,在 ASP.NET Core 中不应尝试构造“POST 重定向”。正确做法是返回一个自动提交的表单,同时确保字段经过 HTML 编码、目标地址固定,并由服务端生成签名。

备注:内容仅供参考。