1
0
mirror of synced 2026-10-13 10:36:46 +08:00

🐛 #4133 【微信支付】V3 应答验签增加时间戳新鲜度校验,防止重放

This commit is contained in:
sisyfyZhang
2026-10-10 12:16:13 +11:00
committed by GitHub
parent 6b4a3d3e78
commit 11bff8384b
3 changed files with 232 additions and 2 deletions

View File

@@ -11,16 +11,58 @@ import org.apache.http.util.EntityUtils;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.TimeUnit;
import java.util.function.LongSupplier;
/**
* @author spvycf & F00lish
*/
@Slf4j
public class WxPayValidator implements Validator {
private final Verifier verifier;
/**
* 默认允许的应答时间戳偏差(秒),与微信支付官方 SDK 保持一致:5 分钟。
*/
public static final long DEFAULT_TIMESTAMP_TOLERANCE_SECONDS = TimeUnit.MINUTES.toSeconds(5);
private final Verifier verifier;
private final long timestampToleranceSeconds;
private final LongSupplier currentTimeSeconds;
/**
* 使用默认的时间戳容差({@link #DEFAULT_TIMESTAMP_TOLERANCE_SECONDS})构造校验器。
*
* @param verifier 平台证书验签器
*/
public WxPayValidator(Verifier verifier) {
this(verifier, DEFAULT_TIMESTAMP_TOLERANCE_SECONDS);
}
/**
* 构造校验器并指定允许的应答时间戳偏差。
* <p>
* 校验签名之前会先比较应答头 Wechatpay-Timestamp 与本地时间,
* 二者之差(绝对值)超过容差的应答视为验签失败,以防止重放攻击。
* </p>
*
* @param verifier 平台证书验签器
* @param timestampToleranceSeconds 允许的时间戳偏差(秒);传 0 或负数表示关闭时间戳校验,
* 仅建议在本地时钟确实无法与微信服务器保持同步的环境中使用
*/
public WxPayValidator(Verifier verifier, long timestampToleranceSeconds) {
this(verifier, timestampToleranceSeconds, () -> TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis()));
}
/**
* 供单元测试注入固定时钟使用。
*
* @param verifier 平台证书验签器
* @param timestampToleranceSeconds 允许的时间戳偏差(秒),0 或负数表示关闭校验
* @param currentTimeSeconds 当前时间(秒)的来源
*/
WxPayValidator(Verifier verifier, long timestampToleranceSeconds, LongSupplier currentTimeSeconds) {
this.verifier = verifier;
this.timestampToleranceSeconds = timestampToleranceSeconds;
this.currentTimeSeconds = currentTimeSeconds;
}
@Override
@@ -34,15 +76,52 @@ public class WxPayValidator implements Validator {
Header timestamp = response.getFirstHeader("Wechatpay-TimeStamp");
Header nonce = response.getFirstHeader("Wechatpay-Nonce");
// todo: check timestamp
if (timestamp == null || nonce == null || serialNo == null || sign == null) {
return false;
}
if (!isTimestampFresh(timestamp.getValue())) {
return false;
}
String message = buildMessage(response);
return verifier.verify(serialNo.getValue(), message.getBytes(StandardCharsets.UTF_8), sign.getValue());
}
/**
* 校验应答时间戳是否在允许的偏差范围内。
*
* @param timestamp 应答头 Wechatpay-Timestamp 的值(秒级时间戳)
* @return 时间戳合法且与本地时间之差不超过容差时返回 true;容差为 0 或负数时恒为 true
*/
private boolean isTimestampFresh(String timestamp) {
if (this.timestampToleranceSeconds <= 0) {
return true;
}
final long responseTime;
try {
responseTime = Long.parseLong(timestamp.trim());
} catch (NumberFormatException e) {
log.warn("微信支付应答时间戳格式非法,拒绝该应答: Wechatpay-Timestamp={}", timestamp);
return false;
}
// 秒级 Unix 时间戳不可能为负;先拒绝负值,后面的减法和 Math.abs 才不会溢出
if (responseTime < 0) {
log.warn("微信支付应答时间戳为负数,拒绝该应答: Wechatpay-Timestamp={}", timestamp);
return false;
}
long offset = Math.abs(this.currentTimeSeconds.getAsLong() - responseTime);
if (offset > this.timestampToleranceSeconds) {
log.warn("微信支付应答时间戳超出允许范围,拒绝该应答以防止重放: Wechatpay-Timestamp={}, 与本地时间相差 {} 秒, 允许偏差 {} 秒",
timestamp, offset, this.timestampToleranceSeconds);
return false;
}
return true;
}
protected final String buildMessage(CloseableHttpResponse response) throws IOException {
String timestamp = response.getFirstHeader("Wechatpay-TimeStamp").getValue();
String nonce = response.getFirstHeader("Wechatpay-Nonce").getValue();

View File

@@ -0,0 +1,150 @@
package com.github.binarywang.wxpay.v3.auth;
import org.apache.http.ProtocolVersion;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.entity.ContentType;
import org.apache.http.entity.StringEntity;
import org.apache.http.message.BasicHttpResponse;
import org.apache.http.message.BasicStatusLine;
import org.testng.annotations.Test;
import java.io.IOException;
import java.security.cert.X509Certificate;
import java.util.concurrent.TimeUnit;
import static org.testng.Assert.assertFalse;
import static org.testng.Assert.assertTrue;
/**
* 测试 WxPayValidator 对应答时间戳的新鲜度校验(防重放)。
*
* @author sisyfy-Zhang
*/
public class WxPayValidatorTest {
/**
* 固定的"当前时间"(秒),便于构造超前/滞后的应答时间戳
*/
private static final long NOW_SECONDS = 1_700_000_000L;
private static final long FIVE_MINUTES = TimeUnit.MINUTES.toSeconds(5);
private static final long SIX_MINUTES = TimeUnit.MINUTES.toSeconds(6);
private static final long TEN_MINUTES = TimeUnit.MINUTES.toSeconds(10);
/**
* 最简 CloseableHttpResponse 实现,仅用于单元测试
*/
private static class StubCloseableHttpResponse extends BasicHttpResponse implements CloseableHttpResponse {
StubCloseableHttpResponse() {
super(new BasicStatusLine(new ProtocolVersion("HTTP", 1, 1), 200, "OK"));
}
@Override
public void close() {
}
}
/**
* 永远验签通过的 Verifier,用于把测试聚焦在时间戳校验上
*/
private static final Verifier ALWAYS_VALID = new Verifier() {
@Override
public boolean verify(String serialNumber, byte[] message, String signature) {
return true;
}
@Override
public X509Certificate getValidCertificate() {
return null;
}
};
private static WxPayValidator validator(long toleranceSeconds) {
return new WxPayValidator(ALWAYS_VALID, toleranceSeconds, () -> NOW_SECONDS);
}
private static CloseableHttpResponse jsonResponse(String timestamp) {
StubCloseableHttpResponse response = new StubCloseableHttpResponse();
response.setHeader("Content-Type", ContentType.APPLICATION_JSON.toString());
if (timestamp != null) {
response.setHeader("Wechatpay-Timestamp", timestamp);
}
response.setHeader("Wechatpay-Nonce", "nonce");
response.setHeader("Wechatpay-Signature", "signature");
response.setHeader("Wechatpay-Serial", "SERIAL");
response.setEntity(new StringEntity("{\"code\":\"OK\"}", ContentType.APPLICATION_JSON));
return response;
}
@Test
public void testFreshTimestampPasses() throws IOException {
assertTrue(validator(FIVE_MINUTES).validate(jsonResponse(String.valueOf(NOW_SECONDS))));
}
@Test
public void testTimestampAtToleranceBoundaryPasses() throws IOException {
assertTrue(validator(FIVE_MINUTES).validate(jsonResponse(String.valueOf(NOW_SECONDS - FIVE_MINUTES))));
assertTrue(validator(FIVE_MINUTES).validate(jsonResponse(String.valueOf(NOW_SECONDS + FIVE_MINUTES))));
}
@Test
public void testStaleTimestampIsRejected() throws IOException {
assertFalse(validator(FIVE_MINUTES).validate(jsonResponse(String.valueOf(NOW_SECONDS - SIX_MINUTES))),
"滞后超过容差的应答应被判定为验签失败");
}
@Test
public void testFutureTimestampIsRejected() throws IOException {
assertFalse(validator(FIVE_MINUTES).validate(jsonResponse(String.valueOf(NOW_SECONDS + SIX_MINUTES))),
"超前超过容差的应答应被判定为验签失败");
}
@Test
public void testNonNumericTimestampIsRejected() throws IOException {
assertFalse(validator(FIVE_MINUTES).validate(jsonResponse("not-a-number")));
}
@Test
public void testNegativeTimestampIsRejected() throws IOException {
assertFalse(validator(FIVE_MINUTES).validate(jsonResponse("-1")));
// 差值会溢出成 Long.MIN_VALUE,Math.abs 仍为负数;若不先拒绝负值,该时间戳会被误判为新鲜
long overflowing = Long.MIN_VALUE + NOW_SECONDS;
assertFalse(validator(FIVE_MINUTES).validate(jsonResponse(String.valueOf(overflowing))),
"导致减法溢出的时间戳必须被拒绝");
}
@Test
public void testMissingTimestampHeaderIsRejected() throws IOException {
assertFalse(validator(FIVE_MINUTES).validate(jsonResponse(null)));
}
@Test
public void testCustomToleranceIsHonoured() throws IOException {
assertTrue(validator(TEN_MINUTES).validate(jsonResponse(String.valueOf(NOW_SECONDS - SIX_MINUTES))),
"容差放宽到 10 分钟后,滞后 6 分钟的应答应通过");
}
@Test
public void testZeroToleranceDisablesTimestampCheck() throws IOException {
assertTrue(validator(0).validate(jsonResponse(String.valueOf(NOW_SECONDS - TEN_MINUTES))),
"容差为 0 表示关闭时间戳校验");
assertTrue(validator(0).validate(jsonResponse("not-a-number")),
"关闭校验后不再解析时间戳");
}
@Test
public void testDefaultConstructorUsesFiveMinutes() throws IOException {
long nowSeconds = TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis());
WxPayValidator validator = new WxPayValidator(ALWAYS_VALID);
assertTrue(validator.validate(jsonResponse(String.valueOf(nowSeconds))));
assertFalse(validator.validate(jsonResponse(String.valueOf(nowSeconds - SIX_MINUTES))));
}
@Test
public void testNonJsonResponseSkipsValidation() throws IOException {
StubCloseableHttpResponse response = new StubCloseableHttpResponse();
response.setHeader("Content-Type", ContentType.APPLICATION_OCTET_STREAM.toString());
// 非 JSON 应答(如下载账单)不做验签,这是既有行为,时间戳校验不应改变它
assertTrue(validator(FIVE_MINUTES).validate(response));
}
}

View File

@@ -53,6 +53,7 @@
<class name="com.github.binarywang.wxpay.util.SignUtilsTest"/>
<class name="com.github.binarywang.wxpay.v3.SignatureExecTrustedHostTest"/>
<class name="com.github.binarywang.wxpay.v3.auth.AutoUpdateCertificatesVerifierPublicKeyModeTest"/>
<class name="com.github.binarywang.wxpay.v3.auth.WxPayValidatorTest"/>
<class name="com.github.binarywang.wxpay.v3.util.RsaCryptoUtilTest"/>
</classes>
</test>