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].
- fix(publish): sanitize server-controlled artifact path (GHSA-x55v-q45… · PerryTS/perry@95e1043
- perry publish Arbitrary File Write via Path Traversal in Server-Controlled artifact_name
- fix(publish): sanitize server-controlled artifact path (GHSA-x55v-q459-68ch) by proggeramlug · Pull Request #4989 · PerryTS/perry
- Perry < 0.5.1159 Path Traversal via ArtifactReady WebSocket
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
2Patches
195e1043df808fix(publish): sanitize server-controlled artifact path (GHSA-x55v-q459-68ch) (#4989)
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- github.com/PerryTS/perry/commit/95e1043df8081f67038bffce847dd9ddb3dae046nvd
- github.com/PerryTS/perry/pull/4989nvd
- github.com/PerryTS/perry/releases/tag/v0.5.1159nvd
- github.com/PerryTS/perry/security/advisories/GHSA-x55v-q459-68chnvd
- www.vulncheck.com/advisories/perry-path-traversal-via-artifactready-websocketnvd
News mentions
0No linked articles in our index yet.