|
| 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