pyLoad: `Api.set_user_permission` は、対象ユーザーのセッションを無効化しません。

### 概要
pyLoadのドキュメントに記載されている`/api/<func>` RPCインターフェース(例:`POST /api/set_user_permission`、またはその旧バージョンのエイリアス`/api/setUserPermission`)を通じて、管理者権限を持つユーザーが、管理者権限を持たないユーザーの権限を剥奪または引き下げると、対象ユーザーのデータベースのレコードは更新されますが、そのユーザーの既存のFlaskのCookieセッションは無効化されません。権限が剥奪されたユーザーは、権限剥奪前の`role`/`permission`の状態を、WebUIの各ページ表示時および、そのCookieを使用して行われる`/api/<func>`の各呼び出しにおいて、デフォルトの`session_lifetime`(約31日)まで、またはユーザーが自主的にログアウトするまで維持します。権限剥奪されたユーザーが行う必要のある操作は、権限剥奪時に既にログインしていること以外にはありません。

### 詳細
脆弱なメソッドは、`src/pyload/core/api/__init__.py`の1652行から1656行に記述されています。

```
@legacy("setUserPermission")
@post
def set_user_permission(self, user: str, permission: int, role: int) -> None:
self.pyload.db.set_permission(user, permission)
self.pyload.db.set_role(user, role)
```

このメソッドには`@permission(...)`デコレーターは付いておらず、また、`clear_all_user_sessions`や、その他のセッション無効化ルーチンは呼び出されていません。

権限/ロールの変更と`clear_all_user_sessions`を組み合わせている箇所は、コード全体の中でWebUIの`update_users()`ルート、`src/pyload/webui/app/blueprints/json_blueprint.py`の442行から444行のみです。

```
api.set_user_permission(name, data["permission"], data["role"])
if was_changed:
clear_all_user_sessions(name)
```

`set_user_permission|setUserPermission`をコード全体で検索すると、正確に3つの箇所が見つかります。それは、`@legacy`エイリアスの登録箇所(`core/api/__init__.py:1652`)、メソッドの定義自体(`:1654`)、そして、この呼び出し箇所(`json_blueprint.py:442`)です。他の呼び出し元は存在しません。

pyLoadの独自のパブリックRPCディスパッチャは、`Api.set_user_permission`を直接呼び出し、`json_blueprint.py`を完全にバイパスします。`src/pyload/webui/app/blueprints/api_blueprint.py:21-26`では、`/api/<func>`および`/api/<func>/<args>`に対して、`GET`/`POST`で`rpc(func, args="")`が登録されています。68行目では、`response = jsonify(getattr(api, func)(**json_request_body))`という形で、`Api`オブジェクト上の任意のメソッドを名前で呼び出しています。これには`set_user_permission`も含まれます(また、`:72`/`:77`のフォーム/multipartボディからもアクセス可能です)。そのディスパッチの前の唯一の認証ゲートは、`api_blueprint.py:50`にあり、以下のようになっています。

```
if not api.is_authorized(func, {"role": user_info["role"], "permission": user_info["permission"]}):
```

`is_authorized`(`core/api/__init__.py:1419-1432`)は、**呼び出し元**のロールが`Role.ADMIN`の場合、常に`True`を返します。それ以外の場合は、`func_name in perm_map`という条件を満たす必要があります。`set_user_permission`には`@permission`デコレータがないため、`perm_map`には登録されていません(`core/api/__init__.py:37`のモジュールコメントには「リストされていない関数は管理者のみが利用可能」と記載されています)。このゲートは、呼び出し元の認証のみに関係しており、**対象**ユーザーの既存のセッションには一切言及しておらず、触れていません。

### 古いセッションが生き残る
降格されたユーザーのセッションは、一度ログイン時に作成され、その後は更新されることがありません。

- `helpers.py:170, 179, 183-185` — `parse_permissions(session)` 関数は、`session.get("role")` および `session.get("perms")` から、呼び出し元の有効な管理者権限/アクセス権ビットを派生させます。
- これらの値は、`set_session()` 関数 (`helpers.py:223-234`) によって一度だけ書き込まれます。この関数は、`app_blueprint.py:88` (ログイン) および `:100` (自動ログイン) からのみ呼び出されます。`src/pyload/` 内の `before_request` を対象とした全体検索を行った結果、何も見つかりませんでした。つまり、データベースからセッション情報を更新する仕組みはどこにも存在しません。
- `is_authenticated(session)` 関数 (`helpers.py:261-266`) は、以下のようになります。
```
user = session.get("name")
authenticated = session.get("authenticated", False)
return authenticated and api.user_exists(user)
```
この関数は、ユーザー名が依然として存在するかどうかのみを再検証します。セッションにキャッシュされた `role`/`perms` が、現在のデータベースの値と一致しているかどうかは検証しません。
- `apikey_auth` 関数 (`helpers.py:393-404`) の中で、`X-API-Key` ヘッダーが提供されない場合、`flask.g.user_info` は、`{"id": s["id"], "name": s["name"], "role": s["role"], "permission": s["perms"]}` という形式で構築されます。これは、ログイン時にキャッシュされたセッションのフィールドから直接取得されます。この `user_info` は、`api_blueprint.py:50` で、クッキーセッションを使用しているすべての `/api/<func>` への呼び出しにおいて、`is_authorized()` 関数に渡されます。

脆弱性の期間は、無限ではなく、限定的です。`default.cfg:47` で、`session_lifetime = 44640` (分、約31日) が設定されており、これは `webui/app/__init__.py:130-131` の `PERMANENT_SESSION_LIFETIME` に組み込まれています。また、`:128` の `SESSION_REFRESH_EACH_REQUEST = False` は、セッションの有効期限がログインから約31日であり、継続的なアクティビティによって延長されないことを意味します。

## 攻撃シーケンス
前提条件: 既に認証済みの管理者権限を持たないユーザーと、pyLoad の公式 API を正当に使用している管理者。

1. 攻撃対象のユーザーは通常通りログインし、自身の現在の(または上位の)権限を反映したFlaskセッションクッキー(`A`)を取得します。
2. 管理者は、攻撃対象ユーザーのユーザー名と、新しい、より低い権限/ロールの値を指定して、`POST /api/set_user_permission`(またはレガシーの`POST /api/setUserPermission`)を送信することで、攻撃対象ユーザーの権限を取り消したり、引き下げたりします。この操作は、`curl`、スクリプト、またはその他のRPCクライアントを使用して行われ、WebUIの「ユーザー管理」HTMLページは使用しません。`Api.set_user_permission`は、`pyload.db.set_permission` / `pyload.db.set_role`を更新し、処理を終了します。この際、どのセッションも変更されません。
3. クッキー`A`(一度も無効化されない)を使用して、攻撃対象ユーザーは、WebUIの管理者権限が必要なページにアクセスしたり、ロール/権限によって制限された`/api/<func>`エンドポイントを呼び出したりできます。`apikey_auth`のクッキー処理では、古いセッションフィールド(ステップ1の時点での`role`/`perms`)から`user_info`を再構築し、`is_authorized()` / `parse_permissions()`がこれらの古い値に基づいて評価が行われ、権限の取り消しが発生したにもかかわらず、操作は成功します。
4. この状態は、最大で約31日(デフォルトの`session_lifetime`)間、または攻撃対象ユーザーがログアウトするまで続きます。

この手順は、ソースコードからの推測ではなく、実際にローカルで実行されたpyLoadインスタンスで確認されています。
正確なコマンド、実際のHTTPステータスコードとレスポンスボディ、および管理者がこの手順で`X-API-Key`ヘッダー認証を使用していること(詳細はセクション10を参照)に関する前提条件については、セクション10を参照してください。

## 提案される修正
`Api.set_user_permission`自体に、`clear_all_user_sessions(name)`の呼び出しを組み込みます(理想的には、`remove_user`で既に採用されているAPIキーキャッシュのクリアパターンも適用します。場所は`core/api/__init__.py:1626-1636`)。これにより、WebUIと`/api/<func>` RPCの両方を含むすべてのエントリーポイントが、セッションの無効化を自動的に継承できるようになり、各呼び出し元が個別にそれを呼び出すことを忘れないようにする必要がなくなります。

### 実証実験 (PoC)
これは、pyLoad自身のコードに対して実際に実行されたものであり、単にコードを読んで予測したものではありません。
セクション1~9 (`ghsa/CLAIMS-pyload.md`) を生成した検証プロセスでは、すべての主張についてソースコードの追跡を行いましたが、実際にPoCを実行したわけではありません。このセクションは、その不足を補うものです。

### 方法と前提条件

- ターゲット: `pyload/pyload` の、ピン留めされたコミット `31e341fd41d2dac9fffa3683a756edc54bcdeb77` (develop ブランチ)。Python 3.12.3 の新規仮想環境に、編集可能な状態でインストール (`pip install -e ".[test]"`)。ソースファイルは変更されていません。
- 動作環境: Flask のインプロセス・テストクライアント (`app.test_client()`) を、実際の `pyload.core.Core` / `pyload.webui.app` の Flask アプリケーションオブジェクトに対して実行。TCP サーバーにバインドされたものではありません。これは、プロジェクト自身の `tests/integration/conftest.py` で使用されているものと同じ設定であり、同一のルーティング/ビュー/デコレーターコード (`api_blueprint.rpc()`, `apikey_auth`, `is_authorized()`, `parse_permissions()`, Flask-WTF の CSRF 保護) が、実際の `127.0.0.1:8000` にバインドされたサーバーで実行されるのと同様の動作をします。実際のネットワークソケットは開かれておらず、サードパーティまたはパブリックホストにアクセスしていません。これは、ターゲットのコードをローカルでクローンしたコピーのみを使用しています。
- ユーザー: `pyload`/`pyload` (pyLoad が自動的に作成するデフォルトの管理者アカウント) と、新たに作成された管理者権限を持たない `victim` ユーザー。このユーザーには `Perms.SETTINGS` の権限ビットが設定されています。
- テスト対象の処理: `GET /api/get_userdir` (`core/api/__init__.py:1434-1437`, `@permission(Perms.SETTINGS)` デコレーターと `@get` デコレーターが適用)。これは、通常の管理者権限以外の権限ビットに依存しており、また `GET` リクエストであるため、CSRF トークンは不要です (Flask-WTF の `WTF_CSRF_METHODS` のデフォルト設定では `GET` が除外されています)。これにより、テストは CSRF の仕組みではなく、セッションの有効期限の問題に焦点を当てることができます。
- 管理者の `POST /api/set_user_permission` の呼び出しでは、Cookie ではなく `X-API-Key` ヘッダーを使用した認証を行っています。これは、バグを単純化するためではなく、前提条件です。`apikey_auth` (`helpers.py:335-413`) は、`/api/<func>` のすべてのルートに対して、デコレーション時に CSRF の保護を無効にします。その後、Cookie セッションを使用する場合 (API キーを使用しない場合) にのみ、手動で `csrf.protect()` (`helpers.py:397`) を呼び出して、リクエストを処理する前に CSRF 保護を有効にします。したがって、Cookie で認証された管理者からの呼び出しには有効な CSRF トークンが必要ですが、`X-API-Key` を使用した呼び出しには不要です (`helpers.py:361-384` でそのチェックが行われる前に処理が終了します)。

(痛み)。これは脆弱性そのものとは関係ありません。`Api.set_user_permission`の処理(`core/api/__init__.py:1652-1656行目)は、呼び出し元がどのように認証を行ったかによって分岐せず、その前に実行される`is_authorized()`のチェック(`api_blueprint.py:50行目)は、管理者自身のロールのみを確認し、対象ユーザーのセッションは確認しません。クッキー認証による管理者からのリクエスト(被害者のログイン時に行うのと同じ方法でCSRFトークンを取得)も、同じコードパスを経由し、個別に再実行されていません。
- 完全なスクリプト、詳細な実行手順が記載されたREADME、および正確にキャプチャされた出力は、以下の証拠バンドルにあります:`ghsa/poc-bundles/pyload-poc/`(zip形式:`ghsa/poc-bundles/pyload-poc.zip`)。

### 出力(実行日:2026年9月10日)

```
ステップ1:被害者が実際のWebUIの/loginルートを通じてログインし、セッションクッキーを保持します。
==============================================================================
POST /login -> ステータス 302
Set-Cookie: ['pyload_session_8000=Kgq1peWlETCpxj4MSOSY-yxy-v3EYgIjiLxYT7kLgO0; Expires=Sun, 11 Oct 2026 12:30:51 GMT; HttpOnly; Path=/; SameSite=Lax']
被害者がログイン直後にキャッシュしたセッションの内容:role=1, perms=128, authenticated=True

==============================================================================
ステップ2:ベースライン -- 被害者がクッキーAを使用して、SETTINGS権限が必要なGETエンドポイント(/api/get_userdir, @permission(Perms.SETTINGS))を呼び出し、成功します。
==============================================================================
GET /api/get_userdir (被害者のクッキー) -> ステータス 200
body: "/tmp/pyload_poc_userdir_y9fl5y10"

==============================================================================
ステップ3:管理者が、pyLoad自身の公開APIエンドポイント/api/<func> RPCディスパッチャ(X-API-Keyヘッダーを使用、WebUIの「ユーザー管理」ページではなく、json_blueprint.pyのupdate_users()関数ではありません)を通じて、被害者の権限を剥奪します。
==============================================================================
POST /api/set_user_permission (管理者のAPIキー) -> ステータス 200
body: null
```

