https://github.com/python/cpython/commit/37f8a63e39ac8aaaa5c51032de19d2b6d81be162
commit: 37f8a63e39ac8aaaa5c51032de19d2b6d81be162
branch: 3.14
author: Miss Islington (bot) <[email protected]>
committer: hugovk <[email protected]>
date: 2025-09-17T17:20:45+03:00
summary:

[3.14] gh-138514: getpass: restrict `echo_char` to a single ASCII character 
(GH-138591) (#138988)

Co-authored-by: Benjamin Johnson <[email protected]>
Co-authored-by: Bénédikt Tran <[email protected]>
Co-authored-by: Brian Schubert <[email protected]>

files:
A Misc/NEWS.d/next/Library/2025-09-06-11-26-21.gh-issue-138514.66ltOb.rst
M Doc/library/getpass.rst
M Lib/getpass.py
M Lib/test/test_getpass.py
M Misc/ACKS

diff --git a/Doc/library/getpass.rst b/Doc/library/getpass.rst
index af9c9e9f39d9a6..a0c0c6dee2d513 100644
--- a/Doc/library/getpass.rst
+++ b/Doc/library/getpass.rst
@@ -27,9 +27,9 @@ The :mod:`getpass` module provides two functions:
 
    The *echo_char* argument controls how user input is displayed while typing.
    If *echo_char* is ``None`` (default), input remains hidden. Otherwise,
-   *echo_char* must be a printable ASCII string and each typed character
-   is replaced by it. For example, ``echo_char='*'`` will display
-   asterisks instead of the actual input.
+   *echo_char* must be a single printable ASCII character and each
+   typed character is replaced by it. For example, ``echo_char='*'`` will
+   display asterisks instead of the actual input.
 
    If echo free input is unavailable getpass() falls back to printing
    a warning message to *stream* and reading from ``sys.stdin`` and
diff --git a/Lib/getpass.py b/Lib/getpass.py
index 1dd40e25e09068..3d9bb1f0d146a4 100644
--- a/Lib/getpass.py
+++ b/Lib/getpass.py
@@ -33,8 +33,8 @@ def unix_getpass(prompt='Password: ', stream=None, *, 
echo_char=None):
       prompt: Written on stream to ask for the input.  Default: 'Password: '
       stream: A writable file object to display the prompt.  Defaults to
               the tty.  If no tty is available defaults to sys.stderr.
-      echo_char: A string used to mask input (e.g., '*').  If None, input is
-                hidden.
+      echo_char: A single ASCII character to mask input (e.g., '*').
+              If None, input is hidden.
     Returns:
       The seKr3t input.
     Raises:
@@ -144,10 +144,19 @@ def fallback_getpass(prompt='Password: ', stream=None, *, 
echo_char=None):
 
 
 def _check_echo_char(echo_char):
-    # ASCII excluding control characters
-    if echo_char and not (echo_char.isprintable() and echo_char.isascii()):
-        raise ValueError("'echo_char' must be a printable ASCII string, "
-                         f"got: {echo_char!r}")
+    # Single-character ASCII excluding control characters
+    if echo_char is None:
+        return
+    if not isinstance(echo_char, str):
+        raise TypeError("'echo_char' must be a str or None, not "
+                        f"{type(echo_char).__name__}")
+    if not (
+        len(echo_char) == 1
+        and echo_char.isprintable()
+        and echo_char.isascii()
+    ):
+        raise ValueError("'echo_char' must be a single printable ASCII "
+                         f"character, got: {echo_char!r}")
 
 
 def _raw_input(prompt="", stream=None, input=None, echo_char=None):
diff --git a/Lib/test/test_getpass.py b/Lib/test/test_getpass.py
index ab36535a1cfa8a..9c3def2c3be59b 100644
--- a/Lib/test/test_getpass.py
+++ b/Lib/test/test_getpass.py
@@ -201,5 +201,41 @@ def test_control_chars_with_echo_char(self):
         self.assertEqual('Password: *******\x08 \x08', mock_output.getvalue())
 
 
+class GetpassEchoCharTest(unittest.TestCase):
+
+    def test_accept_none(self):
+        getpass._check_echo_char(None)
+
+    @support.subTests('echo_char', ["*", "A", " "])
+    def test_accept_single_printable_ascii(self, echo_char):
+        getpass._check_echo_char(echo_char)
+
+    def test_reject_empty_string(self):
+        self.assertRaises(ValueError, getpass.getpass, echo_char="")
+
+    @support.subTests('echo_char', ["***", "AA", "aA*!"])
+    def test_reject_multi_character_strings(self, echo_char):
+        self.assertRaises(ValueError, getpass.getpass, echo_char=echo_char)
+
+    @support.subTests('echo_char', [
+        '\N{LATIN CAPITAL LETTER AE}',  # non-ASCII single character
+        '\N{HEAVY BLACK HEART}',        # non-ASCII multibyte character
+    ])
+    def test_reject_non_ascii(self, echo_char):
+        self.assertRaises(ValueError, getpass.getpass, echo_char=echo_char)
+
+    @support.subTests('echo_char', [
+        ch for ch in map(chr, range(0, 128))
+        if not ch.isprintable()
+    ])
+    def test_reject_non_printable_characters(self, echo_char):
+        self.assertRaises(ValueError, getpass.getpass, echo_char=echo_char)
+
+    # TypeError Rejection
+    @support.subTests('echo_char', [b"*", 0, 0.0, [], {}])
+    def test_reject_non_string(self, echo_char):
+        self.assertRaises(TypeError, getpass.getpass, echo_char=echo_char)
+
+
 if __name__ == "__main__":
     unittest.main()
diff --git a/Misc/ACKS b/Misc/ACKS
index 83bc62726eecc9..ee3d66a04f0217 100644
--- a/Misc/ACKS
+++ b/Misc/ACKS
@@ -903,6 +903,7 @@ Jim Jewett
 Pedro Diaz Jimenez
 Orjan Johansen
 Fredrik Johansson
+Benjamin K. Johnson
 Gregory K. Johnson
 Kent Johnson
 Michael Johnson
diff --git 
a/Misc/NEWS.d/next/Library/2025-09-06-11-26-21.gh-issue-138514.66ltOb.rst 
b/Misc/NEWS.d/next/Library/2025-09-06-11-26-21.gh-issue-138514.66ltOb.rst
new file mode 100644
index 00000000000000..75151ea86373d4
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2025-09-06-11-26-21.gh-issue-138514.66ltOb.rst
@@ -0,0 +1,2 @@
+Raise :exc:`ValueError` when a multi-character string is passed to the
+*echo_char* parameter of :func:`getpass.getpass`. Patch by Benjamin Johnson.

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]

Reply via email to