Skip to content

Fixing path-based interpreter injection in Spaceship


We shipped a security hardening update for spaceship::extract, the helper that reads JSON, YAML, and TOML files for sections such as package.

The reported issue was simple and dangerous: Spaceship discovered a file path with upsearch, then interpolated that path directly into inline Python, Ruby, or Node source passed through -c, -e, or -p. A malicious directory name containing a single quote could break out of the string literal and execute arbitrary code during prompt rendering.

That meant a victim did not need to run a script from the repository. In the vulnerable paths, a passive cd into a directory containing package.json, pubspec.yaml, or similar project files was enough to trigger the bug.

The vulnerable pattern

Before the fix, several extractor helpers embedded the file path directly into the interpreter source string:

lib/extract.zsh
spaceship::extract::python json "json.load(open('$file'))" "$@"
spaceship::extract::ruby 'json' "JSON::load(File.read('$file'))" "$@"
node -p "['${(j|','|)keys}'].map(s => s.split('.').reduce((obj, key) => obj[key], require('./$file'))).find(Boolean)"

That approach is safe only if the path is fully trusted and already escaped for the target interpreter. Here it was neither.

Example trigger

The reported proof of concept used a directory name like this:

foo'+`id>PWNED`+'

With a package.json inside that directory, the Ruby JSON path could evaluate into a command like this:

JSON::load(File.read('/tmp/project/foo'+`id>PWNED`+'/package.json'))

The shell command inside the Ruby backticks runs while Ruby evaluates the argument. File.read still fails afterward, but the command already executed.

In practice, this could happen through the package section:

local package_json=$(spaceship::upsearch package.json) || return
local package_version="$(spaceship::extract --json "$package_json" version)"

If jq or yq were not selected first, Spaceship could fall back to Ruby, Python, or Node and hit the vulnerable code path.

The fix

The mitigation is to keep the file path out of interpreter source strings entirely. Instead of interpolating $file, Spaceship now passes the path as a positional argument and reads it from argv inside the interpreter:

lib/extract.zsh
spaceship::extract::python json "json.load(open(sys.argv[1]))" "$file" "$@"
spaceship::extract::ruby 'json' "JSON::load(File.read(ARGV[0]))" "$file" "$@"
node -p "['${(j|','|)keys}'].map(s => s.split('.').reduce((obj, key) => obj[key], require(require('path').resolve(process.argv[1])))).find(Boolean)" -- "$file" 2>/dev/null

This makes the filename data, not code.

The patch also hardens the Ruby YAML path:

spaceship::extract::ruby 'yaml' "YAML.safe_load(File.read(ARGV[0]))" "$file" "$@"

That replaces YAML::load_file(...), which can deserialize arbitrary Ruby objects on older Ruby/Psych combinations.

Finally, the package section now quotes the file path when it calls spaceship::extract, which avoids zsh splitting paths that contain spaces before they ever reach the hardened extractor.

What this changes for users

No configuration changes are required.

Regression coverage

Security fixes are most useful when they stay fixed. This change set keeps the interpreter boundary explicit and adds tests around the exact data flow that was previously exploitable.

Acknowledgments

Thanks to Trey Keown for responsibly disclosing this issue, providing a proof of concept and a patch, and working with us to get it fixed.