Visitar URL original
🐛 fix(bash): escape the prompt for every PS1 state by gaborbernat · Pull Request #3466 · pypa/virtualenv · GitHub
Skip to content

🐛 fix(bash): escape the prompt for every PS1 state - #3466

Merged
gaborbernat merged 17 commits into
pypa:mainfrom
gaborbernat:fix/bash-prompt-state
Oct 22, 2026
Merged

gaborbernat merged 17 commits into
pypa:mainfrom
gaborbernat:fix/bash-prompt-state

Conversation

@gaborbernat

@gaborbernat gaborbernat commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Fixes GHSA-cx6f-vjhg-g3cc, reported by @rozzkml: an environment prompt taken from project metadata could run commands in the user's shell once a shell option changed after activation.

A --prompt value, or an environment directory name, becomes part of the shell prompt. In bash the activator stored it raw whenever promptvars was off at activation, and shopt -s promptvars, set -o posix or a child bash that inherits an exported PS1 later expanded it, so a value such as x$(id > /tmp/pwned)y ran on the next prompt redraw. 21.14.9 introduced this when it made the escaping depend on the state at activation.

flowchart LR
    Meta["Project metadata<br/>or directory name"] --> Prompt["--prompt value"]
    Prompt --> Activate["source bin/activate"]
    Activate --> Later{"Later in the session:<br/>promptvars on, posix mode,<br/>child shell"}
    Later -- "21.14.9 to 21.14.12" --> Run["Prompt redraw runs $(...)"]
    Later -- "this PR" --> Text["Prompt shows the text"]
    classDef input fill:#cfe4ff,stroke:#1f6feb,color:#0b1f3a;
    classDef bad fill:#ffd7d5,stroke:#cf222e,color:#3a0b0b;
    classDef good fill:#d3f5dc,stroke:#1a7f37,color:#0b2e16;
    class Meta,Prompt,Activate,Later input;
    class Run bad;
    class Text good;
Loading

