如何在 ASP.NET Core Controller 中安全实现远程 POST
明确结论
HTTP 重定向只能让浏览器发起新的 GET 请求。无论使用 Redirect()、RedirectToAction() 还是返回 302 响应,都不能把用户“重定向为 POST”。
在 ASP.NET Core 3.1 中,通常按下面的流程处理:
- Controller 在服务端创建订单并生成签名。
- 返回一个只包含隐藏字段的 Razor View。
- 浏览器加载页面后,自动提交
<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 编码、目标地址固定,并由服务端生成签名。
备注:内容仅供参考。
