Xqt has submitted this change. ( 
https://gerrit.wikimedia.org/r/c/pywikibot/core/+/1210626?usp=email )

Change subject: SEC: parse password lines as literals & update default 
PASS_BASENAME
......................................................................

SEC: parse password lines as literals & update default PASS_BASENAME

- Replaced eval() with ast.literal_eval() to prevent remote code execution.
- Added sanity checks for line length and comma count.
- BotPassword objects are safely reconstructed after parsing.
- Raises ValueError for malformed password line instead passing silently
- Default PASS_BASENAME changed from 'user-password.py' to 'user-password.cfg'.
- Verified with unit test ensuring no code execution occurs from password file.
- Updated docstrings and versionchanged notices.

Patch backported from master

Bug: T410753
Change-Id: I685320ac3ea837023fc41278c48916786d546eff
---
M pywikibot/login.py
M pywikibot/scripts/generate_user_files.py
M tests/generate_user_files_tests.py
M tests/login_tests.py
4 files changed, 74 insertions(+), 11 deletions(-)

Approvals:
  jenkins-bot: Verified
  Xqt: Looks good to me, approved




diff --git a/pywikibot/login.py b/pywikibot/login.py
index 94650e9..e7eb88c 100644
--- a/pywikibot/login.py
+++ b/pywikibot/login.py
@@ -10,6 +10,7 @@
 import os
 import re
 import webbrowser
+from ast import literal_eval
 from enum import IntEnum
 from pathlib import Path
 from textwrap import fill
@@ -33,6 +34,8 @@
 except ImportError as e:
     mwoauth = e

+TEST_RUNNING = os.environ.get('PYWIKIBOT_TEST_RUNNING', '0') == '1'
+

 class _PasswordFileWarning(UserWarning):

@@ -202,7 +205,7 @@
         will be used, so default usernames should occur above specific
         usernames.

-        .. note:: For BotPasswords the password should be given as a
+        .. note:: For BotPasswords the password should be given like a
            :class:`BotPassword` object.

         The file must be either encoded in ASCII or UTF-8.
@@ -215,10 +218,17 @@
          ('my_username', BotPassword('my_suffix', 'my_password'))

         .. versionchanged:: 10.2
-           raises ValueError instead of AttributeError if password_file
-               is not set
+           Raises ValueError instead of AttributeError if password_file
+               is not set.
+        .. versionchanged:: 10.7.1
+           Due to vulnerability issue the password lines are no longer
+           evaluated as Python source but parsed as literals.
+           Raises ValueError if an exception occurs while evaluating a
+           password line.

-        :raises ValueError: `password_file` is not set in the user-config.py
+
+        :raises ValueError: `password_file` is not set in the
+            ``user-config.py`` or entries are critical malformed
         :raises FileNotFoundError: password file does not exist
         """
         if config.password_file is None:
@@ -247,15 +257,24 @@

         lines = password_path.read_text('utf-8').splitlines()
         line_len = len(lines)
-
         for n, line in enumerate(reversed(lines)):
-            if not line.strip() or line.startswith('#'):
+            line = line.strip()
+            if not line or line.startswith('#'):
                 continue

+            # sanity check
+            if (line.count(',') > 3 or len(line) > 250) and not TEST_RUNNING:
+                raise ValueError('Password line too long or too complex')
+
+            botpassword = 'BotPassword' in line
+            if botpassword:
+                line = line.replace('BotPassword', '')
+
             try:
-                entry = eval(line)
-            except SyntaxError:
-                entry = None
+                entry = literal_eval(line)
+            except Exception:
+                # catch the exceptions to not leak passwords to console or logs
+                raise ValueError('Invalid password line format') from None

             if not isinstance(entry, tuple):
                 warn(f'Invalid tuple in line {line_len - n}',
@@ -268,6 +287,9 @@
                      _PasswordFileWarning, stacklevel=2)
                 continue

+            if botpassword:
+                entry = (entry[0], BotPassword(*entry[1]))
+
             code, family, username, password = (
                 self.site.code, self.site.family.name)[:4 - entry_len] + entry

diff --git a/pywikibot/scripts/generate_user_files.py 
b/pywikibot/scripts/generate_user_files.py
index 1395768..e5663d96 100755
--- a/pywikibot/scripts/generate_user_files.py
+++ b/pywikibot/scripts/generate_user_files.py
@@ -6,6 +6,9 @@
 .. versionchanged:: 8.0
    let user the choice which section to be copied.
    Also EXTERNAL EDITOR SETTINGS section can be copied.
+.. versionchanged:: 10.7.1
+   Default ``PASS_BASENAME`` was changed from ``user-password.py`` to
+   ``user-password.cfg``
 """
 #
 # (C) Pywikibot team, 2010-2025
@@ -55,7 +58,7 @@
     console_encoding = 'iso-8859-1'

 USER_BASENAME = 'user-config.py'
-PASS_BASENAME = 'user-password.py'
+PASS_BASENAME = 'user-password.cfg'


 def change_base_dir():
diff --git a/tests/generate_user_files_tests.py 
b/tests/generate_user_files_tests.py
index 28b2575..609c108 100755
--- a/tests/generate_user_files_tests.py
+++ b/tests/generate_user_files_tests.py
@@ -30,7 +30,7 @@
     def test_base_names(self) -> None:
         """Test basename constants."""
         self.assertEndsWith(guf.USER_BASENAME, '.py')
-        self.assertEndsWith(guf.PASS_BASENAME, '.py')
+        self.assertEndsWith(guf.PASS_BASENAME, '.cfg')

     def test_config_test(self) -> None:
         """Test config text strings."""
diff --git a/tests/login_tests.py b/tests/login_tests.py
index 83ce47e..fa0ee65 100755
--- a/tests/login_tests.py
+++ b/tests/login_tests.py
@@ -10,6 +10,8 @@
 #
 from __future__ import annotations

+import builtins
+import uuid
 from collections import defaultdict
 from io import StringIO
 from pathlib import Path
@@ -187,6 +189,42 @@
             """, '~FakePassword')
         self.assertEqual(obj.login_name, '~FakeUsername@~FakeSuffix')

+    def test_eval_security(self) -> None:
+        """Test security that password file does not use eval() function."""
+        # Test file will will be created for Python 3.10-3.13
+        # due to self.stat patch in setUp().
+        no_file = (3, 9) < PYTHON_VERSION < (3, 14)
+
+        builtins.exploit_value = False
+        exploit_code = (
+            "__import__('builtins').__dict__"
+            ".__setitem__('exploit_value', True)"
+        )
+        if not no_file:
+            exploit_filename = f'pwb_rce_{uuid.uuid4().hex[:8]}.txt'
+            exploit_file = Path(exploit_filename)
+            exploit_code = (
+                f"__import__('pathlib').Path('{exploit_filename}').touch() or "
+                + exploit_code
+            )
+
+        with self.subTest(test='Test ValueError'), \
+             self.assertRaisesRegex(ValueError,
+                                    'Invalid password line format'):
+            self._test_pwfile(f"""
+                ('en', 'wikipedia', 'victim', {exploit_code})
+                """, None)
+
+        with self.subTest(test='Test value was modified'):
+            self.assertFalse(exploit_value)  # noqa: F821
+
+        if not no_file:
+            with self.subTest(test='Test file exists'):
+                self.assertFalse(exploit_file.exists())
+
+            # cleanup file (should never happen)
+            exploit_file.unlink(missing_ok=True)
+

 if __name__ == '__main__':
     unittest.main()

--
To view, visit 
https://gerrit.wikimedia.org/r/c/pywikibot/core/+/1210626?usp=email
To unsubscribe, or for help writing mail filters, visit 
https://gerrit.wikimedia.org/r/settings?usp=email

Gerrit-MessageType: merged
Gerrit-Project: pywikibot/core
Gerrit-Branch: stable
Gerrit-Change-Id: I685320ac3ea837023fc41278c48916786d546eff
Gerrit-Change-Number: 1210626
Gerrit-PatchSet: 1
Gerrit-Owner: Xqt <[email protected]>
Gerrit-Reviewer: Xqt <[email protected]>
Gerrit-Reviewer: jenkins-bot
_______________________________________________
Pywikibot-commits mailing list -- [email protected]
To unsubscribe send an email to [email protected]

Reply via email to