Repository: py-pdf/pypdf
Pull request: BUG: Prevent recursive loop in some PDF files
closes #2474
analysis in #2477

Problem statement:
BUG: clean_forms function cause infinite looping if elt["/Resources"] have circular relation
I have several PDF files from customers, and using remove_text can cause infinite looping. Upon investigation, I discovered a corner case where elt["/Resources"] has a circular relation, which can result in calling clean_forms(content, stack + [elt]) infinitely.

I proposed keeping a memory variable visited_resources to keep track of which elt["/Resources"] has been processed and to avoid infinite looping.

The corner case files are private and cannot be shared, but I believe many people would encounter the same problem.

Hints from issue discussion:

Commit message: Merge branch 'main' into iss2474

Patch to review:
diff --git a/pypdf/_writer.py b/pypdf/_writer.py
index f408b5161..b7ee25820 100644
--- a/pypdf/_writer.py
+++ b/pypdf/_writer.py
@@ -1923,7 +1923,14 @@ def clean_forms(
             elt: DictionaryObject, stack: List[DictionaryObject]
         ) -> Tuple[List[str], List[str]]:
             nonlocal to_delete
-            if elt in stack:
+            # elt in recursive is a new contentstream object so we have to check the indirect_reference
+            if (elt in stack) or (
+                hasattr(elt, "indirect_reference")
+                and any(
+                    elt.indirect_reference == getattr(x, "indirect_reference", -1)
+                    for x in stack
+                )
+            ):
                 # to prevent infinite looping
                 return [], []  # pragma: no cover
             try:
@@ -1958,7 +1965,12 @@ def clean_forms(
                                     if k1 not in ["/Length", "/Filter", "/DecodeParms"]
                                 }
                             )
-                        clean_forms(content, stack + [elt])  # clean sub forms
+                            try:
+                                content.indirect_reference = o.indirect_reference
+                            except AttributeError:  # pragma: no cover
+                                pass
+                        stack.append(elt)
+                        clean_forms(content, stack)  # clean sub forms
                     if content is not None:
                         if isinstance(v, IndirectObject):
                             self._objects[v.idnum - 1] = content


Code review comments:
diff_hunk: @@ -1923,7 +1923,14 @@ def clean_forms(
             elt: DictionaryObject, stack: List[DictionaryObject]
         ) -> Tuple[List[str], List[str]]:
             nonlocal to_delete
-            if elt in stack:
+            # elt in recursive is a new contentstream object so we have to check the indirect_reference
path: pypdf/_writer.py
text: @user1:
```suggestion
            # elt in recursive call is a new ContentStream object, so we have to check the indirect_reference
```

diff_hunk: @@ -1958,7 +1965,12 @@ def clean_forms(
                                     if k1 not in ["/Length", "/Filter", "/DecodeParms"]
                                 }
                             )
-                        clean_forms(content, stack + [elt])  # clean sub forms
+                            try:
+                                content.indirect_reference = o.indirect_reference
+                            except AttributeError:  # pragma: no cover
+                                pass
+                        stack.append(elt)
+                        clean_forms(content, stack)  # clean sub forms
path: pypdf/_writer.py
text: @user1:
```suggestion
                        clean_forms(content, stack)  # clean subforms
```