Skip to content

Commit 4ece91d

Browse files
authored
fix: prevent infinite loop in exception cause chains by capping traversal (#200)
1 parent 2f66dce commit 4ece91d

File tree

3 files changed

+94
-8
lines changed

3 files changed

+94
-8
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ CHANGELOG](http://keepachangelog.com/) for how to update this file. This project
44
adheres to [Semantic Versioning](http://semver.org/).
55

66
## [Unreleased]
7+
- Fix: Prevent infinite loop in exception cause chains by capping traversal
78

89
## [0.22.0] - 2025-03-31
910
- Fix: `event_type` is not a required key for honeybadger.event()

honeybadger/payload.py

+32-7
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@
1515

1616
logger = logging.getLogger("honeybadger.payload")
1717

18+
# Prevent infinite loops in exception cause chains
19+
MAX_CAUSE_DEPTH = 15
20+
1821

1922
def error_payload(exception, exc_traceback, config, fingerprint=None):
2023
def _filename(name):
@@ -52,6 +55,34 @@ def prepare_exception_payload(exception, exclude=None):
5255
],
5356
}
5457

58+
def extract_exception_causes(exception):
59+
"""
60+
Traverses the __cause__ chain of an exception and returns a list of prepared payloads.
61+
Limits depth to prevent infinite loops from circular references.
62+
"""
63+
causes = []
64+
depth = 0
65+
66+
while (
67+
getattr(exception, "__cause__", None) is not None
68+
and depth < MAX_CAUSE_DEPTH
69+
):
70+
exception = exception.__cause__
71+
causes.append(prepare_exception_payload(exception))
72+
depth += 1
73+
74+
if depth == MAX_CAUSE_DEPTH:
75+
causes.append(
76+
{
77+
"token": str(uuid.uuid4()),
78+
"class": "HoneybadgerWarning",
79+
"type": "HoneybadgerWarning",
80+
"message": f"Exception cause chain truncated after {MAX_CAUSE_DEPTH} levels. Possible circular reference.",
81+
}
82+
)
83+
84+
return causes
85+
5586
if exc_traceback:
5687
tb = traceback.extract_tb(exc_traceback)
5788
else:
@@ -60,17 +91,11 @@ def prepare_exception_payload(exception, exclude=None):
6091
logger.debug(tb)
6192

6293
payload = prepare_exception_payload(exception)
94+
payload["causes"] = extract_exception_causes(exception)
6395

6496
if fingerprint is not None:
6597
payload["fingerprint"] = fingerprint and str(fingerprint).strip() or None
6698

67-
payload["causes"] = []
68-
69-
# If exception has a __cause__, Recursively build the causes list.
70-
while hasattr(exception, "__cause__") and exception.__cause__ is not None:
71-
exception = exception.__cause__
72-
payload["causes"].append(prepare_exception_payload(exception))
73-
7499
return payload
75100

76101

honeybadger/tests/test_payload.py

+61-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@
44
import os
55
import sys
66

7-
from honeybadger.payload import create_payload, error_payload, server_payload
7+
from honeybadger.payload import (
8+
create_payload,
9+
error_payload,
10+
server_payload,
11+
MAX_CAUSE_DEPTH,
12+
)
813
from honeybadger.config import Configuration
914

1015
from mock import patch
@@ -93,6 +98,15 @@ def test_error_payload_source_missing_file(_isfile):
9398
assert payload["backtrace"][0]["source"] == {}
9499

95100

101+
def test_payload_with_no_exception_cause():
102+
with mock_traceback() as traceback_mock:
103+
config = Configuration()
104+
exception = Exception("Test")
105+
106+
payload = error_payload(exc_traceback=None, exception=exception, config=config)
107+
assert len(payload["causes"]) == 0
108+
109+
96110
def test_payload_captures_exception_cause():
97111
with mock_traceback() as traceback_mock:
98112
config = Configuration()
@@ -103,6 +117,52 @@ def test_payload_captures_exception_cause():
103117
assert len(payload["causes"]) == 1
104118

105119

120+
def test_payload_captures_short_exception_cause_chain():
121+
with mock_traceback() as traceback_mock:
122+
config = Configuration()
123+
exception = Exception("Inner test")
124+
innerException = Exception("Inner exception")
125+
innerException.__cause__ = Exception("Inner cause")
126+
exception.__cause__ = innerException
127+
128+
payload = error_payload(exc_traceback=None, exception=exception, config=config)
129+
assert len(payload["causes"]) == 2
130+
131+
132+
def test_payload_captures_circular_exception_cause_chain():
133+
with mock_traceback() as traceback_mock:
134+
config = Configuration()
135+
exceptionA = Exception("A")
136+
exceptionB = Exception("B")
137+
exceptionA.__cause__ = exceptionB
138+
exceptionB.__cause__ = exceptionA
139+
140+
payload = error_payload(exc_traceback=None, exception=exceptionA, config=config)
141+
assert len(payload["causes"]) == MAX_CAUSE_DEPTH + 1
142+
assert (
143+
payload["causes"][-1]["message"]
144+
== f"Exception cause chain truncated after {MAX_CAUSE_DEPTH} levels. Possible circular reference."
145+
)
146+
147+
148+
def test_payload_captures_deep_exception_cause_chain():
149+
with mock_traceback() as traceback_mock:
150+
config = Configuration()
151+
root = Exception("root")
152+
current = root
153+
for i in range(MAX_CAUSE_DEPTH * 2):
154+
e = Exception(i)
155+
e.__cause__ = current
156+
current = e
157+
158+
payload = error_payload(exc_traceback=None, exception=current, config=config)
159+
assert len(payload["causes"]) == MAX_CAUSE_DEPTH + 1
160+
assert (
161+
payload["causes"][-1]["message"]
162+
== f"Exception cause chain truncated after {MAX_CAUSE_DEPTH} levels. Possible circular reference."
163+
)
164+
165+
106166
def test_error_payload_with_nested_exception():
107167
with mock_traceback() as traceback_mock:
108168
config = Configuration()

0 commit comments

Comments
 (0)