This is an automated email from the ASF dual-hosted git repository.

potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 1533ecf96d3 Fix recursion depth error in 
_redact_exception_with_context (#61776)
1533ecf96d3 is described below

commit 1533ecf96d314d8e17501d2189abf4245283abd8
Author: Jarek Potiuk <[email protected]>
AuthorDate: Thu Feb 12 01:18:57 2026 +0100

    Fix recursion depth error in _redact_exception_with_context (#61776)
    
    * Fix recursion depth error in _redact_exception_with_context
    
    Fix recursion depth check in _redact_exception_with_context_or_cause.
    There are some obscure cases where exception might point to itself
    in cause/context - this PR protects against it.
    
    Changed name to include cause as well.
    
    Initially implemented as a fix to v2-11-test in #61254 - enhanced
    with the case of removal of too-deep exceptions rather than
    not-redacting it (and replacing it with sentinel exception explaining
    that reminder of the stack trace has been removed.
    
    Co-authored-by: Anton Nitochkin <[email protected]>
    
    * Apply suggestions from code review
    
    Co-authored-by: Copilot <[email protected]>
    
    ---------
    
    Co-authored-by: Anton Nitochkin <[email protected]>
    Co-authored-by: Copilot <[email protected]>
---
 .../secrets_masker/secrets_masker.py               |  37 ++-
 .../tests/secrets_masker/test_secrets_masker.py    | 339 +++++++++++++++++++++
 2 files changed, 369 insertions(+), 7 deletions(-)

diff --git 
a/shared/secrets_masker/src/airflow_shared/secrets_masker/secrets_masker.py 
b/shared/secrets_masker/src/airflow_shared/secrets_masker/secrets_masker.py
index f5733a2344e..fb5e10302d7 100644
--- a/shared/secrets_masker/src/airflow_shared/secrets_masker/secrets_masker.py
+++ b/shared/secrets_masker/src/airflow_shared/secrets_masker/secrets_masker.py
@@ -260,15 +260,38 @@ class SecretsMasker(logging.Filter):
         )
         return frozenset(record.__dict__).difference({"msg", "args"})
 
-    def _redact_exception_with_context(self, exception):
+    def _redact_exception_with_context_or_cause(self, exception, visited=None):
         # Exception class may not be modifiable (e.g. declared by an
         # extension module such as JDBC).
         with contextlib.suppress(AttributeError):
-            exception.args = (self.redact(v) for v in exception.args)
-        if exception.__context__:
-            self._redact_exception_with_context(exception.__context__)
-        if exception.__cause__ and exception.__cause__ is not 
exception.__context__:
-            self._redact_exception_with_context(exception.__cause__)
+            if visited is None:
+                visited = set()
+
+            if id(exception) in visited:
+                # already visited - it was redacted earlier
+                return exception
+
+            # Check depth before adding to visited to ensure we skip 
exceptions beyond the limit
+            if len(visited) >= self.MAX_RECURSION_DEPTH:
+                return RuntimeError(
+                    f"Stack trace redaction hit recursion limit of 
{self.MAX_RECURSION_DEPTH} "
+                    f"when processing exception of type 
{type(exception).__name__}. "
+                    f"The remaining exceptions will be skipped to avoid "
+                    f"infinite recursion and protect against revealing 
sensitive information."
+                )
+
+            visited.add(id(exception))
+
+            exception.args = tuple(self.redact(v) for v in exception.args)
+            if exception.__context__:
+                exception.__context__ = 
self._redact_exception_with_context_or_cause(
+                    exception.__context__, visited
+                )
+            if exception.__cause__ and exception.__cause__ is not 
exception.__context__:
+                exception.__cause__ = 
self._redact_exception_with_context_or_cause(
+                    exception.__cause__, visited
+                )
+        return exception
 
     def filter(self, record) -> bool:
         if not self.is_log_masking_enabled():
@@ -285,7 +308,7 @@ class SecretsMasker(logging.Filter):
                     record.__dict__[k] = self.redact(v)
             if record.exc_info and record.exc_info[1] is not None:
                 exc = record.exc_info[1]
-                self._redact_exception_with_context(exc)
+                self._redact_exception_with_context_or_cause(exc)
         record.__dict__[self.ALREADY_FILTERED_FLAG] = True
 
         return True
diff --git a/shared/secrets_masker/tests/secrets_masker/test_secrets_masker.py 
b/shared/secrets_masker/tests/secrets_masker/test_secrets_masker.py
index 99edeec23b6..274e611d57c 100644
--- a/shared/secrets_masker/tests/secrets_masker/test_secrets_masker.py
+++ b/shared/secrets_masker/tests/secrets_masker/test_secrets_masker.py
@@ -238,6 +238,345 @@ class TestSecretsMasker:
             """
         )
 
+    def test_redact_exception_with_context_simple(self):
+        """
+        Test _redact_exception_with_context_or_cause with a simple exception 
without context or cause.
+        """
+        masker = SecretsMasker()
+        configure_secrets_masker_for_test(masker)
+        masker.add_mask(PASSWORD)
+
+        exc = RuntimeError(f"Cannot connect to user:{PASSWORD}")
+        masker._redact_exception_with_context_or_cause(exc)
+
+        assert "password" not in str(exc.args[0])
+        assert "user:***" in str(exc.args[0])
+
+    def test_redact_exception_with_implicit_context(self):
+        """
+        Test _redact_exception_with_context with exception __context__ 
(implicit chaining).
+        """
+        masker = SecretsMasker()
+        configure_secrets_masker_for_test(masker)
+        masker.add_mask(PASSWORD)
+
+        try:
+            try:
+                raise RuntimeError(f"Inner error with {PASSWORD}")
+            except RuntimeError:
+                raise RuntimeError(f"Outer error with {PASSWORD}")
+        except RuntimeError as exc:
+            captured_exc = exc
+
+        masker._redact_exception_with_context_or_cause(captured_exc)
+        assert "password" not in str(captured_exc.args[0])
+        assert "password" not in str(captured_exc.__context__.args[0])
+
+    def test_redact_exception_with_explicit_cause(self):
+        """
+        Test _redact_exception_with_context with exception __cause__ (explicit 
chaining).
+        """
+        masker = SecretsMasker()
+        configure_secrets_masker_for_test(masker)
+        masker.add_mask(PASSWORD)
+
+        try:
+            inner = RuntimeError(f"Cause error: {PASSWORD}")
+            raise RuntimeError(f"Main error: {PASSWORD}") from inner
+        except RuntimeError as exc:
+            captured_exc = exc
+
+        masker._redact_exception_with_context_or_cause(captured_exc)
+        assert "password" not in str(captured_exc.args[0])
+        assert "password" not in str(captured_exc.__cause__.args[0])
+
+    def test_redact_exception_with_circular_context_reference(self):
+        """
+        Test _redact_exception_with_context handles circular references 
without infinite recursion.
+        """
+        masker = SecretsMasker()
+        configure_secrets_masker_for_test(masker)
+        masker.add_mask(PASSWORD)
+
+        exc1 = RuntimeError(f"Error with {PASSWORD}")
+        exc2 = RuntimeError(f"Another error with {PASSWORD}")
+        # Create circular reference
+        exc1.__context__ = exc2
+        exc2.__context__ = exc1
+
+        # Should not raise RecursionError
+        masker._redact_exception_with_context_or_cause(exc1)
+
+        assert "password" not in str(exc1.args[0])
+        assert "password" not in str(exc2.args[0])
+
+    def test_redact_exception_with_max_context_recursion_depth(self):
+        """
+        Test _redact_exception_with_context respects MAX_RECURSION_DEPTH.
+        Once the depth limit is reached, the remaining exception chain is not 
traversed;
+        instead, it is truncated and replaced with a sentinel exception 
indicating the
+        recursion limit was hit, and further chaining is dropped.
+        """
+        masker = SecretsMasker()
+        configure_secrets_masker_for_test(masker)
+        masker.add_mask(PASSWORD)
+
+        # Create a long chain of exceptions
+        exc_chain = RuntimeError(f"Error 0 with {PASSWORD}")
+        current = exc_chain
+        for i in range(1, 10):
+            new_exc = RuntimeError(f"Error {i} with {PASSWORD}")
+            current.__context__ = new_exc
+            current = new_exc
+
+        masker._redact_exception_with_context_or_cause(exc_chain)
+
+        # Verify redaction happens up to MAX_RECURSION_DEPTH
+        # The check is `len(visited) >= MAX_RECURSION_DEPTH` before adding 
current exception
+        # So it processes exactly MAX_RECURSION_DEPTH exceptions (0 through 
MAX_RECURSION_DEPTH-1)
+        current = exc_chain
+        for i in range(10):
+            assert current, "We should always get some exception here"
+            if i < masker.MAX_RECURSION_DEPTH:
+                # Should be redacted within the depth limit
+                assert "password" not in str(current.args[0]), f"Exception {i} 
should be redacted"
+            else:
+                assert "hit recursion limit" in str(current.args[0]), (
+                    f"Exception {i} should indicate recursion depth limit hit"
+                )
+                assert "password" not in str(current.args[0]), f"Exception {i} 
should not be present"
+                assert current.__context__ is None, (
+                    f"Exception {i} should not have a context due to depth 
limit"
+                )
+                break
+            current = current.__context__
+
+    def test_redact_exception_with_circular_cause_reference(self):
+        """
+        Test _redact_exception_with_context_or_cause handles circular 
__cause__ references without infinite recursion.
+        """
+        masker = SecretsMasker()
+        configure_secrets_masker_for_test(masker)
+        masker.add_mask(PASSWORD)
+
+        exc1 = RuntimeError(f"Error with {PASSWORD}")
+        exc2 = RuntimeError(f"Another error with {PASSWORD}")
+        # Create circular reference using __cause__
+        exc1.__cause__ = exc2
+        exc2.__cause__ = exc1
+
+        # Should not raise RecursionError
+        masker._redact_exception_with_context_or_cause(exc1)
+
+        assert "password" not in str(exc1.args[0])
+        assert "password" not in str(exc2.args[0])
+
+    def test_redact_exception_with_max_cause_recursion_depth(self):
+        """
+        Test _redact_exception_with_context_or_cause respects 
MAX_RECURSION_DEPTH for __cause__ chains.
+        Exceptions beyond the depth limit should be skipped (not redacted).
+        """
+        masker = SecretsMasker()
+        configure_secrets_masker_for_test(masker)
+        masker.add_mask(PASSWORD)
+
+        # Create a long chain of exceptions using __cause__
+        exc_chain = RuntimeError(f"Error 0 with {PASSWORD}")
+        current = exc_chain
+        for i in range(1, 10):
+            new_exc = RuntimeError(f"Error {i} with {PASSWORD}")
+            current.__cause__ = new_exc
+            current = new_exc
+
+        masker._redact_exception_with_context_or_cause(exc_chain)
+
+        # Verify redaction happens up to MAX_RECURSION_DEPTH
+        # The check is `len(visited) >= MAX_RECURSION_DEPTH` before adding 
current exception
+        # So it processes exactly MAX_RECURSION_DEPTH exceptions (0 through 
MAX_RECURSION_DEPTH-1)
+        current = exc_chain
+        for i in range(10):
+            assert current, "We should always get some exception here"
+            if i < masker.MAX_RECURSION_DEPTH:
+                # Should be redacted within the depth limit
+                assert "password" not in str(current.args[0]), f"Exception {i} 
should be redacted"
+            else:
+                assert "hit recursion limit" in str(current.args[0]), (
+                    f"Exception {i} should indicate recursion depth limit hit"
+                )
+                assert "password" not in str(current.args[0]), f"Exception {i} 
should not be present"
+                assert current.__cause__ is None, f"Exception {i} should not 
have a cause due to depth limit"
+                break
+            current = current.__cause__
+
+    def test_redact_exception_with_mixed_cause_and_context_linear(self):
+        """
+        Test _redact_exception_with_context_or_cause with mixed __cause__ and 
__context__ in a linear chain.
+        This simulates: exception with cause, which has context, which has 
cause, etc.
+        """
+        masker = SecretsMasker()
+        configure_secrets_masker_for_test(masker)
+        masker.add_mask(PASSWORD)
+
+        # Build a chain: exc1 -> (cause) -> exc2 -> (context) -> exc3 -> 
(cause) -> exc4
+        exc4 = RuntimeError(f"Error 4 with {PASSWORD}")
+        exc3 = RuntimeError(f"Error 3 with {PASSWORD}")
+        exc3.__cause__ = exc4
+        exc2 = RuntimeError(f"Error 2 with {PASSWORD}")
+        exc2.__context__ = exc3
+        exc1 = RuntimeError(f"Error 1 with {PASSWORD}")
+        exc1.__cause__ = exc2
+
+        masker._redact_exception_with_context_or_cause(exc1)
+
+        # All exceptions should be redacted
+        assert "password" not in str(exc1.args[0])
+        assert "password" not in str(exc2.args[0])
+        assert "password" not in str(exc3.args[0])
+        assert "password" not in str(exc4.args[0])
+
+    def test_redact_exception_with_mixed_cause_and_context_branching(self):
+        """
+        Test with an exception that has both __cause__ and __context__ 
pointing to different exceptions.
+        This creates a branching structure.
+        """
+        masker = SecretsMasker()
+        configure_secrets_masker_for_test(masker)
+        masker.add_mask(PASSWORD)
+
+        # Create branching structure:
+        #     exc1
+        #    /    \
+        # cause  context
+        #  |       |
+        # exc2   exc3
+        exc2 = RuntimeError(f"Cause error with {PASSWORD}")
+        exc3 = RuntimeError(f"Context error with {PASSWORD}")
+        exc1 = RuntimeError(f"Main error with {PASSWORD}")
+        exc1.__cause__ = exc2
+        exc1.__context__ = exc3
+
+        masker._redact_exception_with_context_or_cause(exc1)
+
+        # All three should be redacted
+        assert "password" not in str(exc1.args[0])
+        assert "password" not in str(exc2.args[0])
+        assert "password" not in str(exc3.args[0])
+
+    def test_redact_exception_with_mixed_circular_reference(self):
+        """
+        Test with circular references involving both __cause__ and __context__.
+        """
+        masker = SecretsMasker()
+        configure_secrets_masker_for_test(masker)
+        masker.add_mask(PASSWORD)
+
+        # Create circular mixed reference: exc1 -cause-> exc2 -context-> exc1
+        exc1 = RuntimeError(f"Error 1 with {PASSWORD}")
+        exc2 = RuntimeError(f"Error 2 with {PASSWORD}")
+        exc1.__cause__ = exc2
+        exc2.__context__ = exc1
+
+        # Should not raise RecursionError
+        masker._redact_exception_with_context_or_cause(exc1)
+
+        assert "password" not in str(exc1.args[0])
+        assert "password" not in str(exc2.args[0])
+
+    def test_redact_exception_with_mixed_deep_chain(self):
+        """
+        Test with a deep chain alternating between __cause__ and __context__.
+        """
+        masker = SecretsMasker()
+        configure_secrets_masker_for_test(masker)
+        masker.add_mask(PASSWORD)
+
+        # Create alternating chain exceeding MAX_RECURSION_DEPTH
+        exceptions = [RuntimeError(f"Error {i} with {PASSWORD}") for i in 
range(10)]
+
+        # Link them: 0 -cause-> 1 -context-> 2 -cause-> 3 -context-> 4 ...
+        for i in range(len(exceptions) - 1):
+            if i % 2 == 0:
+                exceptions[i].__cause__ = exceptions[i + 1]
+            else:
+                exceptions[i].__context__ = exceptions[i + 1]
+
+        masker._redact_exception_with_context_or_cause(exceptions[0])
+
+        # Check that first MAX_RECURSION_DEPTH are redacted, rest hit the limit
+        for i in range(min(len(exceptions), masker.MAX_RECURSION_DEPTH)):
+            assert "password" not in str(exceptions[i].args[0]), f"Exception 
{i} should be redacted"
+
+    def test_redact_exception_with_mixed_diamond_structure(self):
+        """
+        Test with diamond structure: top exception has both cause and context 
that converge to same exception.
+                exc1
+               /    \
+           cause  context
+             |      |
+           exc2   exc3
+             \\     /
+              exc4
+        """
+        masker = SecretsMasker()
+        configure_secrets_masker_for_test(masker)
+        masker.add_mask(PASSWORD)
+
+        exc4 = RuntimeError(f"Bottom error with {PASSWORD}")
+        exc2 = RuntimeError(f"Left error with {PASSWORD}")
+        exc2.__cause__ = exc4
+        exc3 = RuntimeError(f"Right error with {PASSWORD}")
+        exc3.__context__ = exc4
+        exc1 = RuntimeError(f"Top error with {PASSWORD}")
+        exc1.__cause__ = exc2
+        exc1.__context__ = exc3
+
+        masker._redact_exception_with_context_or_cause(exc1)
+
+        # All should be redacted, exc4 should be visited only once
+        assert "password" not in str(exc1.args[0])
+        assert "password" not in str(exc2.args[0])
+        assert "password" not in str(exc3.args[0])
+        assert "password" not in str(exc4.args[0])
+
+    def test_redact_exception_with_immutable_args(self):
+        """
+        Test _redact_exception_with_context handles exceptions with immutable 
args gracefully.
+        """
+        masker = SecretsMasker()
+        configure_secrets_masker_for_test(masker)
+        masker.add_mask(PASSWORD)
+
+        class ImmutableException(Exception):
+            @property
+            def args(self):
+                return (f"Immutable error with {PASSWORD}",)
+
+        exc = ImmutableException()
+        # Should not raise AttributeError
+        masker._redact_exception_with_context_or_cause(exc)
+        # Note: Since args is immutable, it won't be redacted, but the method 
shouldn't crash
+
+    def test_redact_exception_with_same_cause_and_context(self):
+        """
+        Test _redact_exception_with_context when __cause__ is same as 
__context__.
+        """
+        masker = SecretsMasker()
+        configure_secrets_masker_for_test(masker)
+        masker.add_mask(PASSWORD)
+
+        try:
+            inner = RuntimeError(f"Base error: {PASSWORD}")
+            raise RuntimeError(f"Derived error: {PASSWORD}") from inner
+        except RuntimeError as exc:
+            # Make __context__ same as __cause__
+            exc.__context__ = exc.__cause__
+            captured_exc = exc
+
+        masker._redact_exception_with_context_or_cause(captured_exc)
+        assert "password" not in str(captured_exc.args[0])
+        assert "password" not in str(captured_exc.__cause__.args[0])
+        # Should only process __cause__ once (optimization check)
+
     @pytest.mark.parametrize(
         ("name", "value", "expected_mask"),
         [

Reply via email to