Redact sensitive AWS auth headers (Authorization, X-Amz-Security-Toke… (#5799)
## Issue Closes #5798 ## Change When `logRequests`/`logResponses` are enabled, `AwsLoggingInterceptor` logs the full HTTP header set. Because `beforeTransmission` runs after SigV4 signing, this includes the `Authorization` header (access key ID + signature) and, when temporary/STS credentials are used (assumed roles, EC2/ECS/EKS instance roles, IAM Identity Center), the `X-Amz-Security-Token` a live, replayable session token writing credentials to whatever log sink is configured (CWE-532). This PR masks the values of these two headers (case-insensitive) with `[REDACTED]` before logging, via a small package-private `maskHeaders()` helper. This matches the header redaction already used elsewhere in langchain4j (core `HttpRequestLogger`, and the cohere/jina/ovh-ai/nomic request logging interceptors) and the AWS SDK's own wire logging, which never emits these values. All other headers, URL, query parameters, and body are logged unchanged. No public API or request-behavior change only debug log text. Adds `AwsLoggingInterceptorTest` (7 unit tests covering redaction of both headers, case variants, non-sensitive header preservation, multi-value headers, and null/empty maps). Note: the import re-sort in the diff is mandated by the repo's spotless config (matches sibling `BedrockCustomHeadersInterceptor`); no other reformatting was done. `spotless:check` passes. ## General checklist <!-- Please double-check the following points and mark them like this: [X] --> - [X] There are no breaking changes (API, behaviour) - [X] I have added unit and/or integration tests for my change - [X] The tests cover both positive and negative cases - [X] I have manually run all the unit and integration tests in the module I have added/changed, and they are all green - [ ] I have manually run all the unit and integration tests in the [core](https://github.com/langchain4j/langchain4j/tree/main/langchain4j-core) and [main](https://github.com/langchain4j/langchain4j/tree/main/langchain4j) modules, and they are all green ## Checklist for adding new maven module <!-- Please double-check the following points and mark them like this: [X] --> - [ ] I have added my new module in the root `pom.xml` and `langchain4j-bom/pom.xml` ## Checklist for adding new embedding store integration <!-- Please double-check the following points and mark them like this: [X] --> - [ ] I have added a `{NameOfIntegration}EmbeddingStoreIT` that extends from either `EmbeddingStoreIT` or `EmbeddingStoreWithFilteringIT` - [ ] I have added a `{NameOfIntegration}EmbeddingStoreRemovalIT` that extends from `EmbeddingStoreWithRemovalIT` ## Checklist for changing existing embedding store integration <!-- Please double-check the following points and mark them like this: [X] --> - [ ] I have manually verified that the `{NameOfIntegration}EmbeddingStore` works correctly with the data persisted using the latest released version of LangChain4j
This commit is contained in:
parent
73ec8a22cb
commit
c27e6e9c2d
|
|
@ -4,13 +4,17 @@ import static dev.langchain4j.internal.Utils.getOrDefault;
|
||||||
import static java.util.Objects.isNull;
|
import static java.util.Objects.isNull;
|
||||||
import static java.util.Objects.nonNull;
|
import static java.util.Objects.nonNull;
|
||||||
|
|
||||||
|
import dev.langchain4j.Internal;
|
||||||
import java.io.ByteArrayInputStream;
|
import java.io.ByteArrayInputStream;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
import dev.langchain4j.Internal;
|
import java.util.stream.Collectors;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import software.amazon.awssdk.core.interceptor.Context;
|
import software.amazon.awssdk.core.interceptor.Context;
|
||||||
|
|
@ -28,6 +32,11 @@ class AwsLoggingInterceptor implements ExecutionInterceptor {
|
||||||
|
|
||||||
private static final Logger DEFAULT_LOGGER = LoggerFactory.getLogger(AwsLoggingInterceptor.class);
|
private static final Logger DEFAULT_LOGGER = LoggerFactory.getLogger(AwsLoggingInterceptor.class);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authentication headers whose values must never be written to logs. Compared case-insensitively.
|
||||||
|
*/
|
||||||
|
private static final Set<String> SENSITIVE_HEADERS = Set.of("authorization", "x-amz-security-token");
|
||||||
|
|
||||||
private final boolean logRequests;
|
private final boolean logRequests;
|
||||||
private final boolean logResponses;
|
private final boolean logResponses;
|
||||||
private final Logger logger;
|
private final Logger logger;
|
||||||
|
|
@ -51,7 +60,8 @@ class AwsLoggingInterceptor implements ExecutionInterceptor {
|
||||||
if (logRequests) {
|
if (logRequests) {
|
||||||
if (request.method() == SdkHttpMethod.POST && request instanceof SdkHttpFullRequest sdkHttpFullRequest) {
|
if (request.method() == SdkHttpMethod.POST && request instanceof SdkHttpFullRequest sdkHttpFullRequest) {
|
||||||
try {
|
try {
|
||||||
ContentStreamProvider csp = sdkHttpFullRequest.contentStreamProvider().orElse(null);
|
ContentStreamProvider csp =
|
||||||
|
sdkHttpFullRequest.contentStreamProvider().orElse(null);
|
||||||
if (nonNull(csp)) body = IoUtils.toUtf8String(csp.newStream());
|
if (nonNull(csp)) body = IoUtils.toUtf8String(csp.newStream());
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
logger.warn("Unable to obtain request body", e);
|
logger.warn("Unable to obtain request body", e);
|
||||||
|
|
@ -61,10 +71,9 @@ class AwsLoggingInterceptor implements ExecutionInterceptor {
|
||||||
"Request:\n- method: {}\n- url: {}\n- headers: {}\n- query parameters: {}\n- body: {}",
|
"Request:\n- method: {}\n- url: {}\n- headers: {}\n- query parameters: {}\n- body: {}",
|
||||||
request.method(),
|
request.method(),
|
||||||
request.getUri(),
|
request.getUri(),
|
||||||
request.headers(),
|
maskHeaders(request.headers()),
|
||||||
request.rawQueryParameters(),
|
request.rawQueryParameters(),
|
||||||
body
|
body);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -80,7 +89,7 @@ class AwsLoggingInterceptor implements ExecutionInterceptor {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Response Status: {} \nHeaders: {} \nResponse Body Type: {}",
|
"Response Status: {} \nHeaders: {} \nResponse Body Type: {}",
|
||||||
response.statusCode(),
|
response.statusCode(),
|
||||||
response.headers(),
|
maskHeaders(response.headers()),
|
||||||
context.response().getClass().getSimpleName());
|
context.response().getClass().getSimpleName());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -100,4 +109,24 @@ class AwsLoggingInterceptor implements ExecutionInterceptor {
|
||||||
}
|
}
|
||||||
return isNull(content) ? Optional.empty() : Optional.of(new ByteArrayInputStream(content));
|
return isNull(content) ? Optional.empty() : Optional.of(new ByteArrayInputStream(content));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders HTTP headers for logging, replacing the values of sensitive authentication headers
|
||||||
|
* (e.g. {@code Authorization}, {@code X-Amz-Security-Token}) with a placeholder so that
|
||||||
|
* credentials such as SigV4 signatures and temporary session tokens are not written to logs.
|
||||||
|
* Header-name matching is case-insensitive; all other headers are rendered unchanged.
|
||||||
|
*/
|
||||||
|
static String maskHeaders(Map<String, List<String>> headers) {
|
||||||
|
if (headers == null || headers.isEmpty()) {
|
||||||
|
return "{}";
|
||||||
|
}
|
||||||
|
return headers.entrySet().stream()
|
||||||
|
.map(entry -> {
|
||||||
|
if (SENSITIVE_HEADERS.contains(entry.getKey().toLowerCase(Locale.ROOT))) {
|
||||||
|
return entry.getKey() + "=[REDACTED]";
|
||||||
|
}
|
||||||
|
return entry.getKey() + "=" + entry.getValue();
|
||||||
|
})
|
||||||
|
.collect(Collectors.joining(", ", "{", "}"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
package dev.langchain4j.model.bedrock;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class AwsLoggingInterceptorTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_redact_authorization_header() {
|
||||||
|
Map<String, List<String>> headers = new LinkedHashMap<>();
|
||||||
|
headers.put(
|
||||||
|
"Authorization",
|
||||||
|
List.of("AWS4-HMAC-SHA256 Credential=AKIAEXAMPLE/20240101/us-east-1/bedrock/aws4_request, "
|
||||||
|
+ "SignedHeaders=host;x-amz-date, Signature=deadbeefdeadbeef"));
|
||||||
|
headers.put("Content-Type", List.of("application/json"));
|
||||||
|
|
||||||
|
String result = AwsLoggingInterceptor.maskHeaders(headers);
|
||||||
|
|
||||||
|
assertThat(result).contains("Authorization=[REDACTED]");
|
||||||
|
assertThat(result).doesNotContain("AKIAEXAMPLE");
|
||||||
|
assertThat(result).doesNotContain("deadbeefdeadbeef");
|
||||||
|
assertThat(result).contains("Content-Type=[application/json]");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_redact_session_token_case_insensitively() {
|
||||||
|
Map<String, List<String>> headers = new LinkedHashMap<>();
|
||||||
|
headers.put("x-amz-security-token", List.of("FwoGZXIvYXdzEXAMPLESESSIONTOKEN"));
|
||||||
|
|
||||||
|
String result = AwsLoggingInterceptor.maskHeaders(headers);
|
||||||
|
|
||||||
|
assertThat(result).contains("x-amz-security-token=[REDACTED]");
|
||||||
|
assertThat(result).doesNotContain("EXAMPLESESSIONTOKEN");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_redact_authorization_regardless_of_case() {
|
||||||
|
Map<String, List<String>> headers = new LinkedHashMap<>();
|
||||||
|
headers.put("AUTHORIZATION", List.of("super-secret-value"));
|
||||||
|
|
||||||
|
String result = AwsLoggingInterceptor.maskHeaders(headers);
|
||||||
|
|
||||||
|
assertThat(result).contains("AUTHORIZATION=[REDACTED]");
|
||||||
|
assertThat(result).doesNotContain("super-secret-value");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_preserve_non_sensitive_headers() {
|
||||||
|
Map<String, List<String>> headers = new LinkedHashMap<>();
|
||||||
|
headers.put("X-Amz-Date", List.of("20240101T000000Z"));
|
||||||
|
headers.put("Host", List.of("bedrock-runtime.us-east-1.amazonaws.com"));
|
||||||
|
|
||||||
|
String result = AwsLoggingInterceptor.maskHeaders(headers);
|
||||||
|
|
||||||
|
assertThat(result).contains("X-Amz-Date=[20240101T000000Z]");
|
||||||
|
assertThat(result).contains("Host=[bedrock-runtime.us-east-1.amazonaws.com]");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_preserve_multiple_values_for_non_sensitive_headers() {
|
||||||
|
Map<String, List<String>> headers = new LinkedHashMap<>();
|
||||||
|
headers.put("Accept", List.of("application/json", "text/event-stream"));
|
||||||
|
|
||||||
|
String result = AwsLoggingInterceptor.maskHeaders(headers);
|
||||||
|
|
||||||
|
assertThat(result).contains("Accept=[application/json, text/event-stream]");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_handle_empty_map() {
|
||||||
|
assertThat(AwsLoggingInterceptor.maskHeaders(Map.of())).isEqualTo("{}");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_handle_null_map() {
|
||||||
|
assertThat(AwsLoggingInterceptor.maskHeaders(null)).isEqualTo("{}");
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue