fix: 支付安全审核修复(支付宝+微信)
支付宝: - 回调增加 app_id 校验,防止跨商户通知 - 回调增加 sign_type 过滤,仅接受 RSA2 - 退款增加 out_request_no 保证幂等 - 金额解析增加精度保护 - timestamp 改用 CST 时区 微信: - 自行实现 AES-GCM 解密替代库的 decipher_gcm(修复 AuthTag 未验证) - WXPAY_PUBLIC_KEY_ID 改为必填 - serial 匹配检查改为强制 - 时间戳校验移到签名验证之前 - nonce 改用 crypto.randomBytes - publicKey 不允许空 Buffer fallback Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,7 +10,7 @@ function getCommonParams(appId: string): Record<string, string> {
|
||||
format: 'JSON',
|
||||
charset: 'utf-8',
|
||||
sign_type: 'RSA2',
|
||||
timestamp: new Date().toISOString().replace('T', ' ').substring(0, 19),
|
||||
timestamp: new Date().toLocaleString('sv-SE', { timeZone: 'Asia/Shanghai' }).replace('T', ' '),
|
||||
version: '1.0',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ export class AlipayProvider implements PaymentProvider {
|
||||
return {
|
||||
tradeNo: result.trade_no || tradeNo,
|
||||
status,
|
||||
amount: parseFloat(result.total_amount || '0'),
|
||||
amount: Math.round(parseFloat(result.total_amount || '0') * 100) / 100,
|
||||
paidAt: result.send_pay_date ? new Date(result.send_pay_date) : undefined,
|
||||
};
|
||||
}
|
||||
@@ -90,15 +90,25 @@ export class AlipayProvider implements PaymentProvider {
|
||||
params[key] = value;
|
||||
}
|
||||
|
||||
// sign_type 过滤:仅接受 RSA2
|
||||
if (params.sign_type && params.sign_type !== 'RSA2') {
|
||||
throw new Error('Unsupported sign_type, only RSA2 is accepted');
|
||||
}
|
||||
|
||||
const sign = params.sign || '';
|
||||
if (!env.ALIPAY_PUBLIC_KEY || !verifySign(params, env.ALIPAY_PUBLIC_KEY, sign)) {
|
||||
throw new Error('Alipay notification signature verification failed');
|
||||
}
|
||||
|
||||
// app_id 校验
|
||||
if (params.app_id !== env.ALIPAY_APP_ID) {
|
||||
throw new Error('Alipay notification app_id mismatch');
|
||||
}
|
||||
|
||||
return {
|
||||
tradeNo: params.trade_no || '',
|
||||
orderId: params.out_trade_no || '',
|
||||
amount: parseFloat(params.total_amount || '0'),
|
||||
amount: Math.round(parseFloat(params.total_amount || '0') * 100) / 100,
|
||||
status:
|
||||
params.trade_status === 'TRADE_SUCCESS' || params.trade_status === 'TRADE_FINISHED' ? 'success' : 'failed',
|
||||
rawData: params,
|
||||
@@ -110,6 +120,7 @@ export class AlipayProvider implements PaymentProvider {
|
||||
out_trade_no: request.orderId,
|
||||
refund_amount: request.amount.toFixed(2),
|
||||
refund_reason: request.reason || '',
|
||||
out_request_no: request.orderId + '-refund',
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -49,11 +49,12 @@ export function initPaymentProviders(): void {
|
||||
!env.WXPAY_PRIVATE_KEY ||
|
||||
!env.WXPAY_API_V3_KEY ||
|
||||
!env.WXPAY_PUBLIC_KEY ||
|
||||
!env.WXPAY_PUBLIC_KEY_ID ||
|
||||
!env.WXPAY_CERT_SERIAL ||
|
||||
!env.WXPAY_NOTIFY_URL
|
||||
) {
|
||||
throw new Error(
|
||||
'PAYMENT_PROVIDERS includes wxpay but required env vars are missing: WXPAY_APP_ID, WXPAY_MCH_ID, WXPAY_PRIVATE_KEY, WXPAY_API_V3_KEY, WXPAY_PUBLIC_KEY, WXPAY_CERT_SERIAL, WXPAY_NOTIFY_URL',
|
||||
'PAYMENT_PROVIDERS includes wxpay but required env vars are missing: WXPAY_APP_ID, WXPAY_MCH_ID, WXPAY_PRIVATE_KEY, WXPAY_API_V3_KEY, WXPAY_PUBLIC_KEY, WXPAY_PUBLIC_KEY_ID, WXPAY_CERT_SERIAL, WXPAY_NOTIFY_URL',
|
||||
);
|
||||
}
|
||||
paymentRegistry.register(new WxpayProvider());
|
||||
|
||||
@@ -26,7 +26,10 @@ function getPayInstance(): WxPay {
|
||||
const env = assertWxpayEnv(getEnv());
|
||||
|
||||
const privateKey = Buffer.from(env.WXPAY_PRIVATE_KEY);
|
||||
const publicKey = env.WXPAY_PUBLIC_KEY ? Buffer.from(env.WXPAY_PUBLIC_KEY) : Buffer.alloc(0);
|
||||
if (!env.WXPAY_PUBLIC_KEY) {
|
||||
throw new Error('WXPAY_PUBLIC_KEY is required');
|
||||
}
|
||||
const publicKey = Buffer.from(env.WXPAY_PUBLIC_KEY);
|
||||
|
||||
payInstance = new WxPay({
|
||||
appid: env.WXPAY_APP_ID,
|
||||
@@ -45,7 +48,7 @@ function yuanToFen(yuan: number): number {
|
||||
|
||||
async function request<T>(method: string, url: string, body?: Record<string, unknown>): Promise<T> {
|
||||
const pay = getPayInstance();
|
||||
const nonce_str = Math.random().toString(36).substring(2, 15);
|
||||
const nonce_str = crypto.randomBytes(16).toString('hex');
|
||||
const timestamp = Math.floor(Date.now() / 1000).toString();
|
||||
|
||||
const signature = pay.getSignature(method, nonce_str, timestamp, url, body ? JSON.stringify(body) : '');
|
||||
@@ -134,8 +137,17 @@ export async function createRefund(params: WxpayRefundParams): Promise<Record<st
|
||||
}
|
||||
|
||||
export function decipherNotify<T>(ciphertext: string, associatedData: string, nonce: string): T {
|
||||
const pay = getPayInstance();
|
||||
return pay.decipher_gcm<T>(ciphertext, associatedData, nonce);
|
||||
const env = assertWxpayEnv(getEnv());
|
||||
const key = env.WXPAY_API_V3_KEY;
|
||||
const ciphertextBuf = Buffer.from(ciphertext, 'base64');
|
||||
// AES-GCM 最后 16 字节是 AuthTag
|
||||
const authTag = ciphertextBuf.subarray(ciphertextBuf.length - 16);
|
||||
const data = ciphertextBuf.subarray(0, ciphertextBuf.length - 16);
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', key, nonce);
|
||||
decipher.setAuthTag(authTag);
|
||||
decipher.setAAD(Buffer.from(associatedData));
|
||||
const decoded = Buffer.concat([decipher.update(data), decipher.final()]);
|
||||
return JSON.parse(decoded.toString('utf-8')) as T;
|
||||
}
|
||||
|
||||
export async function verifyNotifySign(params: {
|
||||
|
||||
@@ -112,20 +112,20 @@ export class WxpayProvider implements PaymentProvider {
|
||||
}
|
||||
|
||||
// 验证 serial 匹配我们配置的公钥 ID
|
||||
if (env.WXPAY_PUBLIC_KEY_ID && serial !== env.WXPAY_PUBLIC_KEY_ID) {
|
||||
if (serial !== env.WXPAY_PUBLIC_KEY_ID) {
|
||||
throw new Error(`Wxpay serial mismatch: expected ${env.WXPAY_PUBLIC_KEY_ID}, got ${serial}`);
|
||||
}
|
||||
|
||||
const valid = await verifyNotifySign({ timestamp, nonce, body, serial, signature });
|
||||
if (!valid) {
|
||||
throw new Error('Wxpay notification signature verification failed');
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (Math.abs(now - Number(timestamp)) > 300) {
|
||||
throw new Error('Wechatpay notification timestamp expired');
|
||||
}
|
||||
|
||||
const valid = await verifyNotifySign({ timestamp, nonce, body, serial, signature });
|
||||
if (!valid) {
|
||||
throw new Error('Wxpay notification signature verification failed');
|
||||
}
|
||||
|
||||
const payload: WxpayNotifyPayload = JSON.parse(body);
|
||||
|
||||
if (payload.event_type !== 'TRANSACTION.SUCCESS') {
|
||||
|
||||
Reference in New Issue
Block a user