VYPR
High severity8.1NVD Advisory· Published Jun 11, 2026· Updated Jun 11, 2026

CVE-2026-53777

CVE-2026-53777

Description

Perry before 0.5.1159 allows a malicious build server to write arbitrary files via path traversal in unsanitized WebSocket artifact_name and download_path fields.

AI Insight

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

Perry before 0.5.1159 allows a malicious build server to write arbitrary files via path traversal in unsanitized WebSocket artifact_name and download_path fields.

Vulnerability

Perry versions before 0.5.1159 contain a path traversal vulnerability in the perry publish command [1][2][3]. The build server supplies an artifact_name field in ArtifactReady WebSocket messages, which is used verbatim to construct the local destination path via PathBuf::join without sanitization [1][2]. Additionally, for self-hosted hubs, the download_path field is also unsanitized [1][2]. This allows a malicious build server to supply traversal payloads (e.g., ../../.ssh/authorized_keys) and write arbitrary content to any location writable by the running process [2][4].

Exploitation

An attacker must control the WebSocket server URL that the client connects to, which can be achieved by adding a malicious server entry to perry.toml, either through a pull request or direct configuration [2]. The client uses PathBuf::join to construct the destination file path from the unsanitized artifact_name [1][2]. For self-hosted hubs, the attacker can also control download_path to copy arbitrary local files (e.g., /home/user/.aws/credentials) to an attacker-accessible location [2]. When run with --no-interactive or PERRY_NO_CONFIRM=1, the client performs the operation without user confirmation [2].

Impact

An attacker can achieve arbitrary file write, overwriting sensitive files such as SSH authorized_keys, malicious cron jobs, or system configuration files, limited only by the write permissions of the running process [2][4]. For self-hosted hubs, arbitrary file read is also possible by specifying a local file as the source and a publicly accessible path as the destination [1][2]. The attack can lead to remote code execution, privilege escalation, or data exfiltration depending on the overwritten file [2][4].

Mitigation

Perry version 0.5.1159 patches the vulnerability [1][3]. The fix introduces sanitize_artifact_name() to reduce the artifact_name to a bare, traversal-free file name (rejecting absolute paths, .., ., and embedded path separators) and server_is_local() to only allow local copy from download_path when the server is on the local machine [1][3]. Users should upgrade to v0.5.1159 or later immediately. The vulnerability is documented under GHSA-x55v-q459-68ch [2][4].

AI Insight generated on Jun 11, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

2
  • Perryts/Perryreferences2 versions
    (expand)+ 1 more
    • (no CPE)
    • (no CPE)range: <0.5.1159

Patches

1
95e1043df808

