diff --git a/client/src/main/java/org/asynchttpclient/DefaultRequest.java b/client/src/main/java/org/asynchttpclient/DefaultRequest.java index 09c615d2a..c8e44e338 100644 --- a/client/src/main/java/org/asynchttpclient/DefaultRequest.java +++ b/client/src/main/java/org/asynchttpclient/DefaultRequest.java @@ -38,6 +38,8 @@ import java.util.Map; import static org.asynchttpclient.util.MiscUtils.isNonEmpty; +import static org.asynchttpclient.util.SensitiveLoggingUtils.REDACTED; +import static org.asynchttpclient.util.SensitiveLoggingUtils.isSensitiveHeader; public class DefaultRequest implements Request { @@ -292,8 +294,8 @@ public String toString() { for (Map.Entry header : headers) { sb.append('\t'); sb.append(header.getKey()); - sb.append(':'); - sb.append(header.getValue()); + sb.append(": "); + sb.append(isSensitiveHeader(header.getKey()) ? REDACTED : header.getValue()); } } diff --git a/client/src/main/java/org/asynchttpclient/netty/NettyResponseFuture.java b/client/src/main/java/org/asynchttpclient/netty/NettyResponseFuture.java index 36a06e842..09836b155 100755 --- a/client/src/main/java/org/asynchttpclient/netty/NettyResponseFuture.java +++ b/client/src/main/java/org/asynchttpclient/netty/NettyResponseFuture.java @@ -710,6 +710,7 @@ public String toString() { ",\n\tisDone=" + isDone + // ",\n\tisCancelled=" + isCancelled + // ",\n\tasyncHandler=" + asyncHandler + // + // NettyRequest deliberately keeps Object.toString() so request headers are not rendered here. ",\n\tnettyRequest=" + nettyRequest + // ",\n\tfuture=" + future + // ",\n\turi=" + getUri() + // diff --git a/client/src/main/java/org/asynchttpclient/netty/handler/HttpHandler.java b/client/src/main/java/org/asynchttpclient/netty/handler/HttpHandler.java index 99a23c7e9..c09db7b81 100755 --- a/client/src/main/java/org/asynchttpclient/netty/handler/HttpHandler.java +++ b/client/src/main/java/org/asynchttpclient/netty/handler/HttpHandler.java @@ -58,7 +58,9 @@ private static boolean abortAfterHandlingHeaders(AsyncHandler handler, HttpHe private void handleHttpResponse(final HttpResponse response, final Channel channel, final NettyResponseFuture future, AsyncHandler handler) throws Exception { HttpRequest httpRequest = future.getNettyRequest().getHttpRequest(); - logger.debug("\n\nRequest {}\n\nResponse {}\n", httpRequest, response); + if (logger.isDebugEnabled()) { + logger.debug("\n\nRequest {}\n\nResponse {}\n", HttpMessageFormatter.format(httpRequest), HttpMessageFormatter.format(response)); + } future.setKeepAlive(config.getKeepAliveStrategy().keepAlive((InetSocketAddress) channel.remoteAddress(), future.getTargetRequest(), httpRequest, response)); diff --git a/client/src/main/java/org/asynchttpclient/netty/handler/HttpMessageFormatter.java b/client/src/main/java/org/asynchttpclient/netty/handler/HttpMessageFormatter.java new file mode 100644 index 000000000..0c8e8596b --- /dev/null +++ b/client/src/main/java/org/asynchttpclient/netty/handler/HttpMessageFormatter.java @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2026 AsyncHttpClient Project. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.asynchttpclient.netty.handler; + +import io.netty.handler.codec.http.HttpHeaders; +import io.netty.handler.codec.http.HttpRequest; +import io.netty.handler.codec.http.HttpResponse; +import org.asynchttpclient.util.SensitiveLoggingUtils; + +import java.util.Map; + +/** + * Formats HTTP messages for logging. Sensitive header values are redacted unless the + * {@code org.asynchttpclient.enableSensitiveLogging} system property or {@code AHC_ENABLE_SENSITIVE_LOGGING} environment + * variable is set to {@code true} before the sensitive logging policy is initialized. Enabling sensitive logging may + * expose credentials and session data. + */ +public final class HttpMessageFormatter { + + /** The value used in place of sensitive header values. */ + public static final String REDACTED = SensitiveLoggingUtils.REDACTED; + + private HttpMessageFormatter() { + } + + static String format(HttpRequest request) { + StringBuilder value = new StringBuilder() + .append(request.method()).append(' ') + .append(request.uri()).append(' ') + .append(request.protocolVersion()); + return appendHeaders(value, request.headers()).toString(); + } + + static String format(HttpResponse response) { + StringBuilder value = new StringBuilder() + .append(response.protocolVersion()).append(' ') + .append(response.status()); + return appendHeaders(value, response.headers()).toString(); + } + + private static StringBuilder appendHeaders(StringBuilder value, HttpHeaders headers) { + for (Map.Entry header : headers) { + value.append('\n').append(header.getKey()).append(": ") + .append(isSensitiveHeader(header.getKey()) ? REDACTED : header.getValue()); + } + return value; + } + + /** + * Returns whether a header value must be redacted from logs. + * + * @param name the header name + * @return {@code true} for authentication and cookie headers when sensitive logging is disabled + */ + public static boolean isSensitiveHeader(CharSequence name) { + return SensitiveLoggingUtils.isSensitiveHeader(name); + } +} diff --git a/client/src/main/java/org/asynchttpclient/netty/handler/WebSocketHandler.java b/client/src/main/java/org/asynchttpclient/netty/handler/WebSocketHandler.java index 3ffd781b5..ff9603f4e 100755 --- a/client/src/main/java/org/asynchttpclient/netty/handler/WebSocketHandler.java +++ b/client/src/main/java/org/asynchttpclient/netty/handler/WebSocketHandler.java @@ -110,7 +110,7 @@ public void handleRead(Channel channel, NettyResponseFuture future, Object e) HttpResponse response = (HttpResponse) e; if (logger.isDebugEnabled()) { HttpRequest httpRequest = future.getNettyRequest().getHttpRequest(); - logger.debug("\n\nRequest {}\n\nResponse {}\n", httpRequest, response); + logger.debug("\n\nRequest {}\n\nResponse {}\n", HttpMessageFormatter.format(httpRequest), HttpMessageFormatter.format(response)); } WebSocketUpgradeHandler handler = getWebSocketUpgradeHandler(future); diff --git a/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestSender.java b/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestSender.java index 36af9019b..4e0256757 100755 --- a/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestSender.java +++ b/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestSender.java @@ -1105,7 +1105,8 @@ public boolean retry(NettyResponseFuture future) { if (future.isReplayPossible()) { future.setChannelState(ChannelState.RECONNECTED); - LOGGER.debug("Trying to recover request {}\n", future.getNettyRequest().getHttpRequest()); + HttpRequest request = future.getNettyRequest().getHttpRequest(); + LOGGER.debug("Trying to recover request '{}' to '{}'\n", request.method(), request.uri()); try { future.getAsyncHandler().onRetry(); } catch (Exception e) { @@ -1413,7 +1414,7 @@ public void replayRequest(final NettyResponseFuture future, FilterContext fc, future.setChannelState(ChannelState.NEW); future.touch(); - LOGGER.debug("\n\nReplaying Request {}\n for Future {}\n", newRequest, future); + LOGGER.debug("\n\nReplaying request '{}' to '{}'\n for Future {}\n", newRequest.getMethod(), newRequest.getUri(), future); try { future.getAsyncHandler().onRetry(); } catch (Exception e) { diff --git a/client/src/main/java/org/asynchttpclient/util/SensitiveLoggingUtils.java b/client/src/main/java/org/asynchttpclient/util/SensitiveLoggingUtils.java new file mode 100644 index 000000000..92be0c35f --- /dev/null +++ b/client/src/main/java/org/asynchttpclient/util/SensitiveLoggingUtils.java @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2026 AsyncHttpClient Project. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.asynchttpclient.util; + +import io.netty.handler.codec.http.HttpHeaderNames; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Shared policy for rendering sensitive HTTP headers in logs. The policy is read once from the + * {@code org.asynchttpclient.enableSensitiveLogging} system property or {@code AHC_ENABLE_SENSITIVE_LOGGING} environment + * variable when this class is initialized. + */ +public final class SensitiveLoggingUtils { + + private static final Logger LOGGER = LoggerFactory.getLogger(SensitiveLoggingUtils.class); + private static final String ENABLE_SENSITIVE_LOGGING_PROPERTY = "org.asynchttpclient.enableSensitiveLogging"; + private static final String ENABLE_SENSITIVE_LOGGING_ENVIRONMENT_VARIABLE = "AHC_ENABLE_SENSITIVE_LOGGING"; + private static final boolean SENSITIVE_LOGGING_ENABLED = isSensitiveLoggingEnabled(); + + /** The value used in place of sensitive header values. */ + public static final String REDACTED = ""; + + static { + if (SENSITIVE_LOGGING_ENABLED) { + LOGGER.warn("Sensitive HTTP header logging is enabled; credentials and session data may be written to logs"); + } + } + + private SensitiveLoggingUtils() { + } + + /** + * Returns whether a header value must be redacted from logs. + * + * @param name the header name + * @return {@code true} for authentication and cookie headers when sensitive logging is disabled + */ + public static boolean isSensitiveHeader(CharSequence name) { + return !SENSITIVE_LOGGING_ENABLED && (HttpHeaderNames.AUTHORIZATION.contentEqualsIgnoreCase(name) + || HttpHeaderNames.PROXY_AUTHORIZATION.contentEqualsIgnoreCase(name) + || HttpHeaderNames.COOKIE.contentEqualsIgnoreCase(name) + || HttpHeaderNames.SET_COOKIE.contentEqualsIgnoreCase(name)); + } + + private static boolean isSensitiveLoggingEnabled() { + String propertyValue = System.getProperty(ENABLE_SENSITIVE_LOGGING_PROPERTY); + String value = propertyValue != null ? propertyValue : System.getenv(ENABLE_SENSITIVE_LOGGING_ENVIRONMENT_VARIABLE); + return Boolean.parseBoolean(value); + } +} diff --git a/client/src/test/java/org/asynchttpclient/RequestBuilderTest.java b/client/src/test/java/org/asynchttpclient/RequestBuilderTest.java index 2da2246d6..92bd21e42 100644 --- a/client/src/test/java/org/asynchttpclient/RequestBuilderTest.java +++ b/client/src/test/java/org/asynchttpclient/RequestBuilderTest.java @@ -21,6 +21,7 @@ import io.netty.handler.codec.http.HttpMethod; import io.netty.handler.codec.http.cookie.Cookie; import io.netty.handler.codec.http.cookie.DefaultCookie; +import org.junit.jupiter.api.Test; import java.util.Collection; import java.util.Collections; @@ -412,4 +413,23 @@ public void testAddHeaderWithIterableShouldLockContentType() { String contentType = request.getHeaders().get("Content-Type"); assertEquals("text/plain", contentType, "Content-Type set via addHeader(Iterable) should not be modified"); } + + @Test + public void testRequestToStringRedactsSensitiveHeaders() { + Request request = get("http://localhost/test") + .setHeader("Authorization", "Bearer request-secret") + .setHeader("Proxy-Authorization", "Basic proxy-secret") + .setHeader("Cookie", "session=cookie-secret") + .setHeader("X-Request-Id", "request-id") + .build(); + + String value = request.toString(); + assertFalse(value.contains("request-secret")); + assertFalse(value.contains("proxy-secret")); + assertFalse(value.contains("cookie-secret")); + assertTrue(value.contains("Authorization: ")); + assertTrue(value.contains("Proxy-Authorization: ")); + assertTrue(value.contains("Cookie: ")); + assertTrue(value.contains("request-id")); + } } diff --git a/client/src/test/java/org/asynchttpclient/netty/handler/HttpMessageFormatterTest.java b/client/src/test/java/org/asynchttpclient/netty/handler/HttpMessageFormatterTest.java new file mode 100644 index 000000000..1145fd0be --- /dev/null +++ b/client/src/test/java/org/asynchttpclient/netty/handler/HttpMessageFormatterTest.java @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2026 AsyncHttpClient Project. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.asynchttpclient.netty.handler; + +import io.netty.handler.codec.http.DefaultHttpRequest; +import io.netty.handler.codec.http.DefaultHttpResponse; +import io.netty.handler.codec.http.HttpMethod; +import io.netty.handler.codec.http.HttpRequest; +import io.netty.handler.codec.http.HttpResponse; +import io.netty.handler.codec.http.HttpResponseStatus; +import io.netty.handler.codec.http.HttpVersion; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.TimeUnit; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class HttpMessageFormatterTest { + + @TempDir + private Path tempDirectory; + + @Test + public void shouldRedactSensitiveRequestHeaders() { + HttpRequest request = new DefaultHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.GET, "/test"); + request.headers() + .set("Authorization", "Bearer request-secret") + .set("proxy-authorization", "Basic proxy-secret") + .set("Cookie", "session=cookie-secret") + .set("X-Request-Id", "request-id"); + + String value = HttpMessageFormatter.format(request); + + assertFalse(value.contains("request-secret")); + assertFalse(value.contains("proxy-secret")); + assertFalse(value.contains("cookie-secret")); + assertTrue(value.contains("Authorization: ")); + assertTrue(value.contains("proxy-authorization: ")); + assertTrue(value.contains("X-Request-Id: request-id")); + } + + @Test + public void shouldRedactSensitiveResponseHeaders() { + HttpResponse response = new DefaultHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK); + response.headers() + .set("Set-Cookie", "session=response-secret") + .set("X-Request-Id", "request-id"); + + String value = HttpMessageFormatter.format(response); + + assertFalse(value.contains("response-secret")); + assertTrue(value.contains("Set-Cookie: ")); + assertTrue(value.contains("X-Request-Id: request-id")); + } + + @Test + public void shouldIncludeSensitiveHeadersWhenSystemPropertyEnabled() throws Exception { + String output = runProbe("true", null); + + assertTrue(output.contains("Authorization: Bearer request-secret"), output); + assertTrue(output.contains("Sensitive HTTP header logging is enabled"), output); + } + + @Test + public void shouldIncludeSensitiveHeadersWhenEnvironmentVariableEnabled() throws Exception { + String output = runProbe(null, "true"); + + assertTrue(output.contains("Authorization: Bearer request-secret"), output); + assertTrue(output.contains("Sensitive HTTP header logging is enabled"), output); + } + + @Test + public void systemPropertyShouldOverrideEnvironmentVariable() throws Exception { + String output = runProbe("false", "true"); + + assertTrue(output.contains("Authorization: "), output); + assertFalse(output.contains("Sensitive HTTP header logging is enabled"), output); + } + + private String runProbe(String propertyValue, String environmentValue) throws Exception { + List command = new ArrayList<>(); + command.add(javaExecutable().toString()); + if (propertyValue != null) { + command.add("-Dorg.asynchttpclient.enableSensitiveLogging=" + propertyValue); + } + command.add("-cp"); + command.add(System.getProperty("surefire.test.class.path", System.getProperty("java.class.path"))); + command.add(SensitiveLoggingProbe.class.getName()); + + Path outputFile = Files.createTempFile(tempDirectory, "sensitive-logging-probe-", ".log"); + ProcessBuilder processBuilder = new ProcessBuilder(command) + .redirectErrorStream(true) + .redirectOutput(outputFile.toFile()); + if (environmentValue == null) { + processBuilder.environment().remove("AHC_ENABLE_SENSITIVE_LOGGING"); + } else { + processBuilder.environment().put("AHC_ENABLE_SENSITIVE_LOGGING", environmentValue); + } + + Process process = processBuilder.start(); + if (!process.waitFor(30, TimeUnit.SECONDS)) { + process.destroyForcibly(); + process.waitFor(10, TimeUnit.SECONDS); + throw new AssertionError("Sensitive logging probe timed out\n" + Files.readString(outputFile, UTF_8)); + } + String output = Files.readString(outputFile, UTF_8); + assertEquals(0, process.exitValue(), output); + return output; + } + + private static Path javaExecutable() throws IOException { + String executable = System.getProperty("os.name").toLowerCase(Locale.ROOT).contains("win") + ? "java.exe" + : "java"; + Path path = Paths.get(System.getProperty("java.home"), "bin", executable); + return path.toRealPath(); + } +} diff --git a/client/src/test/java/org/asynchttpclient/netty/handler/SensitiveLoggingProbe.java b/client/src/test/java/org/asynchttpclient/netty/handler/SensitiveLoggingProbe.java new file mode 100644 index 000000000..bd9c9e11d --- /dev/null +++ b/client/src/test/java/org/asynchttpclient/netty/handler/SensitiveLoggingProbe.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2026 AsyncHttpClient Project. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.asynchttpclient.netty.handler; + +import io.netty.handler.codec.http.DefaultHttpRequest; +import io.netty.handler.codec.http.HttpMethod; +import io.netty.handler.codec.http.HttpRequest; +import io.netty.handler.codec.http.HttpVersion; + +public final class SensitiveLoggingProbe { + + private SensitiveLoggingProbe() { + } + + public static void main(String[] args) { + HttpRequest request = new DefaultHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.GET, "/test"); + request.headers().set("Authorization", "Bearer request-secret"); + System.out.println(HttpMessageFormatter.format(request)); + } +}