Skip to content

Commit b69ebb7

Browse files
rads-1996emdneto
andauthored
Redact specific url query string values and url credentials in instrumentations (#3508)
* Updated the instrumentation with aiohttp-server tests for url redaction * Updated the aiohttp-server implementation and the query redaction logic * Updated changelog and moved change to unreleased. Updated test files with license header * Improved formatting * Fixed failing tests * Fixed ruff * Update util/opentelemetry-util-http/src/opentelemetry/util/http/__init__.py Co-authored-by: Emídio Neto <[email protected]> --------- Co-authored-by: Emídio Neto <[email protected]>
1 parent 6977da3 commit b69ebb7

File tree

21 files changed

+363
-63
lines changed

21 files changed

+363
-63
lines changed

CHANGELOG.md

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

19+
### Added
20+
21+
- `opentelemetry-util-http` Added support for redacting specific url query string values and url credentials in instrumentations
22+
([#3508](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3508))
23+
1924
## Version 1.34.0/0.55b0 (2025-06-04)
2025

2126
### Fixed

instrumentation/opentelemetry-instrumentation-aiohttp-client/src/opentelemetry/instrumentation/aiohttp_client/__init__.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ def response_hook(span: Span, params: typing.Union[
135135
)
136136
from opentelemetry.trace import Span, SpanKind, TracerProvider, get_tracer
137137
from opentelemetry.trace.status import Status, StatusCode
138-
from opentelemetry.util.http import remove_url_credentials, sanitize_method
138+
from opentelemetry.util.http import redact_url, sanitize_method
139139

140140
_UrlFilterT = typing.Optional[typing.Callable[[yarl.URL], str]]
141141
_RequestHookT = typing.Optional[
@@ -311,9 +311,9 @@ async def on_request_start(
311311
method = params.method
312312
request_span_name = _get_span_name(method)
313313
request_url = (
314-
remove_url_credentials(trace_config_ctx.url_filter(params.url))
314+
redact_url(trace_config_ctx.url_filter(params.url))
315315
if callable(trace_config_ctx.url_filter)
316-
else remove_url_credentials(str(params.url))
316+
else redact_url(str(params.url))
317317
)
318318

319319
span_attributes = {}

instrumentation/opentelemetry-instrumentation-aiohttp-client/tests/test_aiohttp_client_integration.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -762,16 +762,16 @@ async def do_request(url):
762762
)
763763
self.memory_exporter.clear()
764764

765-
def test_credential_removal(self):
765+
def test_remove_sensitive_params(self):
766766
trace_configs = [aiohttp_client.create_trace_config()]
767767

768-
app = HttpServerMock("test_credential_removal")
768+
app = HttpServerMock("test_remove_sensitive_params")
769769

770770
@app.route("/status/200")
771771
def index():
772772
return "hello"
773773

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

776776
with app.run("localhost", 5000):
777777
with self.subTest(url=url):
@@ -793,7 +793,9 @@ async def do_request(url):
793793
(StatusCode.UNSET, None),
794794
{
795795
HTTP_METHOD: "GET",
796-
HTTP_URL: ("http://localhost:5000/status/200"),
796+
HTTP_URL: (
797+
"http://REDACTED:REDACTED@localhost:5000/status/200?Signature=REDACTED"
798+
),
797799
HTTP_STATUS_CODE: int(HTTPStatus.OK),
798800
},
799801
)

instrumentation/opentelemetry-instrumentation-aiohttp-server/src/opentelemetry/instrumentation/aiohttp_server/__init__.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ async def hello(request):
7272
)
7373
from opentelemetry.semconv.metrics import MetricInstruments
7474
from opentelemetry.trace.status import Status, StatusCode
75-
from opentelemetry.util.http import get_excluded_urls, remove_url_credentials
75+
from opentelemetry.util.http import get_excluded_urls, redact_url
7676

7777
_duration_attrs = [
7878
HTTP_METHOD,
@@ -148,6 +148,7 @@ def collect_request_attributes(request: web.Request) -> Dict:
148148
request.url.port,
149149
str(request.url),
150150
)
151+
151152
query_string = request.query_string
152153
if query_string and http_url:
153154
if isinstance(query_string, bytes):
@@ -161,7 +162,7 @@ def collect_request_attributes(request: web.Request) -> Dict:
161162
HTTP_ROUTE: _get_view_func(request),
162163
HTTP_FLAVOR: f"{request.version.major}.{request.version.minor}",
163164
HTTP_TARGET: request.path,
164-
HTTP_URL: remove_url_credentials(http_url),
165+
HTTP_URL: redact_url(http_url),
165166
}
166167

167168
http_method = request.method

instrumentation/opentelemetry-instrumentation-aiohttp-server/tests/test_aiohttp_server_integration.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,3 +152,46 @@ async def test_suppress_instrumentation(
152152
await client.get("/test-path")
153153

154154
assert len(memory_exporter.get_finished_spans()) == 0
155+
156+
157+
@pytest.mark.asyncio
158+
async def test_remove_sensitive_params(tracer, aiohttp_server):
159+
"""Test that sensitive information in URLs is properly redacted."""
160+
_, memory_exporter = tracer
161+
162+
# Set up instrumentation
163+
AioHttpServerInstrumentor().instrument()
164+
165+
# Create app with test route
166+
app = aiohttp.web.Application()
167+
168+
async def handler(request):
169+
return aiohttp.web.Response(text="hello")
170+
171+
app.router.add_get("/status/200", handler)
172+
173+
# Start the server
174+
server = await aiohttp_server(app)
175+
176+
# Make request with sensitive data in URL
177+
url = f"http://username:password@{server.host}:{server.port}/status/200?Signature=secret"
178+
async with aiohttp.ClientSession() as session:
179+
async with session.get(url) as response:
180+
assert response.status == 200
181+
assert await response.text() == "hello"
182+
183+
# Verify redaction in span attributes
184+
spans = memory_exporter.get_finished_spans()
185+
assert len(spans) == 1
186+
187+
span = spans[0]
188+
assert span.attributes[HTTP_METHOD] == "GET"
189+
assert span.attributes[HTTP_STATUS_CODE] == 200
190+
assert (
191+
span.attributes[HTTP_URL]
192+
== f"http://{server.host}:{server.port}/status/200?Signature=REDACTED"
193+
)
194+
195+
# Clean up
196+
AioHttpServerInstrumentor().uninstrument()
197+
memory_exporter.clear()

instrumentation/opentelemetry-instrumentation-asgi/src/opentelemetry/instrumentation/asgi/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,7 @@ def client_response_hook(span: Span, scope: Scope, message: dict[str, Any]):
278278
get_custom_headers,
279279
normalise_request_header_name,
280280
normalise_response_header_name,
281-
remove_url_credentials,
281+
redact_url,
282282
sanitize_method,
283283
)
284284

@@ -375,7 +375,7 @@ def collect_request_attributes(
375375
if _report_old(sem_conv_opt_in_mode):
376376
_set_http_url(
377377
result,
378-
remove_url_credentials(http_url),
378+
redact_url(http_url),
379379
_StabilityMode.DEFAULT,
380380
)
381381
http_method = scope.get("method", "")

instrumentation/opentelemetry-instrumentation-asgi/tests/test_asgi_middleware.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1809,12 +1809,14 @@ def test_response_attributes_invalid_status_code(self):
18091809
otel_asgi.set_status_code(self.span, "Invalid Status Code")
18101810
self.assertEqual(self.span.set_status.call_count, 1)
18111811

1812-
def test_credential_removal(self):
1812+
def test_remove_sensitive_params(self):
18131813
self.scope["server"] = ("username:password@mock", 80)
18141814
self.scope["path"] = "/status/200"
1815+
self.scope["query_string"] = b"X-Goog-Signature=1234567890"
18151816
attrs = otel_asgi.collect_request_attributes(self.scope)
18161817
self.assertEqual(
1817-
attrs[SpanAttributes.HTTP_URL], "http://mock/status/200"
1818+
attrs[SpanAttributes.HTTP_URL],
1819+
"http://REDACTED:REDACTED@mock/status/200?X-Goog-Signature=REDACTED",
18181820
)
18191821

18201822
def test_collect_target_attribute_missing(self):

instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,7 @@ async def async_response_hook(span, request, response):
259259
from opentelemetry.trace import SpanKind, Tracer, TracerProvider, get_tracer
260260
from opentelemetry.trace.span import Span
261261
from opentelemetry.trace.status import StatusCode
262-
from opentelemetry.util.http import remove_url_credentials, sanitize_method
262+
from opentelemetry.util.http import redact_url, sanitize_method
263263

264264
_logger = logging.getLogger(__name__)
265265

@@ -313,7 +313,7 @@ def _extract_parameters(
313313
# In httpx >= 0.20.0, handle_request receives a Request object
314314
request: httpx.Request = args[0]
315315
method = request.method.encode()
316-
url = httpx.URL(remove_url_credentials(str(request.url)))
316+
url = httpx.URL(str(request.url))
317317
headers = request.headers
318318
stream = request.stream
319319
extensions = request.extensions
@@ -382,7 +382,7 @@ def _apply_request_client_attributes_to_span(
382382
)
383383

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

387387
# Set HTTP method in metric labels
388388
_set_http_method(

instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx_integration.py

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1301,12 +1301,26 @@ def test_basic(self):
13011301
self.assert_span(num_spans=1)
13021302
self.assert_metrics(num_metrics=1)
13031303

1304-
def test_credential_removal(self):
1305-
new_url = "http://username:password@mock/status/200"
1304+
def test_remove_sensitive_params(self):
1305+
new_url = "http://username:password@mock/status/200?sig=secret"
13061306
self.perform_request(new_url)
13071307
span = self.assert_span()
13081308

1309-
self.assertEqual(span.attributes[SpanAttributes.HTTP_URL], self.URL)
1309+
actual_url = span.attributes[SpanAttributes.HTTP_URL]
1310+
1311+
if "@" in actual_url:
1312+
# If credentials are present, they must be redacted
1313+
self.assertEqual(
1314+
span.attributes[SpanAttributes.HTTP_URL],
1315+
"http://REDACTED:REDACTED@mock/status/200?sig=REDACTED",
1316+
)
1317+
else:
1318+
# If credentials are removed completely, the query string should still be redacted
1319+
self.assertIn(
1320+
"http://mock/status/200?sig=REDACTED",
1321+
actual_url,
1322+
f"Basic URL structure is incorrect: {actual_url}",
1323+
)
13101324

13111325

13121326
class TestAsyncIntegration(BaseTestCases.BaseManualTest):
@@ -1373,12 +1387,24 @@ def test_basic_multiple(self):
13731387
self.assert_span(num_spans=2)
13741388
self.assert_metrics(num_metrics=1)
13751389

1376-
def test_credential_removal(self):
1377-
new_url = "http://username:password@mock/status/200"
1390+
def test_remove_sensitive_params(self):
1391+
new_url = "http://username:password@mock/status/200?Signature=secret"
13781392
self.perform_request(new_url)
13791393
span = self.assert_span()
13801394

1381-
self.assertEqual(span.attributes[SpanAttributes.HTTP_URL], self.URL)
1395+
actual_url = span.attributes[SpanAttributes.HTTP_URL]
1396+
1397+
if "@" in actual_url:
1398+
self.assertEqual(
1399+
span.attributes[SpanAttributes.HTTP_URL],
1400+
"http://REDACTED:REDACTED@mock/status/200?Signature=REDACTED",
1401+
)
1402+
else:
1403+
self.assertIn(
1404+
"http://mock/status/200?Signature=REDACTED",
1405+
actual_url,
1406+
f"If credentials are removed, the query string still should be redacted {actual_url}",
1407+
)
13821408

13831409

13841410
class TestSyncInstrumentationIntegration(BaseTestCases.BaseInstrumentorTest):

instrumentation/opentelemetry-instrumentation-requests/src/opentelemetry/instrumentation/requests/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ def response_hook(span, request_obj, response):
147147
ExcludeList,
148148
get_excluded_urls,
149149
parse_excluded_urls,
150-
remove_url_credentials,
150+
redact_url,
151151
sanitize_method,
152152
)
153153
from opentelemetry.util.http.httplib import set_ip_on_next_http_connection
@@ -232,7 +232,7 @@ def get_or_create_headers():
232232
method = request.method
233233
span_name = get_default_span_name(method)
234234

235-
url = remove_url_credentials(request.url)
235+
url = redact_url(request.url)
236236

237237
span_attributes = {}
238238
_set_http_method(

0 commit comments

Comments
 (0)