fix(publish): sanitize server-controlled artifact path (GHSA-x55v-q459-68ch) (#4989)

https://github.com/perryts/perryRalph KüpperJun 11, 2026via nvd-ref
2 files changed · +104 2
  • crates/perry/src/commands/publish/mod.rs+54 2 modified
    @@ -13,6 +13,7 @@ use std::fs;
     use std::io::Write;
     use std::path::{Path, PathBuf};
     use tokio_tungstenite::tungstenite::Message;
    +use url::Url;
     use walkdir::WalkDir;
     
     use crate::{OutputFormat, Platform};
    @@ -1789,10 +1790,25 @@ async fn run_async(args: PublishArgs, format: OutputFormat, _use_color: bool) ->
                     }
     
                     fs::create_dir_all(&args.output)?;
    -                let dest = args.output.join(&name);
    +                // `name` is supplied verbatim by the build server over the
    +                // WebSocket. Reduce it to a bare, traversal-free file name so a
    +                // malicious hub cannot write outside the output directory
    +                // (GHSA-x55v-q459-68ch).
    +                let safe_name = sanitize_artifact_name(&name)?;
    +                let dest = args.output.join(&safe_name);
     
                     if let Some(ref src_path) = download_path {
    -                    // Local path available (self-hosted hub) - copy directly
    +                    // `download_path` is also server-controlled. A filesystem
    +                    // path is only meaningful when the hub shares this machine's
    +                    // filesystem; honoring it for a remote hub would let a
    +                    // malicious server copy out any file the user can read
    +                    // (GHSA-x55v-q459-68ch, Path B). Fall back to HTTP otherwise.
    +                    if !server_is_local(&server_url) {
    +                        bail!(
    +                            "Hub at {server_url} reported a local artifact path ({src_path}) but is not a local hub; refusing to read from an arbitrary local path"
    +                        );
    +                    }
    +                    // Local path available (self-hosted hub on this machine) - copy directly
                         fs::copy(src_path, &dest)
                             .with_context(|| format!("Failed to copy artifact from {src_path}"))?;
                     } else {
    @@ -1875,6 +1891,42 @@ async fn run_async(args: PublishArgs, format: OutputFormat, _use_color: bool) ->
         Ok(())
     }
     
    +/// Reduce a server-supplied artifact name to a single, traversal-free file
    +/// name. The build server controls this value over the WebSocket, so it must
    +/// never be able to escape the chosen output directory: absolute paths, `..`,
    +/// `.`, and embedded path separators are all rejected (GHSA-x55v-q459-68ch).
    +fn sanitize_artifact_name(name: &str) -> Result<String> {
    +    let trimmed = name.trim();
    +    let is_unsafe = trimmed.is_empty()
    +        || trimmed == "."
    +        || trimmed == ".."
    +        || trimmed.contains('/')
    +        || trimmed.contains('\\')
    +        // Anything whose final path component is not exactly the input (drive
    +        // prefixes, embedded NULs, platform-specific separators, ...).
    +        || Path::new(trimmed).file_name().and_then(|s| s.to_str()) != Some(trimmed);
    +
    +    if is_unsafe {
    +        bail!("Server sent an unsafe artifact name {name:?}; refusing to write outside the output directory");
    +    }
    +    Ok(trimmed.to_string())
    +}
    +
    +/// Whether the resolved hub URL points at the local machine. Used to gate the
    +/// `download_path` local-copy shortcut, which trusts a server-controlled local
    +/// filesystem path (GHSA-x55v-q459-68ch, Path B).
    +fn server_is_local(server_url: &str) -> bool {
    +    match Url::parse(server_url) {
    +        Ok(u) => match u.host() {
    +            Some(url::Host::Domain(d)) => d.eq_ignore_ascii_case("localhost"),
    +            Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
    +            Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
    +            None => false,
    +        },
    +        Err(_) => false,
    +    }
    +}
    +
     pub(crate) async fn auto_register_license(server_url: &str) -> Result<String> {
         let client = reqwest::Client::new();
         let resp = client
    
  • crates/perry/src/commands/publish/tests.rs+50 0 modified
    @@ -439,3 +439,53 @@ fn test_validate_passes_when_all_present() {
         );
         assert!(result.is_ok());
     }
    +
    +// --- GHSA-x55v-q459-68ch: server-controlled artifact path handling ---
    +
    +#[test]
    +fn test_sanitize_artifact_name_accepts_plain_names() {
    +    assert_eq!(sanitize_artifact_name("app.zip").unwrap(), "app.zip");
    +    assert_eq!(
    +        sanitize_artifact_name("MyGame-1.2.3.dmg").unwrap(),
    +        "MyGame-1.2.3.dmg"
    +    );
    +    // Surrounding whitespace is trimmed, not treated as part of the name.
    +    assert_eq!(sanitize_artifact_name("  app.ipa  ").unwrap(), "app.ipa");
    +}
    +
    +#[test]
    +fn test_sanitize_artifact_name_rejects_traversal() {
    +    for evil in [
    +        "../../.ssh/authorized_keys",
    +        "../x",
    +        "a/b",
    +        "a/../b",
    +        "/etc/cron.d/x",
    +        "/etc/passwd",
    +        "..",
    +        ".",
    +        "",
    +        "   ",
    +        "foo/bar.zip",
    +        "..\\windows\\system32",
    +        "C:\\Users\\victim\\evil.exe",
    +    ] {
    +        assert!(
    +            sanitize_artifact_name(evil).is_err(),
    +            "expected {evil:?} to be rejected as unsafe"
    +        );
    +    }
    +}
    +
    +#[test]
    +fn test_server_is_local() {
    +    assert!(server_is_local("http://localhost:3000"));
    +    assert!(server_is_local("https://LOCALHOST"));
    +    assert!(server_is_local("http://127.0.0.1:8080"));
    +    assert!(server_is_local("http://[::1]:9000"));
    +
    +    assert!(!server_is_local("https://hub.perryts.com"));
    +    assert!(!server_is_local("https://attacker.example.com"));
    +    assert!(!server_is_local("http://10.0.0.5:3000"));
    +    assert!(!server_is_local("not a url"));
    +}
    

Vulnerability mechanics

Root cause

"Missing input validation on server-controlled `artifact_name` and `download_path` fields in `ArtifactReady` WebSocket messages allows path traversal."

Attack vector

An attacker controls a malicious build server URL (e.g. via a poisoned `perry.toml` `server` field) and sends an `ArtifactReady` WebSocket message containing a path traversal payload in the `artifact_name` field (e.g. `../../.ssh/authorized_keys`). The client joins this unsanitized name onto the output directory, writing arbitrary downloaded content to any location writable by the process [ref_id=2]. In the self-hosted hub path, the server also controls `download_path`, enabling arbitrary local file reads by copying a victim file to an attacker-accessible location [ref_id=2].

Affected code

The vulnerability resides in `crates/perry/src/commands/publish/mod.rs` where the `ArtifactReady` WebSocket message's `artifact_name` and `download_path` fields were used verbatim without sanitization. `PathBuf::join` does not normalize `..` or reject absolute paths, allowing traversal payloads to escape the intended output directory [patch_id=5618384].

What the fix does

The patch introduces `sanitize_artifact_name()` which rejects empty names, `.`, `..`, absolute paths, and any name containing `/` or `\`, reducing the artifact name to a bare traversal-free filename before joining it onto the output directory [patch_id=5618384]. It also adds `server_is_local()` to gate the `download_path` local-copy shortcut to loopback hubs only, preventing a remote malicious hub from specifying arbitrary local filesystem paths [patch_id=5618384].

Preconditions

  • configThe victim must run `perry publish` against a build server URL controlled by the attacker (e.g. via a malicious `perry.toml` or CLI flag).
  • networkThe attacker must operate a WebSocket server that sends a crafted `ArtifactReady` message with a path traversal payload in `artifact_name`.
  • authNo authentication is required; the attacker only needs network reachability to the victim.

Reproduction

The advisory includes a PoC: an attacker runs a minimal WebSocket server (`attacker_hub.py`) that sends an `artifact_name` of `../../.ssh/authorized_keys`. The victim configures `server = "https://attacker.example.com"` in `perry.toml` and runs `perry publish --output /tmp/output`, causing the client to write attacker-controlled content to `/home/victim/.ssh/authorized_keys` [ref_id=2].

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

References

5

News mentions

0

No linked articles in our index yet.