diff --git a/pom.xml b/pom.xml index 8e9384c742..1178f64af1 100644 --- a/pom.xml +++ b/pom.xml @@ -144,6 +144,8 @@ 9.4.57.v20241219 1.85 2.3.3.RELEASE + + 1.2.13 diff --git a/weixin-java-common/src/main/java/me/chanjar/weixin/common/util/http/SensitiveRequestUtils.java b/weixin-java-common/src/main/java/me/chanjar/weixin/common/util/http/SensitiveRequestUtils.java new file mode 100644 index 0000000000..d23f046dbb --- /dev/null +++ b/weixin-java-common/src/main/java/me/chanjar/weixin/common/util/http/SensitiveRequestUtils.java @@ -0,0 +1,69 @@ +package me.chanjar.weixin.common.util.http; + +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import me.chanjar.weixin.common.error.WxError; +import me.chanjar.weixin.common.error.WxErrorException; +import me.chanjar.weixin.common.error.WxRuntimeException; + +/** + * Safe parameter and exception handling for requests containing credentials. + */ +public final class SensitiveRequestUtils { + private SensitiveRequestUtils() { + } + + /** + * Encodes one raw query parameter value, without interpreting existing percent escapes. + * + * @param value raw, non-null parameter value + * @return UTF-8 form-encoded value + * @throws NullPointerException if the value is null + */ + public static String encodeQueryValue(String value) { + try { + return URLEncoder.encode(value, StandardCharsets.UTF_8.name()); + } catch (UnsupportedEncodingException e) { + throw new IllegalStateException("UTF-8 is not available"); + } + } + + /** + * Retains the WeChat error code and stack frames without exposing response data or causes. + * This is intended for credential-bearing entry points, not general exception conversion. + * + * @param failure original failure + * @return safe exception without the original message, JSON, cause or suppressed exceptions + */ + public static WxErrorException sanitize(WxErrorException failure) { + WxErrorException safe = new WxErrorException(new WxError(failure.getError().getErrorCode(), + "Sensitive request failed")); + safe.setStackTrace(failure.getStackTrace()); + return safe; + } + + /** + * Removes request data from a runtime failure. Common argument, state and null failures + * keep their categories; other runtime failures become {@link WxRuntimeException}. + * Original exception class names and stack frames remain available for diagnosis. + * + * @param failure original failure + * @return safe exception without the original message, cause or suppressed exceptions + */ + public static RuntimeException sanitize(RuntimeException failure) { + String message = "Sensitive request failed (" + failure.getClass().getName() + ")"; + RuntimeException safe; + if (failure instanceof IllegalArgumentException) { + safe = new IllegalArgumentException(message); + } else if (failure instanceof IllegalStateException) { + safe = new IllegalStateException(message); + } else if (failure instanceof NullPointerException) { + safe = new NullPointerException(message); + } else { + safe = new WxRuntimeException(message); + } + safe.setStackTrace(failure.getStackTrace()); + return safe; + } +} diff --git a/weixin-java-common/src/test/java/me/chanjar/weixin/common/util/http/SensitiveRequestUtilsTest.java b/weixin-java-common/src/test/java/me/chanjar/weixin/common/util/http/SensitiveRequestUtilsTest.java new file mode 100644 index 0000000000..5c0822edc4 --- /dev/null +++ b/weixin-java-common/src/test/java/me/chanjar/weixin/common/util/http/SensitiveRequestUtilsTest.java @@ -0,0 +1,67 @@ +package me.chanjar.weixin.common.util.http; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.net.URLDecoder; +import me.chanjar.weixin.common.error.WxError; +import me.chanjar.weixin.common.error.WxErrorException; +import me.chanjar.weixin.common.error.WxRuntimeException; +import org.testng.annotations.Test; + +import static org.testng.Assert.*; + +public class SensitiveRequestUtilsTest { + @Test + public void encodesRawValuesExactlyOnce() throws Exception { + for (String value : new String[]{"NORMAL_CODE", "", "a b\n", "+&=#?%2F", "中文\uD83D\uDE00"}) { + String encoded = SensitiveRequestUtils.encodeQueryValue(value); + assertEquals(URLDecoder.decode(encoded, "UTF-8"), value); + assertFalse(encoded.contains("&")); + assertFalse(encoded.contains("#")); + } + assertEquals(SensitiveRequestUtils.encodeQueryValue("%2F"), "%252F"); + } + + @Test(expectedExceptions = NullPointerException.class) + public void doesNotConvertNullToAValue() { + SensitiveRequestUtils.encodeQueryValue(null); + } + + @Test + public void removesAllOriginalErrorRepresentations() { + WxError error = WxError.builder().errorCode(40029).errorMsg("FAKE_SECRET") + .errorMsgEn("FAKE_SECRET").json("FAKE_SECRET").build(); + WxErrorException original = new WxErrorException(error, new IllegalArgumentException("FAKE_SECRET")); + original.addSuppressed(new IllegalStateException("FAKE_SECRET")); + WxErrorException safe = SensitiveRequestUtils.sanitize(original); + assertEquals(safe.getError().getErrorCode(), 40029); + assertNull(safe.getError().getJson()); + assertNull(safe.getError().getErrorMsgEn()); + assertSafe(safe, original); + assertEquals(original.getError().getJson(), "FAKE_SECRET"); + } + + @Test + public void retainsCommonRuntimeCategoriesWithoutCauses() { + for (RuntimeException original : new RuntimeException[]{new IllegalArgumentException("FAKE_SECRET"), + new IllegalStateException("FAKE_SECRET"), new NullPointerException("FAKE_SECRET"), + new WxRuntimeException("FAKE_SECRET")}) { + original.initCause(new RuntimeException("FAKE_SECRET")); + original.addSuppressed(new RuntimeException("FAKE_SECRET")); + RuntimeException safe = SensitiveRequestUtils.sanitize(original); + assertEquals(safe.getClass(), original.getClass()); + assertSafe(safe, original); + } + assertTrue(SensitiveRequestUtils.sanitize(new UnsupportedOperationException("FAKE_SECRET")) + instanceof WxRuntimeException); + } + + private void assertSafe(Throwable safe, Throwable original) { + StringWriter trace = new StringWriter(); + safe.printStackTrace(new PrintWriter(trace)); + assertFalse(trace.toString().contains("FAKE_SECRET")); + assertNull(safe.getCause()); + assertEquals(safe.getSuppressed().length, 0); + assertEquals(safe.getStackTrace(), original.getStackTrace()); + } +} diff --git a/weixin-java-common/src/test/resources/testng.xml b/weixin-java-common/src/test/resources/testng.xml index a5c082f03b..a9064b8c7a 100644 --- a/weixin-java-common/src/test/resources/testng.xml +++ b/weixin-java-common/src/test/resources/testng.xml @@ -3,6 +3,7 @@ + diff --git a/weixin-java-miniapp/pom.xml b/weixin-java-miniapp/pom.xml index 2a4daa7469..3c6d0fd49d 100644 --- a/weixin-java-miniapp/pom.xml +++ b/weixin-java-miniapp/pom.xml @@ -53,6 +53,7 @@ ch.qos.logback logback-classic + ${logback-slf4j1-test.version} test diff --git a/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/api/WxMaService.java b/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/api/WxMaService.java index 4e001c6409..e2ae6b4236 100644 --- a/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/api/WxMaService.java +++ b/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/api/WxMaService.java @@ -38,6 +38,8 @@ public interface WxMaService extends WxService { /** * 获取登录后的 session 信息。 + * 登录参数按原始值传入,无需 URL 编码。为防止泄露凭证,失败异常保留错误码和栈帧, + * 不包含原始请求、响应、cause 或 suppressed 异常;其他运行时异常可能转换为 WxRuntimeException。 * * @param jsCode 登录时获取的 code * @return 登录 session 结果对象 diff --git a/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/api/impl/BaseWxMaServiceImpl.java b/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/api/impl/BaseWxMaServiceImpl.java index bf69439a65..49014318c4 100644 --- a/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/api/impl/BaseWxMaServiceImpl.java +++ b/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/api/impl/BaseWxMaServiceImpl.java @@ -211,16 +211,22 @@ public String getPaidUnionId(String openid, String transactionId, String mchId, @Override public WxMaJscode2SessionResult jsCode2SessionInfo(String jsCode) throws WxErrorException { - final WxMaConfig config = getWxMaConfig(); - Map 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"); - - String result = - get(JSCODE_TO_SESSION_URL, Joiner.on("&").withKeyValueSeparator("=").join(params)); - return WxMaJscode2SessionResult.fromJson(result); + try { + final WxMaConfig config = getWxMaConfig(); + Map 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); + } catch (WxErrorException e) { + throw SensitiveRequestUtils.sanitize(e); + } catch (RuntimeException e) { + throw SensitiveRequestUtils.sanitize(e); + } } @Override @@ -390,7 +396,7 @@ private R executeWithRetry(ExecutorAction executor, String uri, String 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 @@ private R executeWithRetry(ExecutorAction executor, String uri, String } private R executeInternal( - ExecutorAction executor, String uri, String dataForLog, boolean doNotAutoRefreshToken) + ExecutorAction executor, String uri, String dataForLog, boolean doNotAutoRefreshToken, + boolean code2Session) throws WxErrorException { if (uri.contains("access_token=")) { @@ -440,7 +447,11 @@ private R executeInternal( 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 @@ private R executeInternal( } 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 @@ private R executeInternal( } 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 @@ private R executeInternal( * @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) { diff --git a/weixin-java-miniapp/src/test/java/cn/binarywang/wx/miniapp/api/impl/WxMaCode2SessionSecurityTest.java b/weixin-java-miniapp/src/test/java/cn/binarywang/wx/miniapp/api/impl/WxMaCode2SessionSecurityTest.java new file mode 100644 index 0000000000..90b3b30bb2 --- /dev/null +++ b/weixin-java-miniapp/src/test/java/cn/binarywang/wx/miniapp/api/impl/WxMaCode2SessionSecurityTest.java @@ -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 query = new AtomicReference<>(); + AtomicReference 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 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 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 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 decode(String query) throws Exception { + Map 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 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 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}"; + } + } +} diff --git a/weixin-java-miniapp/src/test/resources/testng.xml b/weixin-java-miniapp/src/test/resources/testng.xml index 9733604a1c..8d38decfe7 100644 --- a/weixin-java-miniapp/src/test/resources/testng.xml +++ b/weixin-java-miniapp/src/test/resources/testng.xml @@ -3,6 +3,7 @@ + diff --git a/weixin-java-open/pom.xml b/weixin-java-open/pom.xml index 509733caa4..2e3884bf9b 100644 --- a/weixin-java-open/pom.xml +++ b/weixin-java-open/pom.xml @@ -67,6 +67,11 @@ testng test + + org.mockito + mockito-core + test + com.google.inject guice @@ -94,6 +99,7 @@ ch.qos.logback logback-classic + ${logback-slf4j1-test.version} test diff --git a/weixin-java-open/src/main/java/me/chanjar/weixin/open/api/WxOpenComponentService.java b/weixin-java-open/src/main/java/me/chanjar/weixin/open/api/WxOpenComponentService.java index 9bb648b6c6..82a51d4919 100644 --- a/weixin-java-open/src/main/java/me/chanjar/weixin/open/api/WxOpenComponentService.java +++ b/weixin-java-open/src/main/java/me/chanjar/weixin/open/api/WxOpenComponentService.java @@ -496,6 +496,9 @@ public interface WxOpenComponentService { /** * Miniapp jscode 2 session wx ma jscode 2 session result. + * Pass raw parameter values, without URL encoding. Failures retain error codes and stack + * frames but omit raw responses, causes and suppressed exceptions to protect credentials. + * Other runtime exception subclasses may be converted to WxRuntimeException. * * @param appId the app id * @param jsCode the js code diff --git a/weixin-java-open/src/main/java/me/chanjar/weixin/open/api/impl/WxOpenComponentServiceImpl.java b/weixin-java-open/src/main/java/me/chanjar/weixin/open/api/impl/WxOpenComponentServiceImpl.java index 84f9190bb9..938356cf0e 100644 --- a/weixin-java-open/src/main/java/me/chanjar/weixin/open/api/impl/WxOpenComponentServiceImpl.java +++ b/weixin-java-open/src/main/java/me/chanjar/weixin/open/api/impl/WxOpenComponentServiceImpl.java @@ -15,6 +15,7 @@ import me.chanjar.weixin.common.error.WxRuntimeException; import me.chanjar.weixin.common.util.crypto.SHA1; import me.chanjar.weixin.common.util.http.URIUtil; +import me.chanjar.weixin.common.util.http.SensitiveRequestUtils; import me.chanjar.weixin.common.util.json.GsonParser; import me.chanjar.weixin.common.util.json.WxGsonBuilder; import me.chanjar.weixin.mp.api.WxMpService; @@ -272,7 +273,8 @@ public String get(String uri, String accessTokenKey) throws WxErrorException { lock.unlock(); } if (this.getWxOpenConfigStorage().autoRefreshToken()) { - log.warn("即将重新获取新的access_token,错误代码:{},错误信息:{}", error.getErrorCode(), error.getErrorMsg()); + log.warn("即将重新获取新的access_token,错误代码:{},错误信息:{}", error.getErrorCode(), + WxOpenServiceAbstractImpl.isSensitiveRequest(uri) ? "[redacted]" : error.getErrorMsg()); return this.get(uri, accessTokenKey); } } @@ -494,9 +496,18 @@ public String oauth2buildAuthorizationUrl(String appId, String redirectURI, Stri @Override public WxMaJscode2SessionResult miniappJscode2Session(String appId, String jsCode) throws WxErrorException { - String url = String.format(MINIAPP_JSCODE_2_SESSION, appId, jsCode, getWxOpenConfigStorage().getComponentAppId()); - String responseContent = get(url); - return WxMaJscode2SessionResult.fromJson(responseContent); + try { + String url = String.format(MINIAPP_JSCODE_2_SESSION, + SensitiveRequestUtils.encodeQueryValue(String.valueOf(appId)), + SensitiveRequestUtils.encodeQueryValue(String.valueOf(jsCode)), + SensitiveRequestUtils.encodeQueryValue(String.valueOf(getWxOpenConfigStorage().getComponentAppId()))); + String responseContent = get(url); + return WxMaJscode2SessionResult.fromJson(responseContent); + } catch (WxErrorException e) { + throw SensitiveRequestUtils.sanitize(e); + } catch (RuntimeException e) { + throw SensitiveRequestUtils.sanitize(e); + } } @Override diff --git a/weixin-java-open/src/main/java/me/chanjar/weixin/open/api/impl/WxOpenServiceAbstractImpl.java b/weixin-java-open/src/main/java/me/chanjar/weixin/open/api/impl/WxOpenServiceAbstractImpl.java index bad2241aa5..2ca81aca04 100644 --- a/weixin-java-open/src/main/java/me/chanjar/weixin/open/api/impl/WxOpenServiceAbstractImpl.java +++ b/weixin-java-open/src/main/java/me/chanjar/weixin/open/api/impl/WxOpenServiceAbstractImpl.java @@ -43,20 +43,40 @@ public void setWxOpenConfigStorage(WxOpenConfigStorage wxOpenConfigStorage) { public abstract void initHttp(); protected T execute(RequestExecutor executor, String uri, E data) throws WxErrorException { + boolean sensitive = isSensitiveRequest(uri); try { T result = executor.execute(uri, data, WxType.Open); - log.debug("\n【请求地址】: {}\n【请求参数】:{}\n【响应数据】:{}", uri, data, result); + if (sensitive) { + log.debug("Sensitive request completed"); + } else { + log.debug("\n【请求地址】: {}\n【请求参数】:{}\n【响应数据】:{}", uri, data, result); + } return result; } catch (WxErrorException e) { WxError error = e.getError(); if (error.getErrorCode() != 0) { - log.warn("\n【请求地址】: {}\n【请求参数】:{}\n【错误信息】:{}", uri, data, error); + if (sensitive) { + log.warn("Sensitive request failed, error code: {}", error.getErrorCode()); + } else { + log.warn("\n【请求地址】: {}\n【请求参数】:{}\n【错误信息】:{}", uri, data, error); + } throw new WxErrorException(error, e); } return null; } catch (IOException e) { - log.warn("\n【请求地址】: {}\n【请求参数】:{}\n【异常信息】:{}", uri, data, e.getMessage()); + if (sensitive) { + log.warn("Sensitive request failed, exception type: {}", e.getClass().getName()); + } else { + log.warn("\n【请求地址】: {}\n【请求参数】:{}\n【异常信息】:{}", uri, data, e.getMessage()); + } throw new WxRuntimeException(e); } } + + static boolean isSensitiveRequest(String uri) { + String loginUrl = WxOpenComponentService.MINIAPP_JSCODE_2_SESSION.split("\\?", 2)[0]; + String tokenUrl = WxOpenComponentService.API_COMPONENT_TOKEN_URL; + return uri.equals(loginUrl) || uri.startsWith(loginUrl + "?") + || uri.equals(tokenUrl) || uri.startsWith(tokenUrl + "?"); + } } diff --git a/weixin-java-open/src/test/java/me/chanjar/weixin/open/api/impl/WxOpenCode2SessionSecurityTest.java b/weixin-java-open/src/test/java/me/chanjar/weixin/open/api/impl/WxOpenCode2SessionSecurityTest.java new file mode 100644 index 0000000000..8d468121a1 --- /dev/null +++ b/weixin-java-open/src/test/java/me/chanjar/weixin/open/api/impl/WxOpenCode2SessionSecurityTest.java @@ -0,0 +1,230 @@ +package me.chanjar.weixin.open.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 java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.net.URI; +import java.net.URLDecoder; +import java.util.HashMap; +import java.util.Map; +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 me.chanjar.weixin.open.api.WxOpenComponentService; +import org.mockito.ArgumentCaptor; +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 WxOpenCode2SessionSecurityTest { + private static final String SECRET = "FAKE_COMPONENT_SECRET"; + private static final String TOKEN = "FAKE_COMPONENT_TOKEN"; + private static final String SESSION = "FAKE_SESSION_KEY"; + private static final String RESULT = "{\"openid\":\"FAKE_OPENID\",\"session_key\":\"" + SESSION + "\"}"; + + @Test + public void encodesParametersAndKeepsGetExtensionAndNullBehavior() throws Exception { + try (LogCapture logs = new LogCapture()) { + TestService service = service(); + WxOpenComponentService component = service.getWxOpenComponentService(); + for (String code : new String[]{"NORMAL_CODE", "a b\n", "a+&=#?%2F", "中文\uD83D\uDE00", "", null}) { + reset(service.executor); + when(service.executor.execute(anyString(), isNull(), eq(WxType.Open))).thenReturn(RESULT); + assertEquals(component.miniappJscode2Session("appid+&=", code).getSessionKey(), SESSION); + ArgumentCaptor uri = ArgumentCaptor.forClass(String.class); + verify(service.executor).execute(uri.capture(), isNull(), eq(WxType.Open)); + Map query = decode(URI.create(uri.getValue()).getRawQuery()); + assertEquals(query.size(), 5); + assertEquals(query.get("appid"), "appid+&="); + assertEquals(query.get("js_code"), String.valueOf(code)); + assertEquals(query.get("component_appid"), "component+&="); + assertEquals(query.get("component_access_token"), TOKEN); + } + logs.assertSafe(); + } + } + + @Test + public void refreshesTokenWithoutLoggingLoginOrCredentialPayloads() throws Exception { + try (LogCapture logs = new LogCapture()) { + TestService service = service(); + when(service.executor.execute(anyString(), nullable(String.class), eq(WxType.Open))) + .thenThrow(error(40001)) + .thenReturn("{\"component_access_token\":\"" + TOKEN + "\",\"expires_in\":7200}") + .thenReturn(RESULT); + assertEquals(service.getWxOpenComponentService().miniappJscode2Session("appid", "FAKE_CODE") + .getSessionKey(), SESSION); + verify(service.executor).execute(eq(WxOpenComponentService.API_COMPONENT_TOKEN_URL), + contains(SECRET), eq(WxType.Open)); + logs.assertSafe(); + } + } + + @Test + public void protectsWechatTransportAndResponseParsingFailures() throws Exception { + try (LogCapture logs = new LogCapture()) { + TestService service = service(); + 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(), isNull(), eq(WxType.Open))).thenThrow(failure); + try { + service.getWxOpenComponentService().miniappJscode2Session("appid", "FAKE_CODE"); + fail("Expected failure"); + } catch (WxErrorException e) { + assertEquals(e.getError().getErrorCode(), 40029); + assertNull(e.getError().getJson()); + assertNull(e.getError().getErrorMsgEn()); + assertSafe(e); + } catch (RuntimeException e) { + assertSafe(e); + } + } + reset(service.executor); + when(service.executor.execute(anyString(), isNull(), eq(WxType.Open))) + .thenReturn("{\"session_key\":\"" + SESSION + "\",\"expires_in\":"); + try { + service.getWxOpenComponentService().miniappJscode2Session("appid", "FAKE_CODE"); + fail("Expected malformed response failure"); + } catch (WxRuntimeException e) { + assertSafe(e); + } + logs.assertSafe(); + } + } + + @Test + public void firstTokenRequestFailureIsProtected() throws Exception { + try (LogCapture logs = new LogCapture()) { + TestService service = service(); + service.getWxOpenConfigStorage().expireComponentAccessToken(); + when(service.executor.execute(anyString(), anyString(), eq(WxType.Open))) + .thenThrow(new IOException(SECRET + TOKEN)); + try { + service.getWxOpenComponentService().miniappJscode2Session("appid", "FAKE_CODE"); + fail("Expected token failure"); + } catch (WxRuntimeException e) { + assertSafe(e); + } + logs.assertSafe(); + } + } + + @Test + public void sensitiveEndpointMatchingDoesNotChangeOtherRequests() throws Exception { + assertTrue(WxOpenServiceAbstractImpl.isSensitiveRequest(WxOpenComponentService.API_COMPONENT_TOKEN_URL)); + assertTrue(WxOpenServiceAbstractImpl.isSensitiveRequest( + "https://api.weixin.qq.com/sns/component/jscode2session?appid=x")); + assertFalse(WxOpenServiceAbstractImpl.isSensitiveRequest( + "https://api.weixin.qq.com/sns/component/jscode2session-other?appid=x")); + TestService service = service(); + IllegalArgumentException original = new IllegalArgumentException("ordinary failure"); + when(service.executor.execute(anyString(), eq("value=a%2Fb"), eq(WxType.Open))).thenThrow(original); + try { + service.get("https://api.weixin.qq.com/ordinary", "value=a%2Fb"); + fail("Expected failure"); + } catch (IllegalArgumentException e) { + assertSame(e, original); + } + } + + private TestService service() { + TestService service = new TestService(); + WxOpenInMemoryConfigStorage config = new WxOpenInMemoryConfigStorage(); + config.setComponentAppId("component+&="); + config.setComponentAppSecret(SECRET); + config.setComponentVerifyTicket("FAKE_TICKET"); + config.updateComponentAccessToken(TOKEN, 7200); + service.setWxOpenConfigStorage(config); + 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 Map decode(String query) throws Exception { + Map 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 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", "FAKE_TICKET"}) { + assertFalse(text.contains(secret), "Credentials must not appear in diagnostics"); + } + } + + private static class LogCapture implements AutoCloseable { + private final Logger[] loggers = {(Logger) LoggerFactory.getLogger(WxOpenServiceAbstractImpl.class), + (Logger) LoggerFactory.getLogger(WxOpenComponentServiceImpl.class)}; + private final Level[] previous = new Level[loggers.length]; + private final ListAppender appender = new ListAppender<>(); + + LogCapture() { + appender.start(); + for (int i = 0; i < loggers.length; i++) { + previous[i] = loggers[i].getLevel(); + loggers[i].setLevel(Level.DEBUG); + loggers[i].addAppender(appender); + } + } + + void assertSafe() { + assertFalse(appender.list.isEmpty()); + for (ILoggingEvent event : appender.list) { + assertNoSecrets(event.getFormattedMessage()); + } + } + + @Override + public void close() { + for (int i = 0; i < loggers.length; i++) { + loggers[i].detachAppender(appender); + loggers[i].setLevel(previous[i]); + } + appender.stop(); + } + } + + private static class TestService extends WxOpenServiceImpl { + @SuppressWarnings("unchecked") + private final RequestExecutor executor = mock(RequestExecutor.class); + + @Override + public void initHttp() { + // Keep the inherited component/token flow with a controlled transport. + } + + @Override + public String get(String uri, String data) throws WxErrorException { + return super.execute(executor, uri, data); + } + + @Override + public String post(String uri, String data) throws WxErrorException { + return super.execute(executor, uri, data); + } + } +} diff --git a/weixin-java-open/src/test/resources/testng.xml b/weixin-java-open/src/test/resources/testng.xml index 4fc5e6b52e..4599e3798c 100644 --- a/weixin-java-open/src/test/resources/testng.xml +++ b/weixin-java-open/src/test/resources/testng.xml @@ -10,6 +10,7 @@ +