From b5e2b1ff2a28e855bd65137607c4492febbbcbbe Mon Sep 17 00:00:00 2001 From: Pavel Ptashyts <49400901+pavel-ptashyts@users.noreply.github.com> Date: Mon, 20 Jul 2026 22:40:37 +0200 Subject: [PATCH 1/2] Redact sensitive headers from debug logs Request and response debug logging rendered complete Netty messages, which exposed Authorization values and other credentials. Format logged HTTP messages with shared redaction for authentication and cookie headers. Reuse the same public predicate in DefaultRequest, and avoid rendering complete requests from retry and replay paths. Add regression coverage for HTTP message formatting and request string representations. Fixes #1739 Fixes #1740 Codex on behalf of Pavel Ptashyts Co-Authored-By: Codex --- .../org/asynchttpclient/DefaultRequest.java | 4 +- .../netty/handler/HttpHandler.java | 4 +- .../netty/handler/HttpMessageFormatter.java | 69 +++++++++++++++++++ .../netty/handler/WebSocketHandler.java | 2 +- .../netty/request/NettyRequestSender.java | 5 +- .../asynchttpclient/RequestBuilderTest.java | 20 ++++++ .../handler/HttpMessageFormatterTest.java | 64 +++++++++++++++++ 7 files changed, 163 insertions(+), 5 deletions(-) create mode 100644 client/src/main/java/org/asynchttpclient/netty/handler/HttpMessageFormatter.java create mode 100644 client/src/test/java/org/asynchttpclient/netty/handler/HttpMessageFormatterTest.java diff --git a/client/src/main/java/org/asynchttpclient/DefaultRequest.java b/client/src/main/java/org/asynchttpclient/DefaultRequest.java index 09c615d2a2..a3b94101ef 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 99a23c7e96..c09db7b812 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 0000000000..8f2fed385f --- /dev/null +++ b/client/src/main/java/org/asynchttpclient/netty/handler/HttpMessageFormatter.java @@ -0,0 +1,69 @@ +/* + * 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 without rendering sensitive header values. */ +public final class HttpMessageFormatter { + + /** 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; + } + + /** + * Returns whether a header value must be redacted from logs. + * + * @param name the header name + * @return {@code true} for authentication and cookie headers + */ + public static boolean isSensitiveHeader(CharSequence name) { + return 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 3ffd781b53..ff9603f4e8 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 36af9019b1..4e02567579 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 2da2246d6c..a0845d3ff3 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 0000000000..a9af5b6c30 --- /dev/null +++ b/client/src/test/java/org/asynchttpclient/netty/handler/HttpMessageFormatterTest.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.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 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")); + } +} From 2a7f11b847b7828beb4b175600b454edb2cbdd53 Mon Sep 17 00:00:00 2001 From: Pavel Ptashyts <49400901+pavel-ptashyts@users.noreply.github.com> Date: Tue, 21 Jul 2026 00:04:52 +0200 Subject: [PATCH 2/2] Allow sensitive logging at startup Redaction is secure by default, but it can prevent diagnosis of authentication failures that only occur in CI. Read a JVM property or environment variable once when the HTTP message formatter is initialized. Fold that immutable setting into isSensitiveHeader so both Netty logging and DefaultRequest use the same decision without repeated configuration lookups. Refs #1739 Refs #1740 Codex on behalf of Pavel Ptashyts Co-Authored-By: Codex --- .../netty/handler/HttpMessageFormatter.java | 23 ++++++++++++++--- .../handler/HttpMessageFormatterTest.java | 25 +++++++++++++++++++ 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/client/src/main/java/org/asynchttpclient/netty/handler/HttpMessageFormatter.java b/client/src/main/java/org/asynchttpclient/netty/handler/HttpMessageFormatter.java index 8f2fed385f..9f10e0c835 100644 --- a/client/src/main/java/org/asynchttpclient/netty/handler/HttpMessageFormatter.java +++ b/client/src/main/java/org/asynchttpclient/netty/handler/HttpMessageFormatter.java @@ -22,9 +22,18 @@ import java.util.Map; -/** Formats HTTP messages for logging without rendering sensitive header values. */ +/** + * 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 = ""; @@ -54,16 +63,22 @@ private static StringBuilder appendHeaders(StringBuilder value, HttpHeaders head 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 + * @return {@code true} for authentication and cookie headers when sensitive logging is disabled */ public static boolean isSensitiveHeader(CharSequence name) { - return HttpHeaderNames.AUTHORIZATION.contentEqualsIgnoreCase(name) + return !SENSITIVE_LOGGING_ENABLED && (HttpHeaderNames.AUTHORIZATION.contentEqualsIgnoreCase(name) || HttpHeaderNames.PROXY_AUTHORIZATION.contentEqualsIgnoreCase(name) || HttpHeaderNames.COOKIE.contentEqualsIgnoreCase(name) - || HttpHeaderNames.SET_COOKIE.contentEqualsIgnoreCase(name); + || HttpHeaderNames.SET_COOKIE.contentEqualsIgnoreCase(name)); } } diff --git a/client/src/test/java/org/asynchttpclient/netty/handler/HttpMessageFormatterTest.java b/client/src/test/java/org/asynchttpclient/netty/handler/HttpMessageFormatterTest.java index a9af5b6c30..87050b211e 100644 --- a/client/src/test/java/org/asynchttpclient/netty/handler/HttpMessageFormatterTest.java +++ b/client/src/test/java/org/asynchttpclient/netty/handler/HttpMessageFormatterTest.java @@ -23,6 +23,8 @@ 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; @@ -61,4 +63,27 @@ public void shouldRedactSensitiveResponseHeaders() { 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)); + } }