Skip to content

Commit 4b0b9e8

Browse files
michael.yakmichaelyaakoby
authored andcommitted
Tornado HTTP traces should support multi-value headers
As reported in #46, `TornadoHttpPyctuator` doesn't properly handle multi-value HTTP headers, which are supproted by `tornado.httputil.HTTPHeaders` as multiple entries with the same header. More than that, the value put in traces was a string instead of a list-of-strings. Note that `requests` doesn't seem to support sending a request with multi-value headers, so the testing is limited to verifying `TornadoHttpPyctuator._get_headers` only.
1 parent a474eb5 commit 4b0b9e8

File tree

7 files changed

+39
-19
lines changed

7 files changed

+39
-19
lines changed

pyctuator/httptrace/http_header_scrubber.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import re
22

33
_keys_to_scrub = re.compile(
4-
"^(.*[^A-Za-z])?key([^A-Za-z].*)?$|.*secret.*|.*password.*|.*token.*|.*authorization.*|.*authentication.*|.*cookie.*",
4+
"^(.*[^A-Za-z])?key([^A-Za-z].*)?$|"
5+
".*secret.*|"
6+
".*password.*|"
7+
".*token.*|"
8+
".*authorization.*|"
9+
".*authentication.*|"
10+
".*cookie.*",
511
re.IGNORECASE
612
)
713

pyctuator/httptrace/http_tracer.py

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,4 @@ def add_record(self, record: TraceRecord) -> None:
2222
self.traces_list.append(record)
2323

2424
def _scrub_and_normalize_headers(self, headers: Mapping[str, List[str]]) -> Mapping[str, List[str]]:
25-
26-
processed_headers = {}
27-
for k, values in headers.items():
28-
if isinstance(values, list):
29-
processed_headers[k] = [
30-
scrub_header_value(k, v) for v in values]
31-
elif isinstance(values, str):
32-
# this case happens for tornado headers, unsure why mypy is not catching this, maybe catch this earlier
33-
processed_headers[k] = [scrub_header_value(k, values)]
34-
return processed_headers
25+
return {header: [scrub_header_value(header, value) for value in values] for (header, values) in headers.items()}

pyctuator/impl/tornado_pyctuator.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33
from datetime import datetime, timedelta
44
from functools import partial
55
from http import HTTPStatus
6-
from typing import Any, Optional, Callable
6+
from typing import Any, Optional, Callable, Mapping, List
77

8+
from tornado.httputil import HTTPHeaders
89
from tornado.web import Application, RequestHandler
910

1011
from pyctuator.httptrace import TraceRecord, TraceRequest, TraceResponse
@@ -180,11 +181,11 @@ def _intercept_request_and_response(self, handler: RequestHandler) -> None:
180181
request=TraceRequest(
181182
method=handler.request.method or "",
182183
uri=handler.request.full_url(),
183-
headers={k.lower(): v for k, v in handler.request.headers.items()}
184+
headers=get_headers(handler.request.headers)
184185
),
185186
response=TraceResponse(
186187
status=handler.get_status(),
187-
headers={k.lower(): [v] for k, v in handler._headers.items()} # pylint: disable=protected-access
188+
headers=get_headers(handler._headers) # pylint: disable=protected-access
188189
),
189190
timeTaken=int(handler.request.request_time() * 1000),
190191
)
@@ -200,3 +201,9 @@ def _custom_json_serializer(self, value: Any) -> Any:
200201
if isinstance(value, datetime):
201202
return str(value)
202203
return None
204+
205+
206+
def get_headers(headers: HTTPHeaders) -> Mapping[str, List[str]]:
207+
""" Tornado's HTTPHeaders contains multiple entries of the same header name if multiple values were used, this
208+
function groups headers by header name. See documentation of `tornado.httputil.HTTPHeaders` """
209+
return {header.lower(): headers.get_list(header) for header in headers.keys()}

tests/httptrace/__init__.py

Whitespace-only changes.

tests/httptrace/test_http_header_scrubber.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@
99
("authentication", "secret123"),
1010
("COOKIE", "my-logged-in-session")
1111
])
12-
def test_scrubbing(key, value):
12+
def test_scrubbing(key: str, value: str) -> None:
1313
assert scrub_header_value(key, value) == "******"
1414

1515

1616
@pytest.mark.parametrize("key,value", [("Host", "example.org"), ("Content-Length", "2000")])
17-
def test_non_scrubbing(key, value):
17+
def test_non_scrubbing(key: str, value: str) -> None:
1818
assert scrub_header_value(key, value) == value
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from tornado.httputil import HTTPHeaders
2+
3+
from pyctuator.impl.tornado_pyctuator import get_headers
4+
5+
6+
def test_get_headers() -> None:
7+
tornado_headers = HTTPHeaders({"content-type": "text/html"})
8+
tornado_headers.add("Set-Cookie", "A=B")
9+
tornado_headers.add("Set-Cookie", "C=D")
10+
assert get_headers(tornado_headers) == {
11+
"content-type": ["text/html"],
12+
"set-cookie": ["A=B", "C=D"]
13+
}

tests/test_pyctuator_e2e.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -288,7 +288,10 @@ def test_traces_endpoint(endpoints: Endpoints) -> None:
288288
# Create a request with header
289289
user_header = "my header test"
290290
authorization = "bearer 123"
291-
requests.get(endpoints.root + "httptrace_test_url", headers={"User-Data": user_header, "authorization": authorization})
291+
requests.get(
292+
endpoints.root + "httptrace_test_url",
293+
headers={"User-Data": user_header, "authorization": authorization}
294+
)
292295

293296
# Get the captured httptraces
294297
response = requests.get(endpoints.httptrace)
@@ -300,12 +303,12 @@ def test_traces_endpoint(endpoints: Endpoints) -> None:
300303
assert int(response.headers.get("Content-Length", -1)) > 0
301304

302305
# Assert Response Secret is scrubbed
303-
assert "******" == trace["response"]["headers"]["response-secret"][0]
306+
assert trace["response"]["headers"]["response-secret"][0] == "******"
304307

305308
# Assert Request Authorization is scrubbed
306309
auth_header = "Authorization" if "Authorization" in trace[
307310
"request"]["headers"] else "authorization"
308-
assert "******" == trace["request"]["headers"][auth_header][0]
311+
assert trace["request"]["headers"][auth_header][0] == "******"
309312
# Assert timestamp is formatted in ISO format
310313
datetime.fromisoformat(trace["timestamp"])
311314

0 commit comments

Comments
 (0)