From 732da3cc4640767f73ab55ebf8311f1ee0e3fe83 Mon Sep 17 00:00:00 2001 From: sisyfy-Zhang Date: Fri, 9 Oct 2026 16:49:13 +1100 Subject: [PATCH 1/2] =?UTF-8?q?:bug:=20#4133=20=E3=80=90=E5=BE=AE=E4=BF=A1?= =?UTF-8?q?=E6=94=AF=E4=BB=98=E3=80=91V3=20=E5=BA=94=E7=AD=94=E9=AA=8C?= =?UTF-8?q?=E7=AD=BE=E5=A2=9E=E5=8A=A0=E6=97=B6=E9=97=B4=E6=88=B3=E6=96=B0?= =?UTF-8?q?=E9=B2=9C=E5=BA=A6=E6=A0=A1=E9=AA=8C=EF=BC=8C=E9=98=B2=E6=AD=A2?= =?UTF-8?q?=E9=87=8D=E6=94=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../wxpay/v3/auth/WxPayValidator.java | 75 +++++++++- .../wxpay/v3/auth/WxPayValidatorTest.java | 141 ++++++++++++++++++ weixin-java-pay/src/test/resources/testng.xml | 1 + 3 files changed, 216 insertions(+), 1 deletion(-) create mode 100644 weixin-java-pay/src/test/java/com/github/binarywang/wxpay/v3/auth/WxPayValidatorTest.java diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/v3/auth/WxPayValidator.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/v3/auth/WxPayValidator.java index cc88caf758..f94f23fcd1 100644 --- a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/v3/auth/WxPayValidator.java +++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/v3/auth/WxPayValidator.java @@ -11,16 +11,58 @@ 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 { + /** + * 默认允许的应答时间戳偏差(秒),与微信支付官方 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); + } + + /** + * 构造校验器并指定允许的应答时间戳偏差。 + *

+ * 校验签名之前会先比较应答头 Wechatpay-Timestamp 与本地时间, + * 二者之差(绝对值)超过容差的应答视为验签失败,以防止重放攻击。 + *

+ * + * @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,46 @@ public final boolean validate(CloseableHttpResponse response) throws IOException 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; + } + + 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(); diff --git a/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/v3/auth/WxPayValidatorTest.java b/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/v3/auth/WxPayValidatorTest.java new file mode 100644 index 0000000000..70891a68c7 --- /dev/null +++ b/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/v3/auth/WxPayValidatorTest.java @@ -0,0 +1,141 @@ +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 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)); + } +} diff --git a/weixin-java-pay/src/test/resources/testng.xml b/weixin-java-pay/src/test/resources/testng.xml index 180aa0821a..f4f94f0596 100644 --- a/weixin-java-pay/src/test/resources/testng.xml +++ b/weixin-java-pay/src/test/resources/testng.xml @@ -53,6 +53,7 @@ + From 3e1d0d6ffe6019912b66b2d71f766efd46a4b9b1 Mon Sep 17 00:00:00 2001 From: sisyfy-Zhang Date: Fri, 9 Oct 2026 19:44:49 +1100 Subject: [PATCH 2/2] =?UTF-8?q?:bug:=20#4133=20=E3=80=90=E5=BE=AE=E4=BF=A1?= =?UTF-8?q?=E6=94=AF=E4=BB=98=E3=80=91=E6=8B=92=E7=BB=9D=E8=B4=9F=E6=95=B0?= =?UTF-8?q?=E5=BA=94=E7=AD=94=E6=97=B6=E9=97=B4=E6=88=B3=EF=BC=8C=E9=81=BF?= =?UTF-8?q?=E5=85=8D=E6=BA=A2=E5=87=BA=E7=BB=95=E8=BF=87=E6=96=B0=E9=B2=9C?= =?UTF-8?q?=E5=BA=A6=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- .../github/binarywang/wxpay/v3/auth/WxPayValidator.java | 6 ++++++ .../binarywang/wxpay/v3/auth/WxPayValidatorTest.java | 9 +++++++++ 2 files changed, 15 insertions(+) diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/v3/auth/WxPayValidator.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/v3/auth/WxPayValidator.java index f94f23fcd1..d636a56bb1 100644 --- a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/v3/auth/WxPayValidator.java +++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/v3/auth/WxPayValidator.java @@ -107,6 +107,12 @@ private boolean isTimestampFresh(String 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={}, 与本地时间相差 {} 秒, 允许偏差 {} 秒", diff --git a/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/v3/auth/WxPayValidatorTest.java b/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/v3/auth/WxPayValidatorTest.java index 70891a68c7..d057b276e8 100644 --- a/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/v3/auth/WxPayValidatorTest.java +++ b/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/v3/auth/WxPayValidatorTest.java @@ -104,6 +104,15 @@ 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)));