From 7f390a57fc2621b8b341e0e62dc01cee6f9ef747 Mon Sep 17 00:00:00 2001 From: Deepanshu Date: Tue, 4 Aug 2026 14:06:09 -0400 Subject: [PATCH] fix(logging): reserve trace_id/session_id in JsonFormatter against message-content spoofing JsonFormatter merges keys parsed from the message body before applying extra record attributes, and the extra-attributes loop skips a key that's already present. A caller-controlled log message that happens to parse as JSON/dict with a "trace_id"/"session_id" key (e.g. the proxy logging a raw request-header dict) could therefore make the JSON record carry the attacker-supplied value instead of the real correlation context set via CorrelationContextFilter. trace_id/session_id are now applied from the LogRecord's own attributes after message-content parsing, unconditionally overwriting anything the message body claimed for those two keys. --- litellm/_logging.py | 12 ++++++++++++ tests/test_litellm/test_logging.py | 17 +++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/litellm/_logging.py b/litellm/_logging.py index 204a58a99b3..038aded6e6e 100644 --- a/litellm/_logging.py +++ b/litellm/_logging.py @@ -231,6 +231,18 @@ class JsonFormatter(Formatter): if key not in _STANDARD_RECORD_ATTRS and key not in json_record: json_record[key] = value + # trace_id/session_id are reserved: CorrelationContextFilter is the only + # legitimate source for these two fields. Without this, a message string + # that happens to parse as JSON/dict (e.g. a proxy log line dumping raw + # request headers) with a "trace_id"/"session_id" key would have already + # claimed the key at the parsed-message step above, and the extra-attributes + # loop's "key not in json_record" guard would then skip the real value - + # letting a caller-supplied header spoof another request's correlation ids. + for reserved_key in ("trace_id", "session_id"): + value = getattr(record, reserved_key, None) + if value: + json_record[reserved_key] = value + # Set component/logger only if not already supplied via extra={...} if "component" not in json_record: json_record["component"] = record.name diff --git a/tests/test_litellm/test_logging.py b/tests/test_litellm/test_logging.py index 85719db5cc4..d4a4a30c1fc 100644 --- a/tests/test_litellm/test_logging.py +++ b/tests/test_litellm/test_logging.py @@ -445,6 +445,23 @@ def test_session_id_injected_when_set(monkeypatch): session_id_var.set("") +def test_trace_id_and_session_id_cannot_be_spoofed_by_message_content(monkeypatch): + """A log message that happens to parse as JSON/dict with "trace_id"/"session_id" + keys (e.g. the proxy logging a raw request-header dict) must not override the + real correlation ids set via set_trace_id()/set_session_id().""" + monkeypatch.setattr(litellm, "request_correlation_in_logs", True) + lg, cap = _make_capture_logger("test.spoof_attempt") + set_trace_id("real-trace-id") + set_session_id("real-session-id") + try: + lg.info('{"trace_id": "attacker-supplied-trace", "session_id": "attacker-supplied-session"}') + assert cap.records[0]["trace_id"] == "real-trace-id" + assert cap.records[0]["session_id"] == "real-session-id" + finally: + trace_id_var.set("") + session_id_var.set("") + + def test_session_id_absent_when_not_set(): """session_id must NOT appear in JSON record when not set for this context.""" lg, cap = _make_capture_logger("test.no_session")