Fix MASTER_PASSWORD_HOOK tokenisation on Windows

posix=False in shlex.split() never strips quote characters, so any
Windows hook path quoted to handle spaces (or any quoted argument)
came out with literal quotes still attached, and unquoted spaced
paths were split into multiple argv elements either way.

Use a shlex.shlex instance with posix=True (correct quote-stripping)
and escape='' (so backslashes in Windows paths are not treated as
escape characters). Document the quoting requirement in config.py
and add regression tests for quoted/unquoted spaced paths.
This commit is contained in:
Ashesh Vashi
2026-07-25 00:55:46 +05:30
parent ea7e798aac
commit e7a8576731
3 changed files with 50 additions and 6 deletions
+3 -1
View File
@@ -641,7 +641,9 @@ USE_OS_SECRET_STORAGE = True
# shell, so shell features (pipes, redirection, environment-variable
# expansion, globbing) in this value are not interpreted; put any such
# logic inside the hook script itself. On Windows, a batch file must be
# invoked via an executable wrapper rather than directly.
# invoked via an executable wrapper rather than directly. If the path to
# the script/program contains spaces, quote it, e.g. -
# MASTER_PASSWORD_HOOK = '"<PATH WITH SPACES>/passwdgen_script.exe" %u'
##########################################################################
MASTER_PASSWORD_HOOK = None
+10 -5
View File
@@ -192,7 +192,6 @@ def get_master_password_from_master_hook():
:return: Output of the command, or None on failure.
"""
import os
import shlex
import subprocess
@@ -204,10 +203,16 @@ def get_master_password_from_master_hook():
# untrusted username into the individual arguments. Substituting
# before tokenising would let metacharacters in the username alter
# the command, so the order here is security-critical.
args = [
token.replace('%u', current_user.username)
for token in shlex.split(cmd, posix=(os.name != 'nt'))
]
#
# posix=True strips quotes correctly on both platforms (unlike
# posix=False, which never strips quotes at all and breaks any
# quoted path or argument on Windows). escape='' disables backslash
# escaping so Windows paths (e.g. C:\Program Files\hook.exe) are not
# mangled.
lex = shlex.shlex(cmd, posix=True)
lex.whitespace_split = True
lex.escape = ''
args = [token.replace('%u', current_user.username) for token in lex]
if not args:
return None
@@ -87,6 +87,43 @@ class TestMasterPasswordHookConfinesUsername(BaseTestGenerator):
self.assertEqual(output, 'secret-key')
class TestMasterPasswordHookQuotedPath(BaseTestGenerator):
"""A quoted hook program path (needed when the path contains spaces,
e.g. 'C:\\Program Files\\hook.exe' on Windows) must be tokenised as a
single argv element with the quotes stripped, and any backslashes in
the path must survive untouched."""
scenarios = [
('quoted path with spaces, posix-style',
dict(cmd='"/opt/my secrets/get-secret" %u',
expected_argv0='/opt/my secrets/get-secret')),
('quoted windows path with backslashes and spaces',
dict(cmd='"C:\\Program Files\\hook.exe" %u',
expected_argv0='C:\\Program Files\\hook.exe')),
('unquoted windows path, no spaces',
dict(cmd='C:\\hook.exe %u', expected_argv0='C:\\hook.exe')),
]
def runTest(self):
captured = {}
def fake_popen(args, *a, **kw):
captured['args'] = args
return _FakeProc()
fake_user = MagicMock()
fake_user.username = 'alice'
with patch.object(mp, 'current_user', fake_user), \
patch.object(mp.config, 'MASTER_PASSWORD_HOOK', self.cmd), \
patch('subprocess.Popen', side_effect=fake_popen):
mp.get_master_password_from_master_hook()
self.assertEqual(captured['args'][0], self.expected_argv0)
self.assertEqual(captured['args'][1], 'alice')
self.assertEqual(len(captured['args']), 2)
class TestMasterPasswordHookNoShellExecution(BaseTestGenerator):
"""End-to-end PoC regression (POSIX only): a username containing shell
syntax must not cause command execution. Mirrors the reported proof of