Skip to content

Commit a91a014

Browse files
authored
🎨 #4130 【小程序】【开放平台】优化代码,防止 code2Session 凭证通过异常和日志泄露
1 parent db880bb commit a91a014

15 files changed

Lines changed: 755 additions & 26 deletions

File tree

‎pom.xml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,8 @@
144144
<jetty.version>9.4.57.v20241219</jetty.version> <!-- 这个不能用10以上的版本,不支持jdk8-->
145145
<bouncycastle.version>1.85</bouncycastle.version>
146146
<spring-data-redis.version>2.3.3.RELEASE</spring-data-redis.version>
147+
<!-- Binding used by log-capture tests against the production SLF4J 1.7 API. -->
148+
<logback-slf4j1-test.version>1.2.13</logback-slf4j1-test.version>
147149
</properties>
148150
<dependencyManagement>
149151
<dependencies>
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package me.chanjar.weixin.common.util.http;
2+
3+
import java.io.UnsupportedEncodingException;
4+
import java.net.URLEncoder;
5+
import java.nio.charset.StandardCharsets;
6+
import me.chanjar.weixin.common.error.WxError;
7+
import me.chanjar.weixin.common.error.WxErrorException;
8+
import me.chanjar.weixin.common.error.WxRuntimeException;
9+
10+
/**
11+
* Safe parameter and exception handling for requests containing credentials.
12+
*/
13+
public final class SensitiveRequestUtils {
14+
private SensitiveRequestUtils() {
15+
}
16+
17+
/**
18+
* Encodes one raw query parameter value, without interpreting existing percent escapes.
19+
*
20+
* @param value raw, non-null parameter value
21+
* @return UTF-8 form-encoded value
22+
* @throws NullPointerException if the value is null
23+
*/
24+
public static String encodeQueryValue(String value) {
25+
try {
26+
return URLEncoder.encode(value, StandardCharsets.UTF_8.name());
27+
} catch (UnsupportedEncodingException e) {
28+
throw new IllegalStateException("UTF-8 is not available");
29+
}
30+
}
31+
32+
/**
33+
* Retains the WeChat error code and stack frames without exposing response data or causes.
34+
* This is intended for credential-bearing entry points, not general exception conversion.
35+
*
36+
* @param failure original failure
37+
* @return safe exception without the original message, JSON, cause or suppressed exceptions
38+
*/
39+
public static WxErrorException sanitize(WxErrorException failure) {
40+
WxErrorException safe = new WxErrorException(new WxError(failure.getError().getErrorCode(),
41+
"Sensitive request failed"));
42+
safe.setStackTrace(failure.getStackTrace());
43+
return safe;
44+
}
45+
46+
/**
47+
* Removes request data from a runtime failure. Common argument, state and null failures
48+
* keep their categories; other runtime failures become {@link WxRuntimeException}.
49+
* Original exception class names and stack frames remain available for diagnosis.
50+
*
51+
* @param failure original failure
52+
* @return safe exception without the original message, cause or suppressed exceptions
53+
*/
54+
public static RuntimeException sanitize(RuntimeException failure) {
55+
String message = "Sensitive request failed (" + failure.getClass().getName() + ")";
56+
RuntimeException safe;
57+
if (failure instanceof IllegalArgumentException) {
58+
safe = new IllegalArgumentException(message);
59+
} else if (failure instanceof IllegalStateException) {
60+
safe = new IllegalStateException(message);
61+
} else if (failure instanceof NullPointerException) {
62+
safe = new NullPointerException(message);
63+
} else {
64+
safe = new WxRuntimeException(message);
65+
}
66+
safe.setStackTrace(failure.getStackTrace());
67+
return safe;
68+
}
69+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package me.chanjar.weixin.common.util.http;
2+
3+
import java.io.PrintWriter;
4+
import java.io.StringWriter;
5+
import java.net.URLDecoder;
6+
import me.chanjar.weixin.common.error.WxError;
7+
import me.chanjar.weixin.common.error.WxErrorException;
8+
import me.chanjar.weixin.common.error.WxRuntimeException;
9+
import org.testng.annotations.Test;
10+
11+
import static org.testng.Assert.*;
12+
13+
public class SensitiveRequestUtilsTest {
14+
@Test
15+
public void encodesRawValuesExactlyOnce() throws Exception {
16+
for (String value : new String[]{"NORMAL_CODE", "", "a b\n", "+&=#?%2F", "中文\uD83D\uDE00"}) {
17+
String encoded = SensitiveRequestUtils.encodeQueryValue(value);
18+
assertEquals(URLDecoder.decode(encoded, "UTF-8"), value);
19+
assertFalse(encoded.contains("&"));
20+
assertFalse(encoded.contains("#"));
21+
}
22+
assertEquals(SensitiveRequestUtils.encodeQueryValue("%2F"), "%252F");
23+
}
24+
25+
@Test(expectedExceptions = NullPointerException.class)
26+
public void doesNotConvertNullToAValue() {
27+
SensitiveRequestUtils.encodeQueryValue(null);
28+
}
29+
30+
@Test
31+
public void removesAllOriginalErrorRepresentations() {
32+
WxError error = WxError.builder().errorCode(40029).errorMsg("FAKE_SECRET")
33+
.errorMsgEn("FAKE_SECRET").json("FAKE_SECRET").build();
34+
WxErrorException original = new WxErrorException(error, new IllegalArgumentException("FAKE_SECRET"));
35+
original.addSuppressed(new IllegalStateException("FAKE_SECRET"));
36+
WxErrorException safe = SensitiveRequestUtils.sanitize(original);
37+
assertEquals(safe.getError().getErrorCode(), 40029);
38+
assertNull(safe.getError().getJson());
39+
assertNull(safe.getError().getErrorMsgEn());
40+
assertSafe(safe, original);
41+
assertEquals(original.getError().getJson(), "FAKE_SECRET");
42+
}
43+
44+
@Test
45+
public void retainsCommonRuntimeCategoriesWithoutCauses() {
46+
for (RuntimeException original : new RuntimeException[]{new IllegalArgumentException("FAKE_SECRET"),
47+
new IllegalStateException("FAKE_SECRET"), new NullPointerException("FAKE_SECRET"),
48+
new WxRuntimeException("FAKE_SECRET")}) {
49+
original.initCause(new RuntimeException("FAKE_SECRET"));
50+
original.addSuppressed(new RuntimeException("FAKE_SECRET"));
51+
RuntimeException safe = SensitiveRequestUtils.sanitize(original);
52+
assertEquals(safe.getClass(), original.getClass());
53+
assertSafe(safe, original);
54+
}
55+
assertTrue(SensitiveRequestUtils.sanitize(new UnsupportedOperationException("FAKE_SECRET"))
56+
instanceof WxRuntimeException);
57+
}
58+
59+
private void assertSafe(Throwable safe, Throwable original) {
60+
StringWriter trace = new StringWriter();
61+
safe.printStackTrace(new PrintWriter(trace));
62+
assertFalse(trace.toString().contains("FAKE_SECRET"));
63+
assertNull(safe.getCause());
64+
assertEquals(safe.getSuppressed().length, 0);
65+
assertEquals(safe.getStackTrace(), original.getStackTrace());
66+
}
67+
}

‎weixin-java-common/src/test/resources/testng.xml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
<suite name="Weixin-java-tool-suite" verbose="1">
44
<test name="Bean_Test">
55
<classes>
6+
<class name="me.chanjar.weixin.common.util.http.SensitiveRequestUtilsTest"/>
67
<class name="me.chanjar.weixin.common.bean.WxAccessTokenTest"/>
78
<class name="me.chanjar.weixin.common.error.WxErrorTest"/>
89
<class name="me.chanjar.weixin.common.bean.WxMenuTest"/>

‎weixin-java-miniapp/pom.xml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
<dependency>
5454
<groupId>ch.qos.logback</groupId>
5555
<artifactId>logback-classic</artifactId>
56+
<version>${logback-slf4j1-test.version}</version>
5657
<scope>test</scope>
5758
</dependency>
5859
<dependency>

‎weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/api/WxMaService.java‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ public interface WxMaService extends WxService {
3838

3939
/**
4040
* 获取登录后的 session 信息。
41+
* 登录参数按原始值传入,无需 URL 编码。为防止泄露凭证,失败异常保留错误码和栈帧,
42+
* 不包含原始请求、响应、cause 或 suppressed 异常;其他运行时异常可能转换为 WxRuntimeException。
4143
*
4244
* @param jsCode 登录时获取的 code
4345
* @return 登录 session 结果对象

‎weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/api/impl/BaseWxMaServiceImpl.java‎

Lines changed: 37 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -211,16 +211,22 @@ public String getPaidUnionId(String openid, String transactionId, String mchId,
211211

212212
@Override
213213
public WxMaJscode2SessionResult jsCode2SessionInfo(String jsCode) throws WxErrorException {
214-
final WxMaConfig config = getWxMaConfig();
215-
Map<String, String> params = new HashMap<>(8);
216-
params.put("appid", config.getAppid());
217-
params.put("secret", config.getSecret());
218-
params.put("js_code", jsCode);
219-
params.put("grant_type", "authorization_code");
220-
221-
String result =
222-
get(JSCODE_TO_SESSION_URL, Joiner.on("&").withKeyValueSeparator("=").join(params));
223-
return WxMaJscode2SessionResult.fromJson(result);
214+
try {
215+
final WxMaConfig config = getWxMaConfig();
216+
Map<String, String> params = new HashMap<>(8);
217+
params.put("appid", SensitiveRequestUtils.encodeQueryValue(config.getAppid()));
218+
params.put("secret", SensitiveRequestUtils.encodeQueryValue(config.getSecret()));
219+
params.put("js_code", SensitiveRequestUtils.encodeQueryValue(jsCode));
220+
params.put("grant_type", "authorization_code");
221+
222+
String result =
223+
get(JSCODE_TO_SESSION_URL, Joiner.on("&").withKeyValueSeparator("=").join(params));
224+
return WxMaJscode2SessionResult.fromJson(result);
225+
} catch (WxErrorException e) {
226+
throw SensitiveRequestUtils.sanitize(e);
227+
} catch (RuntimeException e) {
228+
throw SensitiveRequestUtils.sanitize(e);
229+
}
224230
}
225231

226232
@Override
@@ -390,7 +396,7 @@ private <R, T> R executeWithRetry(ExecutorAction<R> executor, String uri, String
390396
int retryTimes = 0;
391397
do {
392398
try {
393-
return this.executeInternal(executor, uri, dataForLog, false);
399+
return this.executeInternal(executor, uri, dataForLog, false, JSCODE_TO_SESSION_URL.equals(uri));
394400
} catch (WxErrorException e) {
395401
if (retryTimes + 1 > this.maxRetryTimes) {
396402
log.warn("重试达到最大次数【{}】", maxRetryTimes);
@@ -423,7 +429,8 @@ private <R, T> R executeWithRetry(ExecutorAction<R> executor, String uri, String
423429
}
424430

425431
private <R, T> R executeInternal(
426-
ExecutorAction<R> executor, String uri, String dataForLog, boolean doNotAutoRefreshToken)
432+
ExecutorAction<R> executor, String uri, String dataForLog, boolean doNotAutoRefreshToken,
433+
boolean code2Session)
427434
throws WxErrorException {
428435

429436
if (uri.contains("access_token=")) {
@@ -440,7 +447,11 @@ private <R, T> R executeInternal(
440447
uri + (uri.contains("?") ? "&" : "?") + "access_token=" + accessToken;
441448
try {
442449
R result = executor.execute(uriWithAccessToken);
443-
log.debug("\n【请求地址】: {}\n【请求参数】:{}\n【响应数据】:{}", uriWithAccessToken, dataForLog, result);
450+
if (code2Session) {
451+
log.debug("code2Session request completed");
452+
} else {
453+
log.debug("\n【请求地址】: {}\n【请求参数】:{}\n【响应数据】:{}", uriWithAccessToken, dataForLog, result);
454+
}
444455
return result;
445456
} catch (WxErrorException e) {
446457
WxError error = e.getError();
@@ -459,15 +470,18 @@ private <R, T> R executeInternal(
459470
}
460471
if (this.getWxMaConfig().autoRefreshToken() && !doNotAutoRefreshToken) {
461472
log.warn(
462-
"即将重新获取新的access_token,错误代码:{},错误信息:{}", error.getErrorCode(), error.getErrorMsg());
473+
"即将重新获取新的access_token,错误代码:{},错误信息:{}", error.getErrorCode(),
474+
code2Session ? "[redacted]" : error.getErrorMsg());
463475
// 下一次不再自动重试
464476
// 当小程序误调用第三方平台专属接口时,第三方无法使用小程序的access token,如果可以继续自动获取token会导致无限循环重试,直到栈溢出
465-
return this.executeInternal(executor, uri, dataForLog, true);
477+
return this.executeInternal(executor, uri, dataForLog, true, code2Session);
466478
}
467479
}
468480

469481
if (error.getErrorCode() != 0) {
470-
if (error.getErrorCode() == WxMaErrorMsgEnum.CODE_43101.getCode()) {
482+
if (code2Session) {
483+
log.warn("code2Session request failed, error code: {}", error.getErrorCode());
484+
} else if (error.getErrorCode() == WxMaErrorMsgEnum.CODE_43101.getCode()) {
471485
// 43101 日志太多, 打印为debug, 其他情况打印为warn
472486
log.debug("\n【请求地址】: {}\n【请求参数】:{}\n【错误信息】:{}", uriWithAccessToken, dataForLog, error);
473487
} else {
@@ -477,8 +491,12 @@ private <R, T> R executeInternal(
477491
}
478492
return null;
479493
} catch (IOException e) {
480-
log.warn(
481-
"\n【请求地址】: {}\n【请求参数】:{}\n【异常信息】:{}", uriWithAccessToken, dataForLog, e.getMessage());
494+
if (code2Session) {
495+
log.warn("code2Session request failed, exception type: {}", e.getClass().getName());
496+
} else {
497+
log.warn(
498+
"\n【请求地址】: {}\n【请求参数】:{}\n【异常信息】:{}", uriWithAccessToken, dataForLog, e.getMessage());
499+
}
482500
throw new WxRuntimeException(e);
483501
}
484502
}
@@ -491,7 +509,7 @@ private <R, T> R executeInternal(
491509
* @throws WxErrorException 异常
492510
*/
493511
protected String extractAccessToken(String resultContent) throws WxErrorException {
494-
log.debug("access-token response: {}", resultContent);
512+
log.debug("access-token response received");
495513
WxMaConfig config = this.getWxMaConfig();
496514
WxError error = WxError.fromJson(resultContent, WxType.MiniApp);
497515
if (error.getErrorCode() != 0) {

0 commit comments

Comments
 (0)