diff --git a/client/src/main/java/org/asynchttpclient/DefaultRequest.java b/client/src/main/java/org/asynchttpclient/DefaultRequest.java index 09c615d2a..a3b94101e 100644 --- a/client/src/main/java/org/asynchttpclient/DefaultRequest.java +++ b/client/src/main/java/org/asynchttpclient/DefaultRequest.java @@ -37,6 +37,8 @@ import java.util.List; import java.util.Map; +import static org.asynchttpclient.netty.handler.HttpMessageFormatter.REDACTED; +import static org.asynchttpclient.netty.handler.HttpMessageFormatter.isSensitiveHeader; import static org.asynchttpclient.util.MiscUtils.isNonEmpty; public class DefaultRequest implements Request { @@ -293,7 +295,7 @@ public String toString() { sb.append('\t'); sb.append(header.getKey()); sb.append(':'); - sb.append(header.getValue()); + sb.append(isSensitiveHeader(header.getKey()) ? REDACTED : header.getValue()); } } 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..9f10e0c83 --- /dev/null +++ b/client/src/main/java/org/asynchttpclient/netty/handler/HttpMessageFormatter.java @@ -0,0 +1,84 @@ +/* + * 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.HttpHeaderNames; +import io.netty.handler.codec.http.HttpHeaders; +import io.netty.handler.codec.http.HttpRequest; +import io.netty.handler.codec.http.HttpResponse; + +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} when this class is initialized. Enabling sensitive logging may expose credentials and + * session data. + */ +public final class HttpMessageFormatter { + + 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 = ""; + + 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; + } + + 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); + } + + /** + * 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)); + } +} 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/test/java/org/asynchttpclient/RequestBuilderTest.java b/client/src/test/java/org/asynchttpclient/RequestBuilderTest.java index 2da2246d6..a0845d3ff 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..87050b211 --- /dev/null +++ b/client/src/test/java/org/asynchttpclient/netty/handler/HttpMessageFormatterTest.java @@ -0,0 +1,89 @@ +/* + * 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.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class HttpMessageFormatterTest { + + @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 + @EnabledIfSystemProperty(named = "org.asynchttpclient.enableSensitiveLogging", matches = "(?i)true") + public void shouldIncludeSensitiveHeadersWhenSystemPropertyEnabled() { + assertSensitiveHeadersIncluded(); + } + + @Test + @EnabledIfEnvironmentVariable(named = "AHC_ENABLE_SENSITIVE_LOGGING", matches = "(?i)true") + public void shouldIncludeSensitiveHeadersWhenEnvironmentVariableEnabled() { + assertSensitiveHeadersIncluded(); + } + + private static void assertSensitiveHeadersIncluded() { + HttpRequest request = new DefaultHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.GET, "/test"); + request.headers().set("Authorization", "Bearer request-secret"); + + String value = HttpMessageFormatter.format(request); + + assertFalse(HttpMessageFormatter.isSensitiveHeader("Authorization")); + assertTrue(value.contains("Authorization: Bearer request-secret")); + assertFalse(value.contains(HttpMessageFormatter.REDACTED)); + } +}