Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
import static com.google.api.gax.util.TimeConversionUtils.toThreetenInstant;

import com.google.api.core.ObsoleteApi;
import com.google.api.gax.tracing.ApiTracer;
import com.google.auth.Credentials;
import com.google.auto.value.AutoValue;
import com.google.protobuf.TypeRegistry;
Expand Down Expand Up @@ -71,6 +72,9 @@ public final org.threeten.bp.Instant getDeadline() {
@Nullable
public abstract TypeRegistry getTypeRegistry();

@Nullable
public abstract ApiTracer getTracer();

public abstract Builder toBuilder();

public static Builder newBuilder() {
Expand Down Expand Up @@ -106,6 +110,11 @@ public HttpJsonCallOptions merge(HttpJsonCallOptions inputOptions) {
builder.setTypeRegistry(newTypeRegistry);
}

ApiTracer newTracer = inputOptions.getTracer();
if (newTracer != null) {
builder.setTracer(newTracer);
}

return builder.build();
}

Expand All @@ -131,6 +140,8 @@ public final Builder setDeadline(org.threeten.bp.Instant value) {

public abstract Builder setTypeRegistry(TypeRegistry value);

public abstract Builder setTracer(ApiTracer value);

public abstract HttpJsonCallOptions build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,13 @@ public static <RequestT, ResponseT> HttpJsonClientCall<RequestT, ResponseT> newC
// if the Universe Domain is valid.
httpJsonContext.validateUniverseDomain();

HttpJsonCallOptions callOptions = httpJsonContext.getCallOptions();
if (httpJsonContext.getTracer() != null) {
callOptions = callOptions.toBuilder().setTracer(httpJsonContext.getTracer()).build();
}

// TODO: add headers interceptor logic
return httpJsonContext.getChannel().newCall(methodDescriptor, httpJsonContext.getCallOptions());
return httpJsonContext.getChannel().newCall(methodDescriptor, callOptions);
}

static <RequestT, ResponseT> ApiFuture<ResponseT> futureUnaryCall(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
import com.google.api.client.json.JsonObjectParser;
import com.google.api.client.json.gson.GsonFactory;
import com.google.api.client.util.GenericData;
import com.google.api.gax.tracing.ApiTracer;
import com.google.auth.Credentials;
import com.google.auth.http.HttpCredentialsAdapter;
import com.google.auto.value.AutoValue;
Expand Down Expand Up @@ -188,6 +189,11 @@ HttpRequest createHttpRequest() throws IOException {
}
}

ApiTracer tracer = httpJsonCallOptions.getTracer();
if (tracer != null) {
tracer.requestUrlResolved(url.build());
}

HttpRequest httpRequest = buildRequest(requestFactory, url, jsonHttpContent);

for (Map.Entry<String, Object> entry : headers.getHeaders().entrySet()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,14 @@ default void requestSent() {}
default void batchRequestSent(long elementCount, long requestSize) {}
;

/**
* Annotates the attempt with the full resolved HTTP URL. Only relevant for HTTP transport.
*
* @param requestUrl the full URL of the request
*/
default void requestUrlResolved(String requestUrl) {}
;

/**
* A context class to be used with {@link #inScope()} and a try-with-resources block. Closing a
* {@link Scope} removes any context that the underlying implementation might've set in {@link
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,4 +93,7 @@ public class ObservabilityAttributes {

/** The destination resource id of the request (e.g. projects/p/locations/l/topics/t). */
public static final String DESTINATION_RESOURCE_ID_ATTRIBUTE = "gcp.resource.destination.id";

/** The full URL of the HTTP request, with sensitive query parameters redacted. */
public static final String HTTP_URL_FULL_ATTRIBUTE = "url.full";
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@

import com.google.api.gax.rpc.ApiException;
import com.google.api.gax.rpc.StatusCode;
import com.google.common.base.Joiner;
import com.google.common.base.Splitter;
import com.google.common.collect.ImmutableSet;
import io.opentelemetry.api.common.Attributes;
import io.opentelemetry.api.common.AttributesBuilder;
import java.util.Map;
Expand All @@ -39,6 +42,107 @@

class ObservabilityUtils {

private static final String REDACTED_VALUE = "REDACTED";

/**
* A set of lowercase query parameter keys whose values should be redacted in URLs for
* observability. These include direct credentials (access keys), cryptographic signatures (to
* prevent replay attacks or leak of authorization), and session identifiers (like upload_id).
*/
private static final ImmutableSet<String> SENSITIVE_QUERY_KEYS =
ImmutableSet.of(
"awsaccesskeyid", // AWS S3-compatible access keys
"signature", // General cryptographic signature
"sig", // General cryptographic signature (abbreviated)
"x-goog-signature", // Google Cloud specific signature
"upload_id", // Resumable upload session identifiers
"access_token", // OAuth2 explicit tokens
"key", // API Keys
"api_key"); // API Keys

/**
* Sanitizes an HTTP URL by redacting sensitive query parameters and credentials in the user-info
* component. If the provided URL cannot be parsed (e.g. invalid syntax), it gracefully returns
* the original string.
*
* <p>This sanitization process conforms to the recommendations in footnote 3 of the OpenTelemetry
* semantic conventions for HTTP URL attributes:
* https://opentelemetry.io/docs/specs/semconv/registry/attributes/url/
*
* <ul>
* <li><i>"url.full MUST NOT contain credentials passed via URL in form of
* https://username:password@www.example.com/. In such case username and password SHOULD be
* redacted and attribute’s value SHOULD be https://REDACTED:REDACTED@www.example.com/."</i>
* - Handled by stripping the raw user info component.
* <li><i>"url.full SHOULD capture the absolute URL when it is available (or can be
* reconstructed)."</i> - Handled by parsing and rebuilding the generic URI.
* <li><i>"When a query string value is redacted, the query string key SHOULD still be
* preserved, e.g. https://www.example.com/path?color=blue&sig=REDACTED."</i> - Handled by
* the redactSensitiveQueryValues method.
* </ul>
*
* @param url the raw URL string
* @return the sanitized URL string, or the original if unparsable
*/
static String sanitizeUrlFull(String url) {
try {
java.net.URI uri = new java.net.URI(url);
String sanitizedUserInfo =
uri.getRawUserInfo() != null ? REDACTED_VALUE + ":" + REDACTED_VALUE : null;
String sanitizedQuery = redactSensitiveQueryValues(uri.getRawQuery());
java.net.URI sanitizedUri =
new java.net.URI(
uri.getScheme(),
sanitizedUserInfo,
uri.getHost(),
uri.getPort(),
uri.getRawPath(),
sanitizedQuery,
uri.getRawFragment());
return sanitizedUri.toString();
} catch (java.net.URISyntaxException | IllegalArgumentException ex) {
return url;
}
}
Comment thread
diegomarquezp marked this conversation as resolved.
Outdated

/**
* Redacts the values of sensitive keys within a raw URI query string.
*
* <p>This logic splits the query string by the `&` delimiter without full URL decoding, ensures
* only values belonging to predefined sensitive keys are replaced with {@code REDACTED_VALUE}.
* The check is strictly case-insensitive.
*
* <p>Note regarding Footnote 3: The OpenTelemetry spec recommends case-sensitive matching for
* query parameters. However, we intentionally utilize purely case-insensitive matching (by
* lowercasing all query keys during the lookup) to prevent credentials bypassing validation when
* sent with mixed casings (e.g., Sig=..., API_KEY=...).
*
* @param rawQuery the raw query string from a java.net.URI (e.g., "key1=value1&key2=value2")
* @return a reconstructed query sequence with sensitive values redacted
*/
private static String redactSensitiveQueryValues(String rawQuery) {
if (rawQuery == null || rawQuery.isEmpty()) {
return rawQuery;
}

java.util.List<String> redactedParams =
Splitter.on('&').splitToList(rawQuery).stream()
.map(
param -> {
int equalsIndex = param.indexOf('=');
String key = equalsIndex >= 0 ? param.substring(0, equalsIndex) : param;
// Case-insensitive match utilizing the fact that all predefined keys are in
// lowercase
if (SENSITIVE_QUERY_KEYS.contains(key.toLowerCase(java.util.Locale.US))) {
return key + "=" + REDACTED_VALUE;
}
return param;
})
.collect(java.util.stream.Collectors.toList());
Comment thread
diegomarquezp marked this conversation as resolved.

return Joiner.on('&').join(redactedParams);
}

/** Function to extract the status of the error as a string */
static String extractStatus(@Nullable Throwable error) {
final String statusString;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,4 +160,12 @@ private void endAttempt() {
attemptSpan = null;
}
}

@Override
public void requestUrlResolved(String url) {
if (attemptSpan != null && url != null) {
attemptSpan.setAttribute(
ObservabilityAttributes.HTTP_URL_FULL_ATTRIBUTE, ObservabilityUtils.sanitizeUrlFull(url));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -118,4 +118,36 @@ void testToOtelAttributes_shouldMapIntAttributes() {
void testToOtelAttributes_shouldReturnEmptyAttributes_nullInput() {
assertThat(ObservabilityUtils.toOtelAttributes(null)).isEqualTo(Attributes.empty());
}

@Test
void testSanitizeUrlFull_redactsUserInfo() {
String url = "https://user:password@example.com/some/path?foo=bar";
String sanitized = ObservabilityUtils.sanitizeUrlFull(url);
assertThat(sanitized).isEqualTo("https://REDACTED:REDACTED@example.com/some/path?foo=bar");
}

@Test
void testSanitizeUrlFull_redactsSensitiveQueryParameters_caseInsensitive() {
String url =
"https://example.com/some/path?upload_Id=secret&AWSAccessKeyId=123&foo=bar&API_KEY=my_key";
String sanitized = ObservabilityUtils.sanitizeUrlFull(url);
assertThat(sanitized)
.isEqualTo(
"https://example.com/some/path?upload_Id=REDACTED&AWSAccessKeyId=REDACTED&foo=bar&API_KEY=REDACTED");
}

@Test
void testSanitizeUrlFull_handlesMalformedUrl() {
String url = "invalid::url:";
String sanitized = ObservabilityUtils.sanitizeUrlFull(url);
// Unparsable URLs should be returned as-is
assertThat(sanitized).isEqualTo(url);
}

@Test
void testSanitizeUrlFull_noQueryOrUserInfo() {
String url = "https://example.com/some/path";
String sanitized = ObservabilityUtils.sanitizeUrlFull(url);
assertThat(sanitized).isEqualTo(url);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -186,4 +186,17 @@ void testAttemptStarted_retryAttributes_http() {
ObservabilityAttributes.HTTP_RESEND_COUNT_ATTRIBUTE),
5L);
}

@Test
void testRequestUrlResolved_setsAttribute() {
spanTracer.attemptStarted(new Object(), 1);

String rawUrl = "https://example.com?api_key=secret";
spanTracer.requestUrlResolved(rawUrl);

verify(span)
.setAttribute(
ObservabilityAttributes.HTTP_URL_FULL_ATTRIBUTE,
"https://example.com?api_key=REDACTED");
}
}
Loading