fix: 全面安全审计修复 — 支付验签、IDOR、竞态、token过期等

- H1: 支付宝响应验签 (verifyResponseSign + bracket-matching 提取签名内容)
- H2/H3: EasyPay queryOrder 从 GET 改 POST,PKEY 不再暴露于 URL
- H5: users/[id] IDOR 修复,校验当前用户只能查询自身信息
- H6: 限额校验移入 prisma.$transaction() 防止 TOCTOU 竞态
- C1: access_token 增加 24h 过期、userId 绑定、派生密钥分离
- M1: EasyPay 回调增加 pid 校验防跨商户注入
- M4: 充值码增加 crypto.randomBytes 随机后缀
- M5: 过期订单批量处理增加 BATCH_SIZE 限制
- M6: 退款失败增加 [CRITICAL] 日志和余额补偿标记
- M7: admin channels PUT 增加 Zod schema 校验
- M8: admin subscriptions 分页参数增加上限
- M9: orders src_url 限制 HTTP/HTTPS 协议
- L1: 微信支付回调时间戳 NaN 检查
- L9: WXPAY_API_V3_KEY 长度校验
This commit is contained in:
erio
2026-03-14 04:36:33 +08:00
parent 34ad876626
commit 4ce3484179
19 changed files with 320 additions and 124 deletions

View File

@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { NextRequest } from 'next/server';
import { ORDER_STATUS } from '@/lib/constants';
@@ -52,10 +52,15 @@ function createPendingOrder(overrides: Record<string, unknown> = {}) {
describe('GET /pay/[orderId]', () => {
beforeEach(() => {
vi.useFakeTimers({ now: new Date('2026-03-14T12:00:00Z') });
vi.clearAllMocks();
mockBuildAlipayPaymentUrl.mockReturnValue('https://openapi.alipay.com/gateway.do?mock=1');
});
afterEach(() => {
vi.useRealTimers();
});
it('returns 404 error page when order does not exist', async () => {
mockFindUnique.mockResolvedValue(null);

View File

@@ -15,6 +15,7 @@ const { mockGenerateSign } = vi.hoisted(() => ({
}));
vi.mock('@/lib/alipay/sign', () => ({
generateSign: mockGenerateSign,
verifyResponseSign: vi.fn(() => true),
}));
import { execute, pageExecute } from '@/lib/alipay/client';

View File

@@ -259,7 +259,7 @@ describe('EasyPay client', () => {
});
describe('queryOrder', () => {
it('should call GET api.php with correct query parameters', async () => {
it('should call POST api.php with correct body parameters', async () => {
global.fetch = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
@@ -285,12 +285,14 @@ describe('EasyPay client', () => {
expect(result.status).toBe(1);
expect(global.fetch).toHaveBeenCalledTimes(1);
const [url] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toContain('https://pay.example.com/api.php');
expect(url).toContain('act=order');
expect(url).toContain('pid=1001');
expect(url).toContain('key=test-merchant-secret-key');
expect(url).toContain('out_trade_no=order-001');
const [url, init] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toBe('https://pay.example.com/api.php');
expect(init.method).toBe('POST');
const body = new URLSearchParams(init.body as string);
expect(body.get('act')).toBe('order');
expect(body.get('pid')).toBe('1001');
expect(body.get('key')).toBe('test-merchant-secret-key');
expect(body.get('out_trade_no')).toBe('order-001');
});
it('should throw when API returns code !== 1', async () => {

View File

@@ -2,9 +2,12 @@ import { describe, it, expect } from 'vitest';
import { generateRechargeCode } from '@/lib/order/code-gen';
describe('generateRechargeCode', () => {
it('should generate code with s2p_ prefix', () => {
it('should generate code with s2p_ prefix and random suffix', () => {
const code = generateRechargeCode('cm1234567890');
expect(code).toBe('s2p_cm1234567890');
expect(code.startsWith('s2p_')).toBe(true);
expect(code.length).toBeLessThanOrEqual(32);
// 包含 orderId 部分和 8 字符随机后缀
expect(code.length).toBeGreaterThan(12);
});
it('should truncate long order IDs to fit 32 chars', () => {
@@ -14,8 +17,15 @@ describe('generateRechargeCode', () => {
expect(code.startsWith('s2p_')).toBe(true);
});
it('should generate different codes for same orderId (randomness)', () => {
const code1 = generateRechargeCode('order-001');
const code2 = generateRechargeCode('order-001');
expect(code1).not.toBe(code2);
});
it('should handle empty string', () => {
const code = generateRechargeCode('');
expect(code).toBe('s2p_');
expect(code.startsWith('s2p_')).toBe(true);
expect(code.length).toBeLessThanOrEqual(32);
});
});

View File

@@ -1,13 +1,33 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
import { prisma } from '@/lib/db';
const updateChannelSchema = z
.object({
group_id: z.number().int().positive().optional(),
name: z.string().min(1).max(100).optional(),
platform: z.string().min(1).max(50).optional(),
rate_multiplier: z.number().positive().optional(),
description: z.string().max(500).nullable().optional(),
models: z.array(z.string()).nullable().optional(),
features: z.record(z.string(), z.unknown()).nullable().optional(),
sort_order: z.number().int().min(0).optional(),
enabled: z.boolean().optional(),
})
.strict();
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
if (!(await verifyAdminToken(request))) return unauthorizedResponse(request);
try {
const { id } = await params;
const body = await request.json();
const rawBody = await request.json();
const parsed = updateChannelSchema.safeParse(rawBody);
if (!parsed.success) {
return NextResponse.json({ error: '参数校验失败' }, { status: 400 });
}
const body = parsed.data;
const existing = await prisma.channel.findUnique({ where: { id } });
if (!existing) {
@@ -27,15 +47,8 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
}
}
if (body.rate_multiplier !== undefined && (typeof body.rate_multiplier !== 'number' || body.rate_multiplier <= 0)) {
return NextResponse.json({ error: 'rate_multiplier 必须是正数' }, { status: 400 });
}
if (body.sort_order !== undefined && (!Number.isInteger(body.sort_order) || body.sort_order < 0)) {
return NextResponse.json({ error: 'sort_order 必须是非负整数' }, { status: 400 });
}
const data: Record<string, unknown> = {};
if (body.group_id !== undefined) data.groupId = Number(body.group_id);
if (body.group_id !== undefined) data.groupId = body.group_id;
if (body.name !== undefined) data.name = body.name;
if (body.platform !== undefined) data.platform = body.platform;
if (body.rate_multiplier !== undefined) data.rateMultiplier = body.rate_multiplier;

View File

@@ -37,8 +37,8 @@ export async function GET(request: NextRequest) {
const result = await listSubscriptions({
group_id: groupId ? Number(groupId) : undefined,
status: status || undefined,
page: page ? Number(page) : undefined,
page_size: pageSize ? Number(pageSize) : undefined,
page: page ? Math.max(1, Number(page)) : undefined,
page_size: pageSize ? Math.min(200, Math.max(1, Number(pageSize))) : undefined,
});
return NextResponse.json({

View File

@@ -11,7 +11,18 @@ const createOrderSchema = z.object({
amount: z.number().positive().max(99999999.99),
payment_type: z.string().min(1),
src_host: z.string().max(253).optional(),
src_url: z.string().max(2048).optional(),
src_url: z
.string()
.max(2048)
.refine((url) => {
try {
const protocol = new URL(url).protocol;
return protocol === 'http:' || protocol === 'https:';
} catch {
return false;
}
}, 'src_url must be a valid HTTP/HTTPS URL')
.optional(),
is_mobile: z.boolean().optional(),
order_type: z.enum(['balance', 'subscription']).optional(),
plan_id: z.string().optional(),

View File

@@ -9,8 +9,9 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
return NextResponse.json({ error: 'token is required' }, { status: 400 });
}
let currentUser: { id: number };
try {
await getCurrentUserByToken(token);
currentUser = await getCurrentUserByToken(token);
} catch {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
}
@@ -22,6 +23,11 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
return NextResponse.json({ error: 'Invalid user id' }, { status: 400 });
}
// 只允许查询自身用户信息,防止 IDOR 用户枚举
if (userId !== currentUser.id) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
try {
const user = await getUser(userId);
return NextResponse.json({ id: user.id, exists: true });

View File

@@ -1,7 +1,7 @@
import { getEnv } from '@/lib/config';
import { generateSign } from './sign';
import { generateSign, verifyResponseSign } from './sign';
import type { AlipayResponse } from './types';
import { parseAlipayJsonResponse } from './codec';
import { parseAlipayJsonResponseWithRaw } from './codec';
const GATEWAY = 'https://openapi.alipay.com/gateway.do';
@@ -89,14 +89,20 @@ export async function execute<T extends AlipayResponse>(
signal: AbortSignal.timeout(10_000),
});
const data = await parseAlipayJsonResponse<Record<string, unknown>>(response);
const { data, rawText } = await parseAlipayJsonResponseWithRaw(response);
// 支付宝响应格式:{ "alipay_trade_query_response": { ... }, "sign": "..." }
// TODO: 实现响应验签 — 需要从原始响应文本中提取 responseKey 对应的 JSON 子串,
// 使用 verifySign 配合 ALIPAY_PUBLIC_KEY 验证 data.sign。
// 当前未验签是因为需要保留原始响应文本(不能 JSON.parse 后再 stringify
// 需要改造 parseAlipayJsonResponse 同时返回原始文本。
const responseKey = method.replace(/\./g, '_') + '_response';
// 响应验签:从原始文本中提取 responseKey 对应的 JSON 子串进行 RSA2 验签
const responseSign = data.sign as string | undefined;
if (responseSign) {
const valid = verifyResponseSign(rawText, responseKey, env.ALIPAY_PUBLIC_KEY, responseSign);
if (!valid) {
throw new Error(`Alipay API response signature verification failed for ${method}`);
}
}
const result = data[responseKey] as T | undefined;
if (!result) {

View File

@@ -102,3 +102,16 @@ export async function parseAlipayJsonResponse<T>(response: Response): Promise<T>
const text = decodeAlipayPayload(rawBody, { 'content-type': contentType });
return JSON.parse(text) as T;
}
/**
* 解析支付宝 JSON 响应并返回原始文本,用于响应验签。
* 验签要求使用原始 JSON 子串(不能 parse 后再 stringify
*/
export async function parseAlipayJsonResponseWithRaw(
response: Response,
): Promise<{ data: Record<string, unknown>; rawText: string }> {
const rawBody = Buffer.from(await response.arrayBuffer());
const contentType = response.headers.get('content-type') || '';
const rawText = decodeAlipayPayload(rawBody, { 'content-type': contentType });
return { data: JSON.parse(rawText), rawText };
}

View File

@@ -43,6 +43,73 @@ export function generateSign(params: Record<string, string>, privateKey: string)
return signer.sign(formatPrivateKey(privateKey), 'base64');
}
/**
* 验证支付宝服务端 API 响应签名。
* 从原始 JSON 文本中提取 responseKey 对应的子串作为验签内容。
*/
export function verifyResponseSign(
rawText: string,
responseKey: string,
alipayPublicKey: string,
sign: string,
): boolean {
// 从原始文本中精确提取 responseKey 对应的 JSON 子串
// 格式: {"responseKey":{ ... },"sign":"..."}
const keyPattern = `"${responseKey}"`;
const keyIdx = rawText.indexOf(keyPattern);
if (keyIdx < 0) return false;
const colonIdx = rawText.indexOf(':', keyIdx + keyPattern.length);
if (colonIdx < 0) return false;
// 找到 value 的起始位置(跳过冒号后的空白)
let start = colonIdx + 1;
while (start < rawText.length && rawText[start] === ' ') start++;
// 使用括号匹配找到完整的 JSON 值
let depth = 0;
let end = start;
let inString = false;
let escaped = false;
for (let i = start; i < rawText.length; i++) {
const ch = rawText[i];
if (escaped) {
escaped = false;
continue;
}
if (ch === '\\' && inString) {
escaped = true;
continue;
}
if (ch === '"') {
inString = !inString;
continue;
}
if (inString) continue;
if (ch === '{') depth++;
if (ch === '}') {
depth--;
if (depth === 0) {
end = i + 1;
break;
}
}
}
const signContent = rawText.substring(start, end);
const pem = formatPublicKey(alipayPublicKey);
try {
const verifier = crypto.createVerify('RSA-SHA256');
verifier.update(signContent);
return verifier.verify(pem, sign, 'base64');
} catch (err) {
if (shouldLogVerifyDebug()) {
console.error('[Alipay verifyResponseSign] crypto error:', err);
}
return false;
}
}
/** 用支付宝公钥验证签名(回调验签:排除 sign 和 sign_type */
export function verifySign(params: Record<string, string>, alipayPublicKey: string, sign: string): boolean {
const filtered = Object.entries(params)

View File

@@ -89,8 +89,17 @@ export async function createPayment(opts: CreatePaymentOptions): Promise<EasyPay
export async function queryOrder(outTradeNo: string): Promise<EasyPayQueryResponse> {
const env = assertEasyPayEnv(getEnv());
const url = `${env.EASY_PAY_API_BASE}/api.php?act=order&pid=${env.EASY_PAY_PID}&key=${env.EASY_PAY_PKEY}&out_trade_no=${outTradeNo}`;
const response = await fetch(url, {
// 使用 POST 避免密钥暴露在 URL 中URL 会被记录到服务器/CDN 日志)
const params = new URLSearchParams({
act: 'order',
pid: env.EASY_PAY_PID,
key: env.EASY_PAY_PKEY,
out_trade_no: outTradeNo,
});
const response = await fetch(`${env.EASY_PAY_API_BASE}/api.php`, {
method: 'POST',
body: params,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
signal: AbortSignal.timeout(10_000),
});
const data = (await response.json()) as EasyPayQueryResponse;

View File

@@ -70,10 +70,21 @@ export class EasyPayProvider implements PaymentProvider {
throw new Error('EasyPay notification signature verification failed');
}
// 校验 pid 与配置一致,防止跨商户回调注入
if (params.pid && params.pid !== env.EASY_PAY_PID) {
throw new Error(`EasyPay notification pid mismatch: expected ${env.EASY_PAY_PID}, got ${params.pid}`);
}
// 校验金额为有限正数
const amount = parseFloat(params.money || '0');
if (!Number.isFinite(amount) || amount <= 0) {
throw new Error(`EasyPay notification invalid amount: ${params.money}`);
}
return {
tradeNo: params.trade_no || '',
orderId: params.out_trade_no || '',
amount: parseFloat(params.money || '0'),
amount,
status: params.trade_status === 'TRADE_SUCCESS' ? 'success' : 'failed',
rawData: params,
};

View File

@@ -1,6 +1,9 @@
import crypto from 'crypto';
export function generateRechargeCode(orderId: string): string {
const prefix = 's2p_';
const maxIdLength = 32 - prefix.length; // 28
const truncatedId = orderId.slice(0, maxIdLength);
return `${prefix}${truncatedId}`;
const random = crypto.randomBytes(4).toString('hex'); // 8 chars
const maxIdLength = 32 - prefix.length - random.length; // 16
const truncatedId = orderId.replace(/-/g, '').slice(0, maxIdLength);
return `${prefix}${truncatedId}${random}`;
}

View File

@@ -127,7 +127,16 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
throw new OrderError('USER_INACTIVE', message(locale, '用户账号已被禁用', 'User account is disabled'), 422);
}
const pendingCount = await prisma.order.count({
const feeRate = getMethodFeeRate(input.paymentType);
const payAmountStr = calculatePayAmount(input.amount, feeRate);
const payAmountNum = Number(payAmountStr);
const expiresAt = new Date(Date.now() + env.ORDER_TIMEOUT_MINUTES * 60 * 1000);
// 将限额校验与订单创建放在同一个 serializable 事务中,防止并发突破限额
const order = await prisma.$transaction(async (tx) => {
// 待支付订单数限制
const pendingCount = await tx.order.count({
where: { userId: input.userId, status: ORDER_STATUS.PENDING },
});
if (pendingCount >= MAX_PENDING_ORDERS) {
@@ -144,7 +153,7 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
// 每日累计充值限额校验0 = 不限制)
if (env.MAX_DAILY_RECHARGE_AMOUNT > 0) {
const dailyAgg = await prisma.order.aggregate({
const dailyAgg = await tx.order.aggregate({
where: {
userId: input.userId,
status: { in: [ORDER_STATUS.PAID, ORDER_STATUS.RECHARGING, ORDER_STATUS.COMPLETED] },
@@ -170,7 +179,7 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
// 渠道每日全平台限额校验0 = 不限)
const methodDailyLimit = getMethodDailyLimit(input.paymentType);
if (methodDailyLimit > 0) {
const methodAgg = await prisma.order.aggregate({
const methodAgg = await tx.order.aggregate({
where: {
paymentType: input.paymentType,
status: { in: [ORDER_STATUS.PAID, ORDER_STATUS.RECHARGING, ORDER_STATUS.COMPLETED] },
@@ -199,12 +208,6 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
}
}
const feeRate = getMethodFeeRate(input.paymentType);
const payAmountStr = calculatePayAmount(input.amount, feeRate);
const payAmountNum = Number(payAmountStr);
const expiresAt = new Date(Date.now() + env.ORDER_TIMEOUT_MINUTES * 60 * 1000);
const order = await prisma.$transaction(async (tx) => {
const created = await tx.order.create({
data: {
userId: input.userId,
@@ -243,8 +246,8 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
initPaymentProviders();
const provider = paymentRegistry.getProvider(input.paymentType);
const statusAccessToken = createOrderStatusAccessToken(order.id);
const orderResultUrl = buildOrderResultUrl(env.NEXT_PUBLIC_APP_URL, order.id);
const statusAccessToken = createOrderStatusAccessToken(order.id, input.userId);
const orderResultUrl = buildOrderResultUrl(env.NEXT_PUBLIC_APP_URL, order.id, input.userId);
// 只有 easypay 从外部传入 notifyUrlreturn_url 统一回到带访问令牌的结果页
let notifyUrl: string | undefined;
@@ -1002,7 +1005,10 @@ export async function processRefund(input: RefundInput): Promise<RefundResult> {
`sub2apipay:refund-rollback:${order.id}`,
);
} catch (rollbackError) {
// 余额恢复也失败,记录审计日志,需人工介入
// 余额恢复也失败,记录审计日志并标记需要补偿,便于定时任务或管理员重试
console.error(
`[CRITICAL] Refund rollback failed for order ${input.orderId}: balance deducted ${rechargeAmount} but gateway refund and balance restoration both failed. Manual intervention required.`,
);
await prisma.auditLog.create({
data: {
orderId: input.orderId,
@@ -1011,6 +1017,7 @@ export async function processRefund(input: RefundInput): Promise<RefundResult> {
gatewayError: gatewayError instanceof Error ? gatewayError.message : String(gatewayError),
rollbackError: rollbackError instanceof Error ? rollbackError.message : String(rollbackError),
rechargeAmount,
needsBalanceCompensation: true,
}),
operator: 'admin',
},

View File

@@ -2,25 +2,48 @@ import crypto from 'crypto';
import { getEnv } from '@/lib/config';
export const ORDER_STATUS_ACCESS_QUERY_KEY = 'access_token';
const ORDER_STATUS_ACCESS_PURPOSE = 'order-status-access:v1';
const ORDER_STATUS_ACCESS_PURPOSE = 'order-status-access:v2';
/** access_token 有效期24 小时) */
const TOKEN_TTL_MS = 24 * 60 * 60 * 1000;
function buildSignature(orderId: string): string {
/** 使用独立派生密钥,不直接使用 ADMIN_TOKEN */
function deriveKey(): string {
return crypto.createHmac('sha256', getEnv().ADMIN_TOKEN).update('order-status-access-key').digest('hex');
}
function buildSignature(orderId: string, userId: number, expiresAt: number): string {
return crypto
.createHmac('sha256', getEnv().ADMIN_TOKEN)
.update(`${ORDER_STATUS_ACCESS_PURPOSE}:${orderId}`)
.createHmac('sha256', deriveKey())
.update(`${ORDER_STATUS_ACCESS_PURPOSE}:${orderId}:${userId}:${expiresAt}`)
.digest('base64url');
}
export function createOrderStatusAccessToken(orderId: string): string {
return buildSignature(orderId);
/** 生成格式: {expiresAt}.{userId}.{signature} */
export function createOrderStatusAccessToken(orderId: string, userId?: number): string {
const expiresAt = Date.now() + TOKEN_TTL_MS;
const uid = userId ?? 0;
const sig = buildSignature(orderId, uid, expiresAt);
return `${expiresAt}.${uid}.${sig}`;
}
export function verifyOrderStatusAccessToken(orderId: string, token: string | null | undefined): boolean {
if (!token) return false;
const expected = buildSignature(orderId);
const parts = token.split('.');
if (parts.length !== 3) return false;
const [expiresAtStr, userIdStr, sig] = parts;
const expiresAt = Number(expiresAtStr);
const userId = Number(userIdStr);
if (!Number.isFinite(expiresAt) || !Number.isFinite(userId)) return false;
// 检查过期
if (Date.now() > expiresAt) return false;
const expected = buildSignature(orderId, userId, expiresAt);
const expectedBuffer = Buffer.from(expected, 'utf8');
const receivedBuffer = Buffer.from(token, 'utf8');
const receivedBuffer = Buffer.from(sig, 'utf8');
if (expectedBuffer.length !== receivedBuffer.length) {
return false;
@@ -29,9 +52,9 @@ export function verifyOrderStatusAccessToken(orderId: string, token: string | nu
return crypto.timingSafeEqual(expectedBuffer, receivedBuffer);
}
export function buildOrderResultUrl(appUrl: string, orderId: string): string {
export function buildOrderResultUrl(appUrl: string, orderId: string, userId?: number): string {
const url = new URL('/pay/result', appUrl);
url.searchParams.set('order_id', orderId);
url.searchParams.set(ORDER_STATUS_ACCESS_QUERY_KEY, createOrderStatusAccessToken(orderId));
url.searchParams.set(ORDER_STATUS_ACCESS_QUERY_KEY, createOrderStatusAccessToken(orderId, userId));
return url.toString();
}

View File

@@ -3,9 +3,12 @@ import { ORDER_STATUS } from '@/lib/constants';
import { cancelOrderCore } from './service';
const INTERVAL_MS = 30_000; // 30 seconds
const BATCH_SIZE = 50;
let timer: ReturnType<typeof setInterval> | null = null;
export async function expireOrders(): Promise<number> {
// 查询到期订单(限制批次大小防止内存爆炸)
// cancelOrderCore 内部 WHERE status='PENDING' 的 CAS 保证多实例不会重复处理同一订单
const orders = await prisma.order.findMany({
where: {
status: ORDER_STATUS.PENDING,
@@ -16,6 +19,8 @@ export async function expireOrders(): Promise<number> {
paymentTradeNo: true,
paymentType: true,
},
take: BATCH_SIZE,
orderBy: { expiresAt: 'asc' },
});
if (orders.length === 0) return 0;

View File

@@ -17,6 +17,9 @@ function assertWxpayEnv(env: ReturnType<typeof getEnv>) {
'Wxpay environment variables (WXPAY_APP_ID, WXPAY_MCH_ID, WXPAY_PRIVATE_KEY, WXPAY_API_V3_KEY) are required',
);
}
if (env.WXPAY_API_V3_KEY.length !== 32) {
throw new Error(`WXPAY_API_V3_KEY must be exactly 32 bytes for AES-256-GCM, got ${env.WXPAY_API_V3_KEY.length}`);
}
return env as typeof env & {
WXPAY_APP_ID: string;
WXPAY_MCH_ID: string;

View File

@@ -117,8 +117,9 @@ export class WxpayProvider implements PaymentProvider {
}
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - Number(timestamp)) > 300) {
throw new Error('Wechatpay notification timestamp expired');
const tsNum = Number(timestamp);
if (!Number.isFinite(tsNum) || Math.abs(now - tsNum) > 300) {
throw new Error('Wechatpay notification timestamp invalid or expired');
}
const valid = await verifyNotifySign({ timestamp, nonce, body, serial, signature });