==============================================================================
ステップ4:データベースから再度情報を読み取り、被害者の権限が本当にデータベース上で引き下げられていることを確認する。
==============================================================================
set_user_permissionを実行した後の被害者のデータベースの行:{'id': 2, 'name': 'victim', 'permission': 0, 'role': 1, 'template': 'default', 'email': ''}

==============================================================================
ステップ5(バグ):ステップ1で使用した、変更されていない被害者のオリジナルクッキーAを使用して、全く同じSETTINGSによるアクセス制限をかけた呼び出しを繰り返す。
==============================================================================
GET /api/get_userdir(同じ被害者のクッキー、権限引き下げ後)-> ステータスコード 200
body: "/tmp/pyload_poc_userdir_y9fl5y10"
結果:権限の取り消し後も、呼び出しは依然として成功する。古いクッキーセッションは、権限取り消し前のSETTINGSビットを保持している。
管理者が権限を取り消した後の、被害者のキャッシュされたセッションの内容(ログイン以降変更されていない):role=1, perms=128

==============================================================================
ステップ6(検証):同じく権限が引き下げられた被害者に対して、クッキーではなくX-API-Keyヘッダーを使用して認証を行うと、新しい、引き下げられた権限が直ちに適用されることを確認する。
==============================================================================
GET /api/get_userdir(被害者のX-API-Key、権限引き下げ後)-> ステータスコード 401
body: {"error": "Access denied"}
結果:API-keyによるアクセスは、権限の引き下げを直ちに反映し(アクセス拒否)、このバグがクッキーセッションに特有であることを確認する。

(pyLoad自身のロガーからのログメッセージは、この出力と組み合わされて生のキャプチャに含まれていますが、ここでは読みやすさのために省略しています。完全な編集されていないキャプチャは、証拠バンドル内の`output.txt`に含まれています。)

これは、セクション4と6で予測された動作と完全に一致します。降格されたユーザーの元のCookieは、権限の取り消し前のデータとともに`200`を返します(ステップ5)。一方、同じ降格は、`X-API-Key`認証によって正しく、かつ即座に検出されます(ステップ6、`401`)。実際の動作において、ソースコードレベルでの分析と矛盾する点はありませんでした。追加のセキュリティ機構による回避の阻止もありませんでしたし、より簡単な回避方法も見つかりませんでした。

このテストは、完全に新規に構築された仮想環境(`pip install -e ".[test]"`を新しい仮想環境にインストールし、以前のインストールは一切使用しない)で行われ、結果は全く同じでした。期待通り、APIキーの値、署名されたセッションCookieの値、および一時ディレクトリのパスのみが、テスト実行間で異なります。

### ゼロから再現する手順

```sh
# pyloadのコードをバージョン31e341fd41d2dac9fffa3683a756edc54bcdeb77でチェックアウトした状態から開始
cd /path/to/pyload-clone-at-31e341f

python3 -m venv /tmp/pyload-poc-venv
/tmp/pyload-poc-venv/bin/pip install -U pip
/tmp/pyload-poc-venv/bin/pip install -e ".[test]"

# オプション:プロジェクト自身のテストスイートに対する健全性チェック
/tmp/pyload-poc-venv/bin/python -m pytest tests/integration/test_api.py -q # -> 12 passed

/tmp/pyload-poc-venv/bin/python3 /path/to/ghsa/poc-bundles/pyload-poc/poc_stale_session.py
```

### 影響
`apikey_auth`のヘッダーが存在するブランチ(`helpers.py:370`)は、`get_user_by_id`を介して、データベースから取得したユーザーのロール/権限情報を、すべてのリクエストで読み込みます。このバグは、Cookie/セッションベースの認証パス(`helpers.py:393-404`)に特有です。

[pyload-poc.zip](https://github.com/user-attachments/files/32062053/pyload-poc.zip)

影響を受けるパッケージ:
- pip (Python) pyload-ng: 影響を受けるバージョンは、0.5.0b3.dev98以上、0.5.0b3.dev101以下。修正は未実施。