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:
erio
2026-03-06 22:57:55 +08:00
parent 5253bc8d35
commit bdf2577f28
5 changed files with 38 additions and 14 deletions

View File

@@ -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',
};
}

View File

@@ -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 {

View File

@@ -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());

View File

@@ -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: {

View File

@@ -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') {