The prompt now stays text under every option a user can toggle afterwards. Bash gets the value escaped for both of its prompt passes, built one character at a time, because the ${var//pat/rep} replacement it used before strips backslashes differently across bash versions, BASH_COMPAT levels and patsub_replacement; the result behaves the same on bash 3.2 through 5.3, including macOS /bin/bash. zsh reads the prompt from psvar, which it never parses as shell syntax. Users who keep promptvars off see a backslash doubled and a backtick with a leading backslash, the one display cost no escaping can avoid while staying safe when the option turns on.

The tests hold every activator to the same check, so a shell that regresses fails the same test bash would:

flowchart LR
    Corpus["Injection corpus<br/>known bypass shapes"] --> Matrix
    States["Shell state at activation<br/>promptvars, posix, BASH_COMPAT,<br/>PROMPT_SUBST, delayed expansion"] --> Matrix
    Actions["Later action<br/>toggle an option, child shell"] --> Matrix
    Matrix["One matrix test for bash, zsh,<br/>fish, tcsh, nushell, PowerShell,<br/>xonsh and cmd.exe"] --> Check["No command ran<br/>and the prompt shows as documented"]
    Fuzz["Hypothesis prompts per shell"] --> Check
    classDef input fill:#cfe4ff,stroke:#1f6feb,color:#0b1f3a;
    classDef test fill:#ffe3a3,stroke:#d29200,color:#3a2c00;
    classDef good fill:#d3f5dc,stroke:#1a7f37,color:#0b2e16;
    class Corpus,States,Actions input;
    class Matrix,Fuzz test;
    class Check good;
Loading

Reviewing the other activators found no other way for a prompt to run commands; fish, nushell, PowerShell, cmd.exe, xonsh and the Python activator keep it as data.

The PR also hardens two writes. virtualenv now writes the .venv redirect and the wheel RECORD with exclusive create, so a link planted between the existence check and the write cannot redirect it; virtualenv also replaces a read-only redirect that already points at a virtualenv, which used to stay with a warning. On the scanning side, two custom CodeQL queries had stopped matching the code they were written for while their own tests stayed green; this PR re-anchors both, and four new queries cover the other advisories from October. #3464 tracks the remaining CodeQL and CI follow-ups.

The CI changes let the check workflow pass without reruns. setup-uv now installs every test interpreter: setup-python finds GraalPy through the GitHub releases listing of oracle/graalpython, which often returns an empty page, and uv's default preference had the macOS 3.9 jobs testing Apple's Python instead of a CPython release. tox passes PSModuleAnalysisCachePath through, because without the prebuilt cache the runner image points at, every Windows PowerShell 5.1 launch rescanned all installed modules (about 18 seconds each) and pushed tests past their timeout. The GraalPy runner compiles the standard library and site-packages before starting its shards, since GraalPy repeats id() values across processes and concurrent bytecode writes picked the same temporary file. Nushell, fish and RustPython install from pinned, hash-checked release assets, each in its own parallel setup step.

The bash activator stored --prompt raw when promptvars was off at
activation. Turning promptvars on, entering posix mode, or exporting
PS1 to a child bash later expanded the value, so $(...) ran.

Escape each character for both of bash's prompt passes, whatever the
state is at activation. The loop builds the escapes by concatenation,
one character at a time: the replacement in ${var//pat/rep} goes through a
different quote-removal path for BASH_COMPAT 42 and older and for
patsub_replacement on or off (parameter_brace_patsub in subst.c), so
its backslash count depends on the bash version and options.

zsh draws the prompt from psvar[9] instead of an escaped PS1 literal.
zsh inserts psvar entries as data under every prompt option, and an
exported PS1 holds %9v rather than shell syntax. deactivate restores
the slot.

GHSA-cx6f-vjhg-g3cc
@gaborbernat
gaborbernat force-pushed the fix/bash-prompt-state branch from 35ff6b9 to 339166d Compare October 22, 2026 14:03
A shell can change how it reads the stored prompt after activation:
promptvars and posix mode in bash, PROMPT_SUBST in zsh, histchars in
tcsh, delayed expansion in cmd.exe, or a child shell that inherits the
prompt. tests/prompt_matrix.py lists those states and actions per
shell with one injection corpus and one driver, and a single matrix
test checks every activator against it: no payload runs, and the
prompt shows the way that shell's display rule says.

The matrix renders each activation script in process instead of
creating an environment per prompt, since the script depends only on
the prompt; creating them pushed slower interpreters past the per-test
timeout. It carries the slow mark like the other real-shell activation
tests, so the graalpy and RustPython jobs skip it as they skip those.

Install zsh and dash in CI so their cases run there.
Generate prompts from the tokens each shell reads as syntax, and run
each one through every prompt state and later action of that shell
with the shared matrix driver. Each example builds the activator in
process, so the search exercises quoting without a launch per example,
and per-shell seeds replay the known bypass shapes first.

Hypothesis rather than Atheris: coverage guidance adds nothing when the
system under test is a subprocess, and the property env runs on
Windows, where batch and PowerShell 5.1 live. The check job installs
tcsh, zsh, fish and nushell for that env on Linux; xonsh comes from the
property dependency group.
creator.py and pip_invoke.py opened the .venv redirect and the wheel
RECORD with a plain write, which follows a link planted after the
existence check. Both now go through write_file, which unlinks the old
entry and creates the new one with O_EXCL.

Windows refuses to unlink a read-only file, so write_file clears the
attribute with the clear_readonly helper safe_delete uses. write_file
now replaces a read-only redirect that points at a virtualenv on every
platform, where the plain write warned and left it.
Two queries matched code that no longer exists. UnquotedActivationValue
sank on str.replace inside instantiate_template, which the single-pass
substitution replaced, and MultilinePyvenvCfgValue sank on write_text,
which PyEnvCfg.write no longer calls. Both passed their fixtures while
reporting nothing on the tree.

Sink the substitution values on the iteration variable used outside a
quote() call, and sink write_file's data argument. Fixtures now mirror
the current shapes, with a positive and a negative class each.
Add four queries for code shapes from the October advisories: an
in-place write that follows a link, chmod inside an rmtree error
handler, a Python or xonsh quote that uses repr() instead of ascii(),
and a ScriptMaker that builds its shebang without shell_shebang().
Each ships with a fixture that has one positive and one negative case,
so the query test fails if the query stops matching that shape.

The in-place write query allows the app-data cache and the three wheel
image modules (base, copy, symlink). Those write into virtualenv's own
app-data cache, not into the environment being created, so this query
does not cover them.
packages.microsoft.com answered apt-get update with 403 and an unsigned
InRelease on one runner, which failed the job before any test ran. No
job installs from that repository, so the steps remove its sources
before updating.
@gaborbernat
gaborbernat force-pushed the fix/bash-prompt-state branch from 339166d to 963bc3d Compare October 22, 2026 15:06
GitHub Actions now runs a group of steps concurrently with parallel
(https://github.blog/changelog/2026-06-25-actions-steps-can-now-be-run-in-parallel/).
The test and check jobs install tox, Python, RustPython and the shells
at the same time and start tox once all of them finish. Each
platform's shells stay in one step, because apt and brew each hold a
global lock that makes concurrent installs wait or fail.
Nushell and fish came from apt and brew, which hold a global lock, so they
waited behind the other packages in one step per platform. They now download
from their pinned GitHub release assets with a sha256 check, as RustPython and
the Windows Nushell build already did, and each runs as its own parallel step.
The fish PPA and the fury.io Nushell repository, two more apt sources that
could fail apt-get update, are gone.
setup-python finds GraalPy by listing the GitHub releases of
oracle/graalpython, and that REST listing now often returns an empty page
with status 200, so GraalPy jobs failed with "No release was found in GraalPy
version.json" or hung until the job timeout. uv installs every interpreter in
the matrix from the download list built into uv, as the 3.15 jobs already did,
so the tests no longer depend on that listing and the GraalPy pin goes away.
The artifact client retries HTTP errors but rethrows ETIMEDOUT, ECONNRESET
and the other network error codes on the first attempt, so one dropped
connection to the artifact service failed a job whose tests had passed.
The stdlib venv module run from inside a virtualenv on Python 3.10 and older
records the virtualenv's bin directory as home, so the new interpreter finds
its standard library only through the prefix compiled into the base build.
That prefix exists for the setup-python builds but not for the uv-managed
ones, which report /install, so the bare interpreter died with "No module
named 'encodings'". virtualenv points the environment at the base interpreter.
uv prefers an installed interpreter over a download, and the macOS runners
ship Apple's Python 3.9, so the 3.9 jobs tested that build instead of a
CPython 3.9 release. The Homebrew legs keep the default because they pass
Homebrew's interpreter through UV_PYTHON.
@gaborbernat
gaborbernat force-pushed the fix/bash-prompt-state branch 2 times, most recently from 0de7c15 to 685b398 Compare October 22, 2026 17:42
The Windows runner image sets PSModuleAnalysisCachePath to a prebuilt module
analysis cache. tox dropped it, so each Windows PowerShell 5.1 launch inside
the test session rescanned every module on the image: about 18 seconds per
launch against 0.3 with the cache, and one test ran past the 120 second
per-test timeout.
@gaborbernat
gaborbernat force-pushed the fix/bash-prompt-state branch from 685b398 to 2bef45f Compare October 22, 2026 17:42
importlib writes bytecode to a temporary file named after id(path) and renames
it into place. GraalPy hands out the same id() values in every process, so
shards importing a module for the first time pick the same temporary name:
one shard's cleanup deletes another's file mid-write and a third rewrites it,
which can publish a truncated cache file. A shard then crashed with an
EOFException while loading unittest. compileall gives each file to a single
worker, so compiling the standard library and site-packages once before the
shards start leaves them nothing to write in the trees they share.
@gaborbernat
gaborbernat force-pushed the fix/bash-prompt-state branch 2 times, most recently from 86ddb84 to 4079723 Compare October 22, 2026 19:12
No test uses snap since Nushell installs from its release asset, and the runner
image already ships snapd, so the install only upgraded it: a 33 MB download
that took 13 minutes from a slow mirror and ran a GraalPy job into its
timeout.
make_zipapp fetched each locked wheel with one urlopen call, so a single TLS
EOF from the index failed the zipapp check. The download now retries
connection errors, timeouts and 5xx responses twice with a short backoff; a
4xx response still fails at once, and the sha256 check is unchanged.
@gaborbernat
gaborbernat marked this pull request as ready for review October 22, 2026 20:05
@gaborbernat
gaborbernat merged commit 3598e20 into pypa:main Oct 22, 2026
77 checks passed
@gaborbernat
gaborbernat deleted the fix/bash-prompt-state branch October 22, 2026 20:10
darrenhuai added a commit to darrenhuai/virtualenv that referenced this pull request Oct 29, 2026
…heck

With every file in the environment read-only, re-seeding still failed in _uninstall_dist, which removed the old
pip-3.14.exe with a bare unlink. It now goes through remove_file like the creator does.

The hard-link check in remove_file is gone. safe_delete and pypa#3466 clear the attribute through hard links, so the check
only made copy() of a file behave differently from copy() of a folder.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant