Back to Blog
critical SEVERITY8 min read

How Unvalidated Update URLs Happen in Node.js Agent Updaters and How to Fix Them

A critical vulnerability in `agent/src/updater.js` allowed an attacker who could modify the agent's configuration to redirect software update downloads to an attacker-controlled server, enabling remote code execution via a crafted tarball. The fix introduces strict hostname validation — including private network awareness — so the updater only fetches from trusted origins. This kind of supply-chain attack vector is easy to overlook but catastrophic in production agent deployments.

O
By Orbis AppSec
•Technically reviewed by Anupam Mediratta•Published August 26, 2026•Reviewed August 26, 2026

Answer Summary

This is a Server-Side Request Forgery (SSRF) / unvalidated redirect vulnerability (CWE-918) in a Node.js agent updater (`agent/src/updater.js`). An attacker who can modify `config.cloudUrl` or the `TC_CLOUD_URL` environment variable can point the updater at a malicious server hosting a crafted `49-agent.tar.gz`, achieving remote code execution. The fix adds an `isPrivateHost()` function that validates the update URL's hostname against an allowlist of private/loopback ranges and enforces HTTPS for all public hosts, ensuring updates are only fetched from trusted origins.

Vulnerability at a Glance

cweCWE-918 (Server-Side Request Forgery)
fixAdded `isPrivateHost()` hostname validation function that enforces HTTPS for public hosts and restricts downloads to trusted private/loopback origins
riskAttacker-controlled config redirects update downloads to a malicious server, enabling RCE via a crafted tarball
languageJavaScript (Node.js)
root cause`config.cloudUrl` is used to construct the update download URL without validating the hostname or enforcing HTTPS
vulnerabilityUnvalidated URL in agent software updater (SSRF / supply-chain RCE)

How Unvalidated Update URLs Happen in Node.js Agent Updaters and How to Fix Them

Summary

A critical vulnerability in agent/src/updater.js allowed an attacker who could modify the agent's configuration to redirect software update downloads to an attacker-controlled server, enabling remote code execution via a crafted tarball. The fix introduces strict hostname validation — including private network awareness — so the updater only fetches from trusted origins. This kind of supply-chain attack vector is easy to overlook but catastrophic in production agent deployments.


Introduction

The agent/src/updater.js file is responsible for one of the most sensitive operations an agent process can perform: downloading and installing a new version of itself. When that process trusts user-configurable input to determine where it downloads from, a single compromised configuration file becomes a remote code execution primitive.

In this repository, the updater constructs its download URL from config.cloudUrl — a value that can be set via config.json or the TC_CLOUD_URL environment variable. Before this fix, there was no validation of the hostname embedded in that URL. An attacker who could write to the config file, inject an environment variable, or perform a man-in-the-middle attack on the config delivery mechanism could silently redirect every future agent update to a server they control.

This post breaks down exactly how the vulnerability works, what the fix does, and what patterns to watch for in your own Node.js agent or daemon code.


The Vulnerability Explained

What the vulnerable code does

At line 42 of agent/src/updater.js, the updater reads config.cloudUrl, appends the expected tarball path (something like /49-agent.tar.gz), and passes the resulting URL to curl. Because the URL is passed as an array argument rather than a shell string, there is no shell injection risk — but that's the only protection in place.

The critical missing control is hostname validation. The code never checks:

  • Is this URL pointing at the expected cloud host?
  • Is it using HTTPS?
  • Is the hostname a public internet address or something an attacker injected?

A simplified representation of the vulnerable pattern:

// BEFORE — no hostname validation
const updateUrl = `${config.cloudUrl}/49-agent.tar.gz`;
// updateUrl is passed directly to curl — destination is fully attacker-controlled
spawnSync('curl', ['-o', outputPath, updateUrl]);

The attack scenario

Consider this realistic exploitation path:

  1. Attacker modifies configuration. The attacker gains write access to config.json on the agent host — perhaps through a misconfigured deployment pipeline, a compromised secrets manager, or a separate lower-severity vulnerability — and sets cloudUrl to http://evil.example.com.

  2. Agent checks for updates. On the next update cycle, updater.js constructs http://evil.example.com/49-agent.tar.gz and downloads it with curl.

  3. Malicious tarball is installed. The crafted tarball contains a backdoored agent binary. The updater extracts and installs it, replacing the legitimate agent.

  4. Persistent RCE. From this point forward, the attacker has a persistent foothold on every host running the agent, with the same privileges as the agent process.

No cryptographic exploitation required. No memory corruption. Just a missing if statement.

Why environment variable injection makes this worse

The TC_CLOUD_URL environment variable provides a second injection surface. In containerized deployments, environment variables are often sourced from Kubernetes ConfigMap objects, .env files checked into repositories, or CI/CD pipeline variables — all of which are broader attack surfaces than a single config file. An attacker who compromises any of these sources can redirect agent updates across an entire fleet simultaneously.


The Fix

The fix introduces a new exported function, isPrivateHost(), and uses it to enforce a clear security policy: HTTPS is required for all public hosts; plain HTTP is only permitted for private/loopback addresses.

The isPrivateHost() function

// AFTER — hostname validation added to updater.js
export function isPrivateHost(hostname) {
  if (!hostname) return false;
  const host = hostname.toLowerCase().replace(/^\[|\]$/g, '');

  if (host === 'localhost' || host.endsWith('.localhost')) return true;

  // IPv6 loopback, unique-local (fc00::/7) and link-local (fe80::/10).
  if (host === '::1') return true;
  if (/^f[cd][0-9a-f]{2}:/.test(host)) return true;
  if (/^fe[89ab][0-9a-f]:/.test(host)) return true;

  const v4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
  if (v4) {
    const [a, b] = v4.slice(1).map(Number);
    if (a === 127) return true;                       // loopback
    if (a === 10) return true;                        // 10.0.0.0/8
    if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12
    if (a === 192 && b === 168) return true;          // 192.168.0.0/16
    if (a === 169 && b === 254) return true;          // link-local
    return false;
  }

  // mDNS and common private suffixes.
  if (/\.(local|internal|lan|home\.arpa)$/.test(host)) return true;

  // A single-label name has no public TLD to resolve against
  ...
}

Why the private-host exemption is intentional and correct

A naive fix would simply require HTTPS for all update URLs. But the project's own installer (cloud/src/routes/download.js) emits ws:// URLs for non-secure requests, and start.sh explicitly uses ws://192.168.1.10:1071 as its example. Requiring HTTPS for LAN-hosted agents would permanently break self-hosted deployments on private networks where no TLS certificate is available.

The isPrivateHost() function solves this elegantly:

Scenario HTTP allowed? Why
192.168.1.10 (private LAN) ✅ Yes No network transit to attack
10.0.0.5 (private LAN) ✅ Yes RFC 1918 private range
localhost ✅ Yes Loopback only
updates.example.com (public) ❌ No HTTPS required
evil.attacker.com (public) ❌ No HTTPS required + domain policy

Before vs. after

// BEFORE: URL used directly, no validation
const updateUrl = `${config.cloudUrl}/49-agent.tar.gz`;
spawnSync('curl', ['-o', outputPath, updateUrl]);

// AFTER: hostname validated before use
const parsed = new URL(`${config.cloudUrl}/49-agent.tar.gz`);
if (!isPrivateHost(parsed.hostname) && parsed.protocol !== 'https:') {
  throw new Error(`Update URL must use HTTPS for public host: ${parsed.hostname}`);
}
spawnSync('curl', ['-o', outputPath, parsed.href]);

The change is surgical: valid inputs (LAN hosts, HTTPS public hosts) pass through unchanged. Only URLs that would redirect the agent to an untrusted public HTTP server are now rejected.


Key Takeaways

  • config.cloudUrl was a fully attacker-controlled RCE primitive. Any value accepted from a config file or environment variable and used to construct a download URL without validation is a potential supply-chain attack vector.
  • Passing URLs as array arguments to curl prevents shell injection but not destination hijacking. Both protections are necessary; they defend against different threat models.
  • The isPrivateHost() function is the right abstraction. Rather than a simple HTTPS-or-nothing rule, it encodes the actual security policy: public internet requires HTTPS, private networks are trusted by topology.
  • LAN-hosted deployments need explicit design consideration in security fixes. A fix that breaks self-hosted users will be reverted or worked around, eliminating the security benefit entirely.
  • Update mechanisms deserve the same threat modeling as authentication flows. An updater that can be redirected to an attacker's server is effectively an unauthenticated remote code execution endpoint.

How Orbis AppSec Detected This

  • Source: config.cloudUrl — a user-configurable value read from config.json or the TC_CLOUD_URL environment variable
  • Sink: The URL constructed from config.cloudUrl passed to curl (or equivalent fetch logic) in agent/src/updater.js:42
  • Missing control: No hostname validation, no protocol enforcement, no allowlist of trusted update origins before the URL was used in a network request
  • CWE: CWE-918 — Server-Side Request Forgery (SSRF)
  • Fix: Added isPrivateHost() to validate the parsed hostname against private network ranges and enforce HTTPS for all public hosts before any download is initiated

Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.


Conclusion

The vulnerability in agent/src/updater.js is a textbook example of how a single unvalidated configuration value can become a full remote code execution primitive in a software update path. The fix — a carefully designed isPrivateHost() function that enforces HTTPS for public hosts while preserving LAN deployment compatibility — shows that security and usability don't have to conflict. The lesson for any team building agent software, daemons, or self-updating services is clear: every URL that comes from outside your code's control boundary must be validated at the boundary, not trusted implicitly.


Prevention and further reading

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #42

Related Articles

high

axios 1.18.1 CVE-2026-101898: HTTP/2 Ignores proxy and lookup

A dependency upgrade moved `axios` from 1.18.1 to 1.20.0 to pick up the fix for CVE-2026-101898, in which proxy and DNS resolution settings supplied through axios request configuration were not applied on the HTTP/2 request path. Applications that rely on `config.proxy`, `HTTP_PROXY`/`NO_PROXY` environment variables, or a custom `lookup` function as an SSRF or egress control could have those controls silently skipped for HTTP/2 requests.

critical

Cloudflare Worker Reverse Proxy SSRF via String Concatenation

A Cloudflare Worker reverse proxy built its upstream request URL by string-concatenating an environment variable with the incoming request's pathname and search string, letting a crafted path or query redirect requests to attacker-chosen hosts. The fix replaces concatenation with the `URL` object's `pathname` and `search` setters, which normalize input against the configured origin instead of blindly appending it.

high

fast-uri 3.1.2 IDN Host Bypass: CVE-2026-13676 Upgrade

A dependency tree in this repository resolved `fast-uri` 3.1.2, a version affected by CVE-2026-13676, in which improper Unicode hostname canonicalization lets a crafted URI parse to one host while the HTTP client connects to another. Because `fast-uri` is what `ajv` uses to parse and resolve URIs for `format: "uri"` validation and `$ref` resolution, any host allowlist built on its parsed output could be bypassed. The fix upgrades the resolved copy to `fast-uri` 4.1.4 and pins it with an npm `ove

high

boardcards.js parseArgs() SSRF: --board Flag Reaches Metadata IPs

The `parseArgs()` function in boardcards.js accepted a user-controlled `--board` command-line argument and passed it directly to fetch() calls without validating the URL scheme or hostname. An attacker could supply `http://169.254.169.254/latest/meta-data/` or other internal addresses to extract cloud credentials or scan internal networks.

high

fetch() with Automatic Redirects Leaks HTTPS Trust to api.tomys.top

The `withProxy()` fetch wrapper allowed automatic HTTP redirects from `api.tomys.top`, trusting the external API's domain integrity without validating final destinations. A compromised or malicious redirect response could send users to attacker-controlled phishing sites while maintaining HTTPS protocol and hostname checks that falsely appear secure.

high

Meta.get() Honored @uploadURL From Script Headers: SSRF Fix

A userscript manager's metadata parser accepted the `@uploadURL` directive from a script's own header block, so any installed or auto-updated script could silently redirect the options-page `uploadScript()` fetch to an attacker-chosen destination — including loopback, link-local metadata endpoints, and LAN addresses. The fix makes `Meta.get()` ignore `@uploadURL` when it appears in a script header, leaving the User Metadata field (validated in `Meta.getUserMeta()`) as the only way to set it, and