1
0
mirror of synced 2026-10-17 04:46:10 +08:00

🎨 #4130 【小程序】【开放平台】优化代码,防止 code2Session 凭证通过异常和日志泄露

This commit is contained in:
yearliny
2026-09-26 15:14:18 +08:00
committed by GitHub
parent db880bb0da
commit a91a0140fc
15 changed files with 754 additions and 25 deletions

View File

@@ -53,6 +53,7 @@
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>${logback-slf4j1-test.version}</version>
<scope>test</scope>
</dependency>
<dependency>

View File

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

View File

@@ -211,16 +211,22 @@ public abstract class BaseWxMaServiceImpl<H, P> implements WxMaService, RequestH
@Override
public WxMaJscode2SessionResult jsCode2SessionInfo(String jsCode) throws WxErrorException {
final WxMaConfig config = getWxMaConfig();
Map<String, String> params = new HashMap<>(8);
params.put("appid", config.getAppid());
params.put("secret", config.getSecret());
params.put("js_code", jsCode);
params.put("grant_type", "authorization_code");
try {
final WxMaConfig config = getWxMaConfig();
Map<String, String> params = new HashMap<>(8);
params.put("appid", SensitiveRequestUtils.encodeQueryValue(config.getAppid()));
params.put("secret", SensitiveRequestUtils.encodeQueryValue(config.getSecret()));
params.put("js_code", SensitiveRequestUtils.encodeQueryValue(jsCode));
params.put("grant_type", "authorization_code");
String result =
get(JSCODE_TO_SESSION_URL, Joiner.on("&").withKeyValueSeparator("=").join(params));
return WxMaJscode2SessionResult.fromJson(result);
String result =
get(JSCODE_TO_SESSION_URL, Joiner.on("&").withKeyValueSeparator("=").join(params));
return WxMaJscode2SessionResult.fromJson(result);
} catch (WxErrorException e) {
throw SensitiveRequestUtils.sanitize(e);
} catch (RuntimeException e) {
throw SensitiveRequestUtils.sanitize(e);
}
}
@Override
@@ -390,7 +396,7 @@ public abstract class BaseWxMaServiceImpl<H, P> implements WxMaService, RequestH
int retryTimes = 0;
do {
try {
return this.executeInternal(executor, uri, dataForLog, false);
return this.executeInternal(executor, uri, dataForLog, false, JSCODE_TO_SESSION_URL.equals(uri));
} catch (WxErrorException e) {
if (retryTimes + 1 > this.maxRetryTimes) {
log.warn("重试达到最大次数【{}】", maxRetryTimes);
@@ -423,7 +429,8 @@ public abstract class BaseWxMaServiceImpl<H, P> implements WxMaService, RequestH
}
private <R, T> R executeInternal(
ExecutorAction<R> executor, String uri, String dataForLog, boolean doNotAutoRefreshToken)
ExecutorAction<R> executor, String uri, String dataForLog, boolean doNotAutoRefreshToken,
boolean code2Session)
throws WxErrorException {
if (uri.contains("access_token=")) {
@@ -440,7 +447,11 @@ public abstract class BaseWxMaServiceImpl<H, P> implements WxMaService, RequestH
uri + (uri.contains("?") ? "&" : "?") + "access_token=" + accessToken;
try {
R result = executor.execute(uriWithAccessToken);
log.debug("\n【请求地址】: {}\n【请求参数】:{}\n【响应数据】:{}", uriWithAccessToken, dataForLog, result);
if (code2Session) {
log.debug("code2Session request completed");
} else {
log.debug("\n【请求地址】: {}\n【请求参数】:{}\n【响应数据】:{}", uriWithAccessToken, dataForLog, result);
}
return result;
} catch (WxErrorException e) {
WxError error = e.getError();
@@ -459,15 +470,18 @@ public abstract class BaseWxMaServiceImpl<H, P> implements WxMaService, RequestH
}
if (this.getWxMaConfig().autoRefreshToken() && !doNotAutoRefreshToken) {
log.warn(
"即将重新获取新的access_token,错误代码:{},错误信息:{}", error.getErrorCode(), error.getErrorMsg());
"即将重新获取新的access_token,错误代码:{},错误信息:{}", error.getErrorCode(),
code2Session ? "[redacted]" : error.getErrorMsg());
// 下一次不再自动重试
// 当小程序误调用第三方平台专属接口时,第三方无法使用小程序的access token,如果可以继续自动获取token会导致无限循环重试,直到栈溢出
return this.executeInternal(executor, uri, dataForLog, true);
return this.executeInternal(executor, uri, dataForLog, true, code2Session);
}
}
if (error.getErrorCode() != 0) {
if (error.getErrorCode() == WxMaErrorMsgEnum.CODE_43101.getCode()) {
if (code2Session) {
log.warn("code2Session request failed, error code: {}", error.getErrorCode());
} else if (error.getErrorCode() == WxMaErrorMsgEnum.CODE_43101.getCode()) {
// 43101 日志太多, 打印为debug, 其他情况打印为warn
log.debug("\n【请求地址】: {}\n【请求参数】:{}\n【错误信息】:{}", uriWithAccessToken, dataForLog, error);
} else {
@@ -477,8 +491,12 @@ public abstract class BaseWxMaServiceImpl<H, P> implements WxMaService, RequestH
}
return null;
} catch (IOException e) {
log.warn(
"\n【请求地址】: {}\n【请求参数】:{}\n【异常信息】:{}", uriWithAccessToken, dataForLog, e.getMessage());
if (code2Session) {
log.warn("code2Session request failed, exception type: {}", e.getClass().getName());
} else {
log.warn(
"\n【请求地址】: {}\n【请求参数】:{}\n【异常信息】:{}", uriWithAccessToken, dataForLog, e.getMessage());
}
throw new WxRuntimeException(e);
}
}
@@ -491,7 +509,7 @@ public abstract class BaseWxMaServiceImpl<H, P> implements WxMaService, RequestH
* @throws WxErrorException 异常
*/
protected String extractAccessToken(String resultContent) throws WxErrorException {
log.debug("access-token response: {}", resultContent);
log.debug("access-token response received");
WxMaConfig config = this.getWxMaConfig();
WxError error = WxError.fromJson(resultContent, WxType.MiniApp);
if (error.getErrorCode() != 0) {

View File

@@ -0,0 +1,297 @@
package cn.binarywang.wx.miniapp.api.impl;
import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.read.ListAppender;
import cn.binarywang.wx.miniapp.config.impl.WxMaDefaultConfigImpl;
import com.sun.net.httpserver.HttpServer;
import java.io.Closeable;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.net.InetSocketAddress;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import me.chanjar.weixin.common.enums.WxType;
import me.chanjar.weixin.common.error.WxError;
import me.chanjar.weixin.common.error.WxErrorException;
import me.chanjar.weixin.common.error.WxRuntimeException;
import me.chanjar.weixin.common.util.http.RequestExecutor;
import org.slf4j.LoggerFactory;
import org.testng.annotations.Test;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import static org.testng.Assert.*;
public class WxMaCode2SessionSecurityTest {
private static final String SECRET = "FAKE_APP_SECRET";
private static final String TOKEN = "FAKE_ACCESS_TOKEN";
private static final String SESSION = "FAKE_SESSION_KEY";
private static final String RESULT = "{\"openid\":\"FAKE_OPENID\",\"session_key\":\"" + SESSION + "\"}";
@Test
public void allHttpClientsSendEncodedValuesAndPreserveSession() throws Exception {
AtomicReference<String> query = new AtomicReference<>();
AtomicReference<String> response = new AtomicReference<>(RESULT);
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/sns/jscode2session", exchange -> {
query.set(exchange.getRequestURI().getRawQuery());
byte[] body = response.get().getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, body.length);
try (java.io.OutputStream out = exchange.getResponseBody()) {
out.write(body);
}
});
server.start();
try (LogCapture logs = new LogCapture()) {
BaseWxMaServiceImpl<?, ?>[] services = {new WxMaServiceImpl(), new WxMaServiceHttpComponentsImpl(),
new WxMaServiceOkHttpImpl(), new WxMaServiceJoddHttpImpl()};
for (BaseWxMaServiceImpl<?, ?> service : services) {
WxMaDefaultConfigImpl config = config();
config.setAppid("FAKE_APP_ID+&=中文");
config.setSecret(SECRET + "+&=中文\uD83D\uDE00");
config.setApiHostUrl("http://127.0.0.1:" + server.getAddress().getPort());
service.setWxMaConfig(config);
try {
response.set(RESULT);
for (String code : new String[]{"NORMAL_CODE", "a b\n", "a+&=#?%2F", "中文\uD83D\uDE00", ""}) {
assertEquals(service.jsCode2SessionInfo(code).getSessionKey(), SESSION);
Map<String, String> params = decode(query.get());
assertEquals(params.size(), 5);
assertEquals(params.get("js_code"), code);
assertEquals(params.get("secret"), config.getSecret());
assertEquals(params.get("appid"), config.getAppid());
assertEquals(params.get("access_token"), TOKEN);
}
response.set("{\"errcode\":40029,\"errmsg\":\"" + SECRET + TOKEN + "\"}");
try {
service.jsCode2SessionInfo("FAKE_CODE");
fail("Expected WeChat error");
} catch (WxErrorException e) {
assertEquals(e.getError().getErrorCode(), 40029);
assertSafe(e);
}
} finally {
Object client = service.getRequestHttpClient();
if (client instanceof Closeable) {
((Closeable) client).close();
} else if (client instanceof okhttp3.OkHttpClient) {
((okhttp3.OkHttpClient) client).connectionPool().evictAll();
((okhttp3.OkHttpClient) client).dispatcher().executorService().shutdown();
}
}
}
logs.assertSafe();
} finally {
server.stop(0);
}
}
@Test
public void errorsAndRetryLogsDoNotContainCredentials() throws Exception {
try (LogCapture logs = new LogCapture()) {
TestService service = service();
service.getWxMaConfig().setApiHostUrl("http://proxy.invalid");
when(service.executor.execute(anyString(), anyString(), eq(WxType.MiniApp)))
.thenThrow(error(40001)).thenThrow(error(-1)).thenReturn(RESULT);
assertEquals(service.jsCode2SessionInfo("FAKE_CODE").getSessionKey(), SESSION);
assertEquals(service.tokenRequests, 1);
verify(service.executor, times(3)).execute(startsWith("http://proxy.invalid/"), anyString(), eq(WxType.MiniApp));
for (Exception failure : new Exception[]{error(40029), new IOException(SECRET + TOKEN),
new IllegalArgumentException(SECRET + TOKEN), new IllegalStateException(SECRET + TOKEN)}) {
reset(service.executor);
when(service.executor.execute(anyString(), anyString(), eq(WxType.MiniApp))).thenThrow(failure);
try {
service.jsCode2SessionInfo("FAKE_CODE");
fail("Expected failure");
} catch (WxErrorException e) {
assertEquals(e.getError().getErrorCode(), 40029);
assertNull(e.getError().getJson());
assertSafe(e);
} catch (RuntimeException e) {
assertSafe(e);
}
}
logs.assertSafe();
}
}
@Test
public void tokenAndParsingFailuresAreProtectedAndNullRemainsNullFailure() throws Exception {
try (LogCapture logs = new LogCapture()) {
TestService service = service();
service.getWxMaConfig().expireAccessToken();
when(service.executor.execute(anyString(), anyString(), eq(WxType.MiniApp))).thenReturn(RESULT);
assertEquals(service.jsCode2SessionInfo("FAKE_CODE").getSessionKey(), SESSION);
assertEquals(service.tokenRequests, 1);
when(service.executor.execute(anyString(), anyString(), eq(WxType.MiniApp)))
.thenReturn("{\"session_key\":\"" + SESSION + "\",\"expires_in\":");
try {
service.jsCode2SessionInfo("FAKE_CODE");
fail("Expected malformed response failure");
} catch (WxRuntimeException e) {
assertSafe(e);
}
try {
service.jsCode2SessionInfo(null);
fail("Expected null input failure");
} catch (NullPointerException e) {
assertSafe(e);
}
service.getWxMaConfig().expireAccessToken();
service.failToken = true;
try {
service.jsCode2SessionInfo("FAKE_CODE");
fail("Expected token failure");
} catch (WxRuntimeException e) {
assertSafe(e);
}
logs.assertSafe();
}
}
@Test
public void ordinaryGetKeepsSerializedQueryAndExceptionContract() throws Exception {
TestService service = service();
IllegalArgumentException original = new IllegalArgumentException("ordinary failure");
when(service.executor.execute(anyString(), eq("value=a%2Fb"), eq(WxType.MiniApp))).thenThrow(original);
try {
service.get("https://api.weixin.qq.com/ordinary", "value=a%2Fb");
fail("Expected failure");
} catch (IllegalArgumentException e) {
assertSame(e, original);
}
}
@Test
public void accountSwitchKeepsCredentialsWithTheirAccount() throws Exception {
TestService service = service();
WxMaDefaultConfigImpl first = config();
WxMaDefaultConfigImpl second = config();
second.setAppid("SECOND_APP_ID");
second.setSecret("SECOND_SECRET");
second.updateAccessToken("SECOND_TOKEN", 7200);
Map<String, cn.binarywang.wx.miniapp.config.WxMaConfig> configs = new HashMap<>();
configs.put(first.getAppid(), first);
configs.put(second.getAppid(), second);
service.setMultiConfigs(configs, first.getAppid());
when(service.executor.execute(anyString(), anyString(), eq(WxType.MiniApp))).thenAnswer(invocation -> {
Map<String, String> params = decode(invocation.getArgument(1));
String uri = invocation.getArgument(0);
if ("SECOND_APP_ID".equals(params.get("appid"))) {
assertEquals(params.get("secret"), "SECOND_SECRET");
assertTrue(uri.endsWith("access_token=SECOND_TOKEN"));
} else {
assertEquals(params.get("secret"), SECRET);
assertTrue(uri.endsWith("access_token=" + TOKEN));
}
return RESULT;
});
assertEquals(service.jsCode2SessionInfo("FAKE_CODE").getSessionKey(), SESSION);
assertTrue(service.switchover(second.getAppid()));
assertEquals(service.jsCode2SessionInfo("FAKE_CODE").getSessionKey(), SESSION);
assertTrue(service.switchover(first.getAppid()));
assertEquals(service.jsCode2SessionInfo("FAKE_CODE").getSessionKey(), SESSION);
}
private static WxMaDefaultConfigImpl config() {
WxMaDefaultConfigImpl config = new WxMaDefaultConfigImpl();
config.setAppid("FAKE_APP_ID");
config.setSecret(SECRET);
config.updateAccessToken(TOKEN, 7200);
return config;
}
private TestService service() {
TestService service = new TestService();
service.setWxMaConfig(config());
service.setRetrySleepMillis(0);
return service;
}
private WxErrorException error(int code) {
return new WxErrorException(WxError.builder().errorCode(code).errorMsg(SECRET + TOKEN)
.errorMsgEn(SECRET).json(SESSION).build(), new RuntimeException(SECRET));
}
private static Map<String, String> decode(String query) throws Exception {
Map<String, String> result = new HashMap<>();
for (String pair : query.split("&")) {
String[] parts = pair.split("=", 2);
result.put(parts[0], URLDecoder.decode(parts[1], "UTF-8"));
}
return result;
}
private static void assertSafe(Throwable e) {
StringWriter trace = new StringWriter();
e.printStackTrace(new PrintWriter(trace));
assertNoSecrets(trace.toString());
assertNull(e.getCause());
assertEquals(e.getSuppressed().length, 0);
}
private static void assertNoSecrets(String text) {
for (String secret : new String[]{SECRET, TOKEN, SESSION, "FAKE_CODE", "FAKE_OPENID"}) {
assertFalse(text.contains(secret), "Credentials must not appear in diagnostics");
}
}
private static class LogCapture implements AutoCloseable {
private final Logger logger = (Logger) LoggerFactory.getLogger(BaseWxMaServiceImpl.class);
private final Level previous = logger.getLevel();
private final ListAppender<ILoggingEvent> appender = new ListAppender<>();
LogCapture() {
logger.setLevel(Level.DEBUG);
appender.start();
logger.addAppender(appender);
}
void assertSafe() {
assertFalse(appender.list.isEmpty());
for (ILoggingEvent event : appender.list) {
assertNoSecrets(event.getFormattedMessage());
}
}
@Override
public void close() {
logger.detachAppender(appender);
logger.setLevel(previous);
appender.stop();
}
}
private static class TestService extends WxMaServiceImpl {
@SuppressWarnings("unchecked")
private final RequestExecutor<String, String> executor = mock(RequestExecutor.class);
private int tokenRequests;
private boolean failToken;
@Override
public void initHttp() {
// The inherited execution/retry pipeline is exercised with a controlled transport.
}
@Override
public String get(String url, String query) throws WxErrorException {
return (String) super.execute(executor, url, query);
}
@Override
protected String doGetAccessTokenRequest() throws IOException {
tokenRequests++;
if (failToken) {
throw new IOException(SECRET + TOKEN);
}
return "{\"access_token\":\"" + TOKEN + "\",\"expires_in\":7200}";
}
}
}

View File

@@ -3,6 +3,7 @@
<suite name="weixin-java-miniapp-unit-tests" verbose="1">
<test name="Unit_Test">
<classes>
<class name="cn.binarywang.wx.miniapp.api.impl.WxMaCode2SessionSecurityTest"/>
<class name="cn.binarywang.wx.miniapp.api.impl.WxMaSubscribeServiceImplUrlTest"/>
<class name="cn.binarywang.wx.miniapp.api.impl.WxMaUserServiceImplCheckSessionKeyTest"/>
</classes>