如何防止非支付网关向 ASP.NET 页面提交响应
结论
不能只凭 Request.Form["responsecode"] 判断付款是否成功,也不能靠隐藏页面地址、检查 Referer 或限制表单来源来确认请求来自 First Atlantic Commerce(FAC)。任何人都能构造内容相同的 HTTP POST 请求。
正确的处理方式是:
- 按照 FAC 官方协议验证签名或消息认证码。
- 由服务器调用 FAC 的交易查询或验证接口,确认交易的真实状态。
- 核对订单号、金额、币种、商户号和网关交易编号。
- 通过原子操作,只把数据库中处于待支付状态的订单更新为已支付。
- IP 白名单只能作为附加措施,不能承担主要验证工作。
如果当前使用的 FAC 接口没有提供可验证的签名,就应以服务器之间的交易查询结果为准,不能直接信任浏览器提交到 Success.aspx 的字段。
为什么普通 POST 不可信
下面的代码只能读取客户端提交的数据:
string responseCode = Request.Form["responsecode"];
它无法证明:
- 请求来自 FAC 服务器;
- 请求内容没有被修改;
- 对应交易已经成功;
- 金额和币种与本地订单一致;
- 同一条成功响应没有被重复提交。
攻击者不需要访问支付网关,只需向公开地址发送类似请求:
POST /Success.aspx HTTP/1.1
Content-Type: application/x-www-form-urlencoded
responsecode=00&orderId=12345
所以,responsecode 只能视为一项尚未验证的输入,不能直接用来更新付款状态。
还需注意,3-D Secure 流程中的返回请求有时由持卡人的浏览器提交,并非支付网关服务器直接发起。此时即使检查来源 IP,得到的也可能是用户或代理服务器的 IP 地址。
推荐的处理流程
可以按以下流程处理支付结果:
收到 Success.aspx POST
↓
读取并严格校验参数格式
↓
验证 FAC 签名(如果该接口提供)
↓
根据本地订单读取预期金额、币种和当前状态
↓
由服务器调用 FAC 查询/确认接口
↓
核对订单号、金额、币种、商户号和交易编号
↓
在数据库事务中将 Pending 原子更新为 Paid
Success.aspx 适合作为支付结果的接收入口或用户返回页,不应成为判断付款成功的唯一依据。
首选方案:服务器端查询交易状态
具体的接口名称和参数应以当前 FAC 商户文档为准。下面的示例不能用于推测 FAC 的真实 URL、字段名或成功码。
可以将 FAC 查询逻辑封装为一个服务:
public sealed class GatewayTransactionResult
{
public bool IsSuccessful { get; set; }
public string MerchantOrderId { get; set; }
public string GatewayTransactionId { get; set; }
public decimal Amount { get; set; }
public string Currency { get; set; }
}
public interface IFacGatewayClient
{
GatewayTransactionResult QueryTransaction(string gatewayTransactionId);
}
页面收到 POST 后,应先把其中的所有数据视为不可信输入:
protected void Page_Load(object sender, EventArgs e)
{
if (!string.Equals(Request.HttpMethod, "POST",
StringComparison.OrdinalIgnoreCase))
{
Response.StatusCode = 405;
Response.End();
return;
}
string orderId = Request.Form["orderId"];
string gatewayTransactionId = Request.Form["transactionId"];
if (string.IsNullOrWhiteSpace(orderId) ||
string.IsNullOrWhiteSpace(gatewayTransactionId))
{
Response.StatusCode = 400;
Response.End();
return;
}
PaymentOrder order = paymentRepository.GetByOrderId(orderId);
if (order == null)
{
Response.StatusCode = 404;
Response.End();
return;
}
GatewayTransactionResult result =
facGatewayClient.QueryTransaction(gatewayTransactionId);
bool verified =
result != null &&
result.IsSuccessful &&
string.Equals(result.MerchantOrderId, order.OrderId,
StringComparison.Ordinal) &&
result.Amount == order.Amount &&
string.Equals(result.Currency, order.Currency,
StringComparison.OrdinalIgnoreCase);
if (!verified)
{
auditLogger.Warn(
"Payment verification failed. OrderId={0}, TransactionId={1}",
orderId,
gatewayTransactionId);
Response.StatusCode = 400;
Response.End();
return;
}
paymentRepository.MarkPaidIfPending(
order.OrderId,
result.GatewayTransactionId);
Response.Redirect("~/PaymentComplete.aspx", false);
Context.ApplicationInstance.CompleteRequest();
}
判断交易是否成功,依据的不是客户端提交的 responsecode,而是服务器使用商户凭据向 FAC 查询后得到的结果。
查询时,应优先使用服务器在创建支付请求时生成并保存到数据库中的标识,不要完全依赖回调提交的订单号。否则,攻击者可能替换订单号,把一笔真实的小额交易错误地关联到另一张订单。
验证支付网关签名
如果 FAC 当前接口文档规定响应中包含签名、哈希或 MAC,就必须严格按照文档指定的算法、字段顺序、字符编码和编码格式进行验证。
不能自行选择参与签名的字段,也不能擅自将算法改为 HMAC-SHA256。支付接口两端必须使用完全一致的协议。下面只是通用的 HMAC-SHA256 示例,只有 FAC 文档明确规定使用相同算法和拼接规则时,才能直接采用:
using System;
using System.Security.Cryptography;
using System.Text;
public static class SignatureVerifier
{
public static bool VerifyHmacSha256(
string canonicalMessage,
string receivedHexSignature,
string secret)
{
if (string.IsNullOrEmpty(canonicalMessage) ||
string.IsNullOrEmpty(receivedHexSignature) ||
string.IsNullOrEmpty(secret))
{
return false;
}
byte[] key = Encoding.UTF8.GetBytes(secret);
byte[] data = Encoding.UTF8.GetBytes(canonicalMessage);
string expected;
using (var hmac = new HMACSHA256(key))
{
byte[] hash = hmac.ComputeHash(data);
expected = BytesToHex(hash);
}
return FixedTimeEquals(
expected.ToUpperInvariant(),
receivedHexSignature.Trim().ToUpperInvariant());
}
private static string BytesToHex(byte[] bytes)
{
var builder = new StringBuilder(bytes.Length * 2);
foreach (byte value in bytes)
{
builder.Append(value.ToString("X2"));
}
return builder.ToString();
}
private static bool FixedTimeEquals(string left, string right)
{
if (left == null || right == null)
{
return false;
}
int difference = left.Length ^ right.Length;
int length = Math.Min(left.Length, right.Length);
for (int i = 0; i < length; i++)
{
difference |= left[i] ^ right[i];
}
return difference == 0;
}
}
构造 canonicalMessage 时必须遵守 FAC 文档。字段顺序、分隔符、金额的小数格式、URL 编码方式,以及使用 Base64 还是十六进制格式,任何一项不一致都会导致签名验证失败。
商户密钥必须保存在服务器端配置或密钥管理系统中,不能出现在页面、JavaScript、日志或数据库审计信息里。
数据库更新必须具备幂等性
即使请求真实有效,支付网关也可能因为超时或重试而重复发送通知,所以更新操作必须具备幂等性。
例如,只允许将 Pending 状态更新为 Paid:
UPDATE PaymentOrders
SET PaymentStatus = 'Paid',
GatewayTransactionId = @GatewayTransactionId,
PaidAtUtc = @PaidAtUtc
WHERE OrderId = @OrderId
AND PaymentStatus = 'Pending';
程序还应检查受影响的行数:
int affectedRows = command.ExecuteNonQuery();
if (affectedRows == 1)
{
// 首次成功确认
}
else
{
// 订单可能已经处理,不要重复发货或重复记账
}
最好为 GatewayTransactionId 设置唯一约束,防止同一笔网关交易关联到多个本地订单。
除了检查 responsecode,还要确认:
- 本地订单确实存在;
- 当前状态为
Pending; - 商户订单号完全一致;
- 支付金额完全一致;
- 币种一致;
- 商户账户或终端标识一致;
- 网关交易状态为最终成功;
- 网关交易编号此前没有绑定到其他订单。
IP 白名单只能作为补充
如果 FAC 明确提供固定的生产环境回调 IP 地址,可以在防火墙、反向代理或 IIS 层配置白名单。但白名单不能代替签名验证和服务器端查询,原因包括:
- 3-D Secure 返回请求可能经过用户浏览器;
- 网关的 IP 范围可能发生调整;
- 代理、负载均衡以及错误的转发头配置可能影响来源判断;
- IP 地址只能表明网络来源,无法证明请求内容未被修改或重放。
如果站点部署在反向代理之后,不要直接信任客户端提供的 X-Forwarded-For。只有该请求头由受信任的代理覆盖并清洗时,才可以使用。
不应依赖的防护方式
以下方式都不足以确认付款成功:
- 检查
Referer或Origin; - 将
Success.aspx地址保密; - 使用隐藏字段;
- 检查浏览器 User-Agent;
- 只允许 HTTP POST;
- 使用 Session 判断用户是否刚刚发起过支付;
- 只判断
responsecode是否等于某个成功值; - 仅依赖 HTTPS。
HTTPS 能保护传输过程,但无法阻止攻击者主动向页面提交自行构造的数据。Referer、Origin 和 User-Agent 也可能缺失或被伪造。
传统 ASP.NET 应用还可能受到 SameSite Cookie 策略影响,从第三方支付页面返回时拿不到原来的 Session Cookie。因此,支付确认逻辑不能依赖 Session,而应通过服务器保存的订单号、一次性关联值和网关交易编号建立关联。
其他安全注意事项
生产环境还应采取以下措施:
- 所有支付入口只接受 HTTPS。
- 限制输入的长度、字符集和格式。
- 使用参数化 SQL,防止 SQL 注入。
- 不要在日志中记录银行卡资料、完整认证数据、密钥或未经处理的完整请求。
- 记录验证失败、金额不一致、重复交易和状态冲突,供后续审计。
- 对异常请求实施速率限制,但不能把限流当作身份验证。
- 只有完成服务器端验证后,才能执行发货、充值等后续操作。
- 对长期停留在
Pending状态的订单运行后台对账任务,并再次向 FAC 查询交易状态。
最终原则是:浏览器返回页只负责接收通知和展示结果。订单是否已经付款,必须由可验证的签名或 FAC 服务器端交易记录来确定。
备注:内容仅供参考。
