Skip to content

Redact specific url query string values and url credentials in instrumentations #3508

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `opentelemetry-resource-detector-containerid`: make it more quiet on platforms without cgroups
([#3579](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3579))

### Added

- `opentelemetry-util-http` Added support for redacting specific url query string values and url credentials in instrumentations
([#3508](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3508))

## Version 1.34.0/0.55b0 (2025-06-04)

### Fixed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ def response_hook(span: Span, params: typing.Union[
)
from opentelemetry.trace import Span, SpanKind, TracerProvider, get_tracer
from opentelemetry.trace.status import Status, StatusCode
from opentelemetry.util.http import remove_url_credentials, sanitize_method
from opentelemetry.util.http import redact_url, sanitize_method

_UrlFilterT = typing.Optional[typing.Callable[[yarl.URL], str]]
_RequestHookT = typing.Optional[
Expand Down Expand Up @@ -311,9 +311,9 @@ async def on_request_start(
method = params.method
request_span_name = _get_span_name(method)
request_url = (
remove_url_credentials(trace_config_ctx.url_filter(params.url))
redact_url(trace_config_ctx.url_filter(params.url))
if callable(trace_config_ctx.url_filter)
else remove_url_credentials(str(params.url))
else redact_url(str(params.url))
)

span_attributes = {}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -762,16 +762,16 @@ async def do_request(url):
)
self.memory_exporter.clear()

def test_credential_removal(self):
def test_remove_sensitive_params(self):
trace_configs = [aiohttp_client.create_trace_config()]

app = HttpServerMock("test_credential_removal")
app = HttpServerMock("test_remove_sensitive_params")

@app.route("/status/200")
def index():
return "hello"

url = "http://username:password@localhost:5000/status/200"
url = "http://username:password@localhost:5000/status/200?Signature=secret"

with app.run("localhost", 5000):
with self.subTest(url=url):
Expand All @@ -793,7 +793,9 @@ async def do_request(url):
(StatusCode.UNSET, None),
{
HTTP_METHOD: "GET",
HTTP_URL: ("http://localhost:5000/status/200"),
HTTP_URL: (
"http://REDACTED:REDACTED@localhost:5000/status/200?Signature=REDACTED"
),
HTTP_STATUS_CODE: int(HTTPStatus.OK),
},
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ async def hello(request):
)
from opentelemetry.semconv.metrics import MetricInstruments
from opentelemetry.trace.status import Status, StatusCode
from opentelemetry.util.http import get_excluded_urls, remove_url_credentials
from opentelemetry.util.http import get_excluded_urls, redact_url

_duration_attrs = [
HTTP_METHOD,
Expand Down Expand Up @@ -148,6 +148,7 @@ def collect_request_attributes(request: web.Request) -> Dict:
request.url.port,
str(request.url),
)

query_string = request.query_string
if query_string and http_url:
if isinstance(query_string, bytes):
Expand All @@ -161,7 +162,7 @@ def collect_request_attributes(request: web.Request) -> Dict:
HTTP_ROUTE: _get_view_func(request),
HTTP_FLAVOR: f"{request.version.major}.{request.version.minor}",
HTTP_TARGET: request.path,
HTTP_URL: remove_url_credentials(http_url),
HTTP_URL: redact_url(http_url),
}

http_method = request.method
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,3 +152,46 @@ async def test_suppress_instrumentation(
await client.get("/test-path")

assert len(memory_exporter.get_finished_spans()) == 0


@pytest.mark.asyncio
async def test_remove_sensitive_params(tracer, aiohttp_server):
"""Test that sensitive information in URLs is properly redacted."""
_, memory_exporter = tracer

# Set up instrumentation
AioHttpServerInstrumentor().instrument()

# Create app with test route
app = aiohttp.web.Application()

async def handler(request):
return aiohttp.web.Response(text="hello")

app.router.add_get("/status/200", handler)

# Start the server
server = await aiohttp_server(app)

# Make request with sensitive data in URL
url = f"http://username:password@{server.host}:{server.port}/status/200?Signature=secret"
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
assert response.status == 200
assert await response.text() == "hello"

# Verify redaction in span attributes
spans = memory_exporter.get_finished_spans()
assert len(spans) == 1

span = spans[0]
assert span.attributes[HTTP_METHOD] == "GET"
assert span.attributes[HTTP_STATUS_CODE] == 200
assert (
span.attributes[HTTP_URL]
== f"http://{server.host}:{server.port}/status/200?Signature=REDACTED"
)

# Clean up
AioHttpServerInstrumentor().uninstrument()
memory_exporter.clear()
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ def client_response_hook(span: Span, scope: Scope, message: dict[str, Any]):
get_custom_headers,
normalise_request_header_name,
normalise_response_header_name,
remove_url_credentials,
redact_url,
sanitize_method,
)

Expand Down Expand Up @@ -375,7 +375,7 @@ def collect_request_attributes(
if _report_old(sem_conv_opt_in_mode):
_set_http_url(
result,
remove_url_credentials(http_url),
redact_url(http_url),
_StabilityMode.DEFAULT,
)
http_method = scope.get("method", "")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1809,12 +1809,14 @@ def test_response_attributes_invalid_status_code(self):
otel_asgi.set_status_code(self.span, "Invalid Status Code")
self.assertEqual(self.span.set_status.call_count, 1)

def test_credential_removal(self):
def test_remove_sensitive_params(self):
self.scope["server"] = ("username:password@mock", 80)
self.scope["path"] = "/status/200"
self.scope["query_string"] = b"X-Goog-Signature=1234567890"
attrs = otel_asgi.collect_request_attributes(self.scope)
self.assertEqual(
attrs[SpanAttributes.HTTP_URL], "http://mock/status/200"
attrs[SpanAttributes.HTTP_URL],
"http://REDACTED:REDACTED@mock/status/200?X-Goog-Signature=REDACTED",
)

def test_collect_target_attribute_missing(self):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ async def async_response_hook(span, request, response):
from opentelemetry.trace import SpanKind, Tracer, TracerProvider, get_tracer
from opentelemetry.trace.span import Span
from opentelemetry.trace.status import StatusCode
from opentelemetry.util.http import remove_url_credentials, sanitize_method
from opentelemetry.util.http import redact_url, sanitize_method

_logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -313,7 +313,7 @@ def _extract_parameters(
# In httpx >= 0.20.0, handle_request receives a Request object
request: httpx.Request = args[0]
method = request.method.encode()
url = httpx.URL(remove_url_credentials(str(request.url)))
url = httpx.URL(str(request.url))
headers = request.headers
stream = request.stream
extensions = request.extensions
Expand Down Expand Up @@ -382,7 +382,7 @@ def _apply_request_client_attributes_to_span(
)

# http semconv transition: http.url -> url.full
_set_http_url(span_attributes, str(url), semconv)
_set_http_url(span_attributes, redact_url(str(url)), semconv)

# Set HTTP method in metric labels
_set_http_method(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1301,12 +1301,26 @@ def test_basic(self):
self.assert_span(num_spans=1)
self.assert_metrics(num_metrics=1)

def test_credential_removal(self):
new_url = "http://username:password@mock/status/200"
def test_remove_sensitive_params(self):
new_url = "http://username:password@mock/status/200?sig=secret"
self.perform_request(new_url)
span = self.assert_span()

self.assertEqual(span.attributes[SpanAttributes.HTTP_URL], self.URL)
actual_url = span.attributes[SpanAttributes.HTTP_URL]

if "@" in actual_url:
# If credentials are present, they must be redacted
self.assertEqual(
span.attributes[SpanAttributes.HTTP_URL],
"http://REDACTED:REDACTED@mock/status/200?sig=REDACTED",
)
else:
# If credentials are removed completely, the query string should still be redacted
self.assertIn(
"http://mock/status/200?sig=REDACTED",
actual_url,
f"Basic URL structure is incorrect: {actual_url}",
)


class TestAsyncIntegration(BaseTestCases.BaseManualTest):
Expand Down Expand Up @@ -1373,12 +1387,24 @@ def test_basic_multiple(self):
self.assert_span(num_spans=2)
self.assert_metrics(num_metrics=1)

def test_credential_removal(self):
new_url = "http://username:password@mock/status/200"
def test_remove_sensitive_params(self):
new_url = "http://username:password@mock/status/200?Signature=secret"
self.perform_request(new_url)
span = self.assert_span()

self.assertEqual(span.attributes[SpanAttributes.HTTP_URL], self.URL)
actual_url = span.attributes[SpanAttributes.HTTP_URL]

if "@" in actual_url:
self.assertEqual(
span.attributes[SpanAttributes.HTTP_URL],
"http://REDACTED:REDACTED@mock/status/200?Signature=REDACTED",
)
else:
self.assertIn(
"http://mock/status/200?Signature=REDACTED",
actual_url,
f"If credentials are removed, the query string still should be redacted {actual_url}",
)


class TestSyncInstrumentationIntegration(BaseTestCases.BaseInstrumentorTest):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ def response_hook(span, request_obj, response):
ExcludeList,
get_excluded_urls,
parse_excluded_urls,
remove_url_credentials,
redact_url,
sanitize_method,
)
from opentelemetry.util.http.httplib import set_ip_on_next_http_connection
Expand Down Expand Up @@ -232,7 +232,7 @@ def get_or_create_headers():
method = request.method
span_name = get_default_span_name(method)

url = remove_url_credentials(request.url)
url = redact_url(request.url)

span_attributes = {}
_set_http_method(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -686,12 +686,17 @@ def perform_request(url: str, session: requests.Session = None):
return requests.get(url, timeout=5)
return session.get(url)

def test_credential_removal(self):
new_url = "http://username:password@mock/status/200"
def test_remove_sensitive_params(self):
new_url = (
"http://username:password@mock/status/200?AWSAccessKeyId=secret"
)
self.perform_request(new_url)
span = self.assert_span()

self.assertEqual(span.attributes[HTTP_URL], self.URL)
self.assertEqual(
span.attributes[HTTP_URL],
"http://REDACTED:REDACTED@mock/status/200?AWSAccessKeyId=REDACTED",
)

def test_if_headers_equals_none(self):
result = requests.get(self.URL, headers=None, timeout=5)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
HTTP_URL,
)
from opentelemetry.trace.status import Status, StatusCode
from opentelemetry.util.http import remove_url_credentials
from opentelemetry.util.http import redact_url


def _normalize_request(args, kwargs):
Expand Down Expand Up @@ -79,7 +79,7 @@ def fetch_async(

if span.is_recording():
attributes = {
HTTP_URL: remove_url_credentials(request.url),
HTTP_URL: redact_url(request.url),
HTTP_METHOD: request.method,
}
for key, value in attributes.items():
Expand Down Expand Up @@ -165,7 +165,7 @@ def _finish_tracing_callback(
def _create_metric_attributes(response):
metric_attributes = {
HTTP_STATUS_CODE: response.code,
HTTP_URL: remove_url_credentials(response.request.url),
HTTP_URL: redact_url(response.request.url),
HTTP_METHOD: response.request.method,
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -500,16 +500,16 @@ def test_response_headers(self):

set_global_response_propagator(orig)

def test_credential_removal(self):
app = HttpServerMock("test_credential_removal")
def test_remove_sensitive_params(self):
app = HttpServerMock("test_remove_sensitive_params")

@app.route("/status/200")
def index():
return "hello"

with app.run("localhost", 5000):
response = self.fetch(
"http://username:password@localhost:5000/status/200"
"http://username:password@localhost:5000/status/200?Signature=secret"
)
self.assertEqual(response.code, 200)

Expand All @@ -522,7 +522,7 @@ def index():
self.assertSpanHasAttributes(
client,
{
HTTP_URL: "http://localhost:5000/status/200",
HTTP_URL: "http://REDACTED:REDACTED@localhost:5000/status/200?Signature=REDACTED",
HTTP_METHOD: "GET",
HTTP_STATUS_CODE: 200,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ def response_hook(span: Span, request: Request, response: HTTPResponse):
ExcludeList,
get_excluded_urls,
parse_excluded_urls,
remove_url_credentials,
redact_url,
sanitize_method,
)
from opentelemetry.util.types import Attributes
Expand Down Expand Up @@ -258,7 +258,7 @@ def _instrumented_open_call(

span_name = _get_span_name(method)

url = remove_url_credentials(url)
url = redact_url(url)

data = getattr(request, "data", None)
request_size = 0 if data is None else len(data)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -512,14 +512,17 @@ def test_requests_timeout_exception(self, *_, **__):
span = self.assert_span()
self.assertEqual(span.status.status_code, StatusCode.ERROR)

def test_credential_removal(self):
def test_remove_sensitive_params(self):
url = "http://username:password@mock/status/200"

with self.assertRaises(Exception):
self.perform_request(url)

span = self.assert_span()
self.assertEqual(span.attributes[SpanAttributes.HTTP_URL], self.URL)
self.assertEqual(
span.attributes[SpanAttributes.HTTP_URL],
"http://REDACTED:REDACTED@mock/status/200",
)

def test_hooks(self):
def request_hook(span, request_obj):
Expand Down
Loading