Skip to content

Commit a7f7265

Browse files
authored
PEP 787: Safer subprocess usage using t-strings (#4368)
1 parent 12925da commit a7f7265

File tree

2 files changed

+266
-0
lines changed

2 files changed

+266
-0
lines changed

.github/CODEOWNERS

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -665,6 +665,8 @@ peps/pep-0783.rst @hoodmane @ambv
665665
peps/pep-0784.rst @gpshead
666666
peps/pep-0785.rst @gpshead
667667
# ...
668+
peps/pep-0787.rst @ncoghlan
669+
# ...
668670
peps/pep-0789.rst @njsmith
669671
# ...
670672
peps/pep-0801.rst @warsaw

peps/pep-0787.rst

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
PEP: 787
2+
Title: Safer subprocess usage using t-strings
3+
Author: Nick Humrich <[email protected]>, Alyssa Coghlan <[email protected]>
4+
Discussions-To: Pending
5+
Status: Draft
6+
Type: Standards Track
7+
Requires: 750
8+
Created: 13-Apr-2025
9+
Python-Version: 3.14
10+
11+
Abstract
12+
========
13+
14+
:pep:`750` introduced template strings (t-strings) as a generalization of f-strings,
15+
providing a way to safely handle string interpolation in various contexts. This PEP
16+
proposes extending the :mod:`subprocess` and :mod:`shlex` modules to natively support t-strings, enabling
17+
safer and more ergonomic shell command execution with interpolated values, as well as
18+
serving as a reference implementation for the t-string feature to improve API ergonomics.
19+
20+
Motivation
21+
==========
22+
23+
Despite the safety benefits and flexibility that template strings offer in PEP 750,
24+
they lack a concrete consumer implementation in the standard library that demonstrates
25+
their practical application. One of the most compelling use cases for t-strings is safer
26+
shell command execution, as noted in the withdrawn :pep:`501`:
27+
28+
.. code-block:: python
29+
30+
# Unsafe with f-strings:
31+
os.system(f"echo {message_from_user}")
32+
33+
# Also unsafe with f-strings
34+
subprocess.run(f"echo {message_from_user}", shell=True)
35+
36+
# Fails with f-strings
37+
subprocess.run(f"echo {message_from_user}")
38+
39+
# Safe with t-strings and POSIX-compliant shell quoting:
40+
subprocess.run(t"echo {message_from_user}", shell=True)
41+
42+
# Safe on all platforms with t-strings:
43+
subprocess.run(t"echo {message_from_user}")
44+
45+
# Safe on all platforms without t-strings:
46+
subprocess.run(["echo", str(message_from_user)])
47+
48+
Currently, developers must choose between convenience (using f-strings with potential
49+
security risks) and safety (using more verbose, list-based APIs). By adding native t-string
50+
support to the :mod:`subprocess` module, we provide a consumer reference implementation that
51+
demonstrates the value of t-strings while addressing a common security concern.
52+
53+
Rationale
54+
=========
55+
56+
The subprocess module is an ideal candidate for t-string support for several reasons:
57+
58+
* Command injection vulnerabilities in shell commands are a well-known security risk.
59+
* The :mod:`subprocess` module already supports both string and list-based command specifications.
60+
* There's a natural mapping between t-strings and proper shell escaping that provides both convenience and safety.
61+
* It serves as a practical showcase for t-strings that developers can immediately understand and appreciate.
62+
63+
By extending subprocess to handle t-strings natively, we make it easier to write secure code without sacrificing
64+
the convenience that led many developers to use potentially unsafe f-strings.
65+
66+
Specification
67+
=============
68+
69+
This PEP proposes two main additions to the standard library:
70+
71+
#. A new ``sh()`` renderer function in the :mod:`shlex` module for safe shell command construction
72+
#. Adding t-string support to the :mod:`subprocess` module's core functions,
73+
particularly :class:`subprocess.Popen`, :func:`subprocess.run`, and other related functions
74+
that accept a command argument
75+
76+
77+
Renderer for shell escaping added to :mod:`shlex`
78+
-------------------------------------------------
79+
80+
As a reference implementation, a renderer for safe POSIX shell escaping will be added to
81+
the :mod:`shlex` module. This renderer would be called ``sh`` and would be equivalent to
82+
calling ``shlex.quote`` on each field value in the template literal.
83+
84+
Thus::
85+
86+
os.system(shlex.sh(t"cat {myfile}"))
87+
88+
would have the same behavior as::
89+
90+
os.system("cat " + shlex.quote(myfile)))
91+
92+
93+
The addition of ``shlex.sh`` will NOT change the existing admonishments in the
94+
:mod:`subprocess` documentation that passing ``shell=True`` is best avoided, nor the
95+
reference from the :func:`os.system` documentation to the higher level ``subprocess`` APIs.
96+
97+
The t-string processor implementation would look like::
98+
99+
from string.templatelib import Template, Interpolation
100+
101+
def sh(template: Template) -> str:
102+
parts: list[str] = []
103+
for item in template:
104+
if isinstance(item, Interpolation):
105+
# shlex.sh implementation, so shlex.quote can be used directly
106+
parts.append(quote(str(item.value)))
107+
else:
108+
parts.append(item)
109+
110+
# shlex.sh implementation, so `join` references shlex.join
111+
return join(parts)
112+
113+
This allows for explicit escaping of t-strings for shell usage::
114+
115+
import shlex
116+
# Safe POSIX-compliant shell command construction
117+
command = shlex.sh(t"cat {filename}")
118+
os.system(command)
119+
120+
Changes to subprocess module
121+
----------------------------
122+
123+
With the additional renderer in the shlex module, and the addition of template strings,
124+
the :mod:`subprocess` module can be changed to handle accepting template strings
125+
as an additional input type to ``Popen``, as it already accepts a sequence, or a string,
126+
with different behavior for each. In return, all :class:`subprocess.Popen` higher level
127+
functions such as :func:`subprocess.run` could accept strings in a safe way
128+
(on all systems for ``shell=False`` and on :ref:`POSIX systems <pep-0787-defer-non-posix-shells>` for ``shell=True``).
129+
130+
For example::
131+
132+
subprocess.run(t"cat {myfile}", shell=True)
133+
134+
would automatically use the ``shlex.sh`` renderer provided in this PEP. Therefore, using
135+
``shlex`` inside a ``subprocess.run`` call like so::
136+
137+
subprocess.run(shlex.sh(t"cat {myfile}"), shell=True)
138+
139+
would be redundant, as ``run`` would automatically render any template literals
140+
through ``shlex.sh``
141+
142+
When ``subprocess.Popen`` is called without ``shell=True``, t-string support would still
143+
provide subprocess with a more ergonomic syntax. For example::
144+
145+
subprocess.run(t"cat {myfile} --flag {value}")
146+
147+
would be equivalent to::
148+
149+
subprocess.run(["cat", myfile, "--flag", value])
150+
151+
or, more accurately::
152+
153+
subprocess.run(shlex.split(f"cat {shlex.quote(myfile)} --flag {shlex.quote(value)}"))
154+
155+
It would do this by first using the ``shlex.sh`` renderer, as above, then using
156+
``shlex.split`` on the result.
157+
158+
The implementation inside ``subprocess.Popen._execute_child`` would check for t-strings::
159+
160+
from string.templatelib import Template
161+
162+
if isinstance(args, Template):
163+
import shlex
164+
if shell:
165+
args = shlex.sh(args)
166+
else:
167+
args = shlex.split(shlex.sh(args))
168+
169+
Backwards Compatibility
170+
=======================
171+
172+
This change is fully backwards compatible as it only adds new functionality without altering existing behavior.
173+
The subprocess module will continue to handle strings and lists in the same way it currently does.
174+
175+
Security Implications
176+
=====================
177+
178+
This PEP is explicitly designed to improve security by providing a safer alternative to using
179+
f-strings with shell commands. By automatically applying appropriate escaping based on context
180+
(shell or non-shell), it helps prevent command injection vulnerabilities.
181+
182+
However, it's worth noting that when ``shell=True`` is used, the safety is limited to
183+
POSIX-compliant shells. On Windows systems where cmd.exe or PowerShell may be used as the shell,
184+
the escaping mechanism provided by :func:`shlex.quote` is not sufficient to prevent all forms
185+
of command injection.
186+
187+
How to Teach This
188+
=================
189+
190+
This feature can be taught as a natural extension of t-strings that demonstrates their practical value:
191+
192+
1. Introduce the problem of command injection and why f-strings are dangerous with shell commands
193+
2. Show the traditional solutions (list-based commands, manual escaping)
194+
3. Introduce the ``shlex.sh`` renderer for explicit shell escaping::
195+
196+
# Unsafe:
197+
os.system(f"cat {filename}") # Potential command injection!
198+
199+
# Safe using shlex.sh:
200+
os.system(shlex.sh(t"cat {filename}")) # Explicitly escaping for shell
201+
202+
4. Introduce the subprocess module's native t-string support::
203+
204+
# Unsafe:
205+
subprocess.run(f"cat {filename}", shell=True) # Potential command injection!
206+
207+
# Safe but verbose:
208+
subprocess.run(["cat", filename])
209+
210+
# Safe and readable with t-strings:
211+
subprocess.run(t"cat {filename}", shell=True) # Automatically escapes filename
212+
subprocess.run(t"cat {filename}") # Automatically converts to list form
213+
214+
The implementation should be added to both the shlex and subprocess module documentation with clear
215+
examples and security advisories.
216+
217+
.. _pep-0787-defer-non-posix-shells:
218+
219+
Deferring escaped rendering support for non-POSIX shells
220+
--------------------------------------------------------
221+
222+
:func:`shlex.quote` works by classifying the regex character set ``[\w@%+=:,./-]`` to be
223+
safe, deeming all other characters to be unsafe, and hence requiring quoting of the string
224+
containing them. The quoting mechanism used is then specific to the way that string quoting
225+
works in POSIX shells, so it cannot be trusted when running a shell that doesn't follow
226+
POSIX shell string quoting rules.
227+
228+
For example, running ``subprocess.run(f"echo {shlex.quote(sys.argv[1])}", shell=True)`` is
229+
safe when using a shell that follows POSIX quoting rules:
230+
231+
.. code-block:: console
232+
233+
$ cat > run_quoted.py
234+
import sys, shlex, subprocess
235+
subprocess.run(f"echo {shlex.quote(sys.argv[1])}", shell=True)
236+
$ python3 run_quoted.py pwd
237+
pwd
238+
$ python3 run_quoted.py '; pwd'
239+
; pwd
240+
$ python3 run_quoted.py "'pwd'"
241+
'pwd'
242+
243+
but remains unsafe when running a shell from Python invokes ``cmd.exe`` (or Powershell):
244+
245+
.. code-block:: powershell
246+
247+
S:\> echo import sys, shlex, subprocess > run_quoted.py
248+
S:\> echo subprocess.run(f"echo {shlex.quote(sys.argv[1])}", shell=True) >> run_quoted.py
249+
S:\> type run_quoted.py
250+
import sys, shlex, subprocess
251+
subprocess.run(f"echo {shlex.quote(sys.argv[1])}", shell=True)
252+
S:\> python3 run_quoted.py "echo OK"
253+
'echo OK'
254+
S:\> python3 run_quoted.py "'& echo Oh no!"
255+
''"'"'
256+
Oh no!'
257+
258+
Resolving this standard library limitation is beyond the scope of this PEP.
259+
260+
Copyright
261+
=========
262+
263+
This document is placed in the public domain or under the
264+
CC0-1.0-Universal license, whichever is more permissive.

0 commit comments

Comments
 (0)