praisonai-platform: 모든 워크스페이스 구성원은 PATCH /workspaces/{id}/members/{user_id}를 통해 자신이나 다른 사람을 소유자로 승격할 수 있습니다.
유형: 수직적 권한 상승. PATCH /workspaces/{workspace_id}/members/{user_id} 엔드포인트는 require_workspace_member(workspace_id)에 의해 제어됩니다. 기본값은 min_role="member"이며 경로에 의해 재정의되지 않습니다. 그런 다음 핸들러는 'MemberService.update_role(workspace_id, user_id, body.role)'을 호출하여 호출자가 소유자 또는 관리자 권한을 가지고 있는지 확인하지 않고, 새 역할이 호출자의 역할보다 높지 않은지 확인하지 않고, 호출자가 자동으로 자신을 승격시키지 않는지 확인하지 않고 대상 구성원의 역할을 요청 본문에 지정된 대로 설정합니다.
파일: src/praisonai-platform/praisonai_platform/api/routes/workspaces.py, 115-127행; services/member_service.py, 55-69행; api/deps.py, 54-73행.
근본 원인: require_workspace_member는 min_role 매개변수(deps.py:58)와 함께 존재하지만 FastAPI의 Depends(require_workspace_member)는 인수를 전달할 수 없으므로 모든 경로는 기본 "member"를 사용합니다. 그런 다음 경로는 URL 제공 user_id와 본문 제공 role을 권한 검사가 포함되지 않은 MemberService.update_role에 직접 전달합니다. 복합 키로 멤버를 로드하고 member.role = new_role을 할당합니다. 따라서 가능한 가장 낮은 권한을 가진 사용자("회원")는 하나의 HTTP 패치를 통해 자신의 역할을 "소유자"로 설정하여 단일 요청으로 회원에서 소유자로의 권한 상승을 완료합니다.
파일 1: src/praisonai-platform/praisonai_platform/api/routes/workspaces.py, 115-127행.
``파이썬 @router.patch("/{workspace_id}/members/{user_id}", response_model=MemberResponse) 비동기 def update_member_role( 작업공간_ID: str, user_id: 문자열, 본문: MemberUpdate, user: AuthIdentity = dependency(require_workspace_member), # <-- BUG: 기본값은 min_role="member"; 역할 게이트 없음 세션: AsyncSession = 종속(get_db), ): member_svc = MemberService(세션) member = wait member_svc.update_role(workspace_id, user_id, body.role) # <-- 모든 구성원에게 역할을 씁니다. 멤버가 없음인 경우: HTTPException(status_code=404, Detail="멤버를 찾을 수 없음") 발생 MemberResponse.model_validate(member)를 반환합니다.
**파일 2:** `src/praisonai-platform/praisonai_platform/services/member_service.py`, 55-69행.
``파이썬
비동기 def update_role(
자기,
작업공간_ID: str,
user_id: 문자열,
new_role: str,
) -> 선택[회원]:
"""회원의 역할을 업데이트합니다."""
if new_role이 VALID_ROLES에 없으면: # 호출자의 권리가 아닌 *값*만 검증합니다.
raise ValueError(f"잘못된 역할: {new_role}. {VALID_ROLES} 중 하나여야 합니다.")
멤버 = self.get(workspace_id, user_id)을 기다립니다.
멤버가 없음인 경우:
반환 없음
member.role = new_role # <-- 버그: 호출자 역할 확인 없음, 대상 대 호출자 계층 확인 없음
self._session.flush()를 기다리세요
복귀멤버
파일 3: src/praisonai-platform/praisonai_platform/api/deps.py, 54-73행.
``파이썬 비동기 def require_workspace_member( 작업공간_ID: str, 사용자: AuthIdentity = 종속(get_current_user), 세션: AsyncSession = 종속(get_db), min_role: str = "member", # <-- 경로가 재정의되지 않는 기본값 ) -> 인증 ID: member_svc = MemberService(세션) has = member_svc.has_role(workspace_id, user.id, min_role)을 기다립니다. 그렇지 않은 경우: raise HTTPException(status_code=403, Detail="이 작업공간의 멤버가 아니거나 역할이 부족합니다.") user.workspace_id = 작업공간_id 복귀 사용자
**잘못된 이유:** `require_workspace_member`는 경로별로 조정할 수 있도록 명확하게 설계되었습니다. `min_role` 매개변수가 바로 거기에 있습니다. 하지만 FastAPI의 `Depends(require_workspace_member)`는 종속성에 인수를 전달할 수 없으므로 모든 경로는 기본 `"member"`로 확인됩니다. 작성자의 의도는 이 엔드포인트가 시행해야 하는 '소유자 > 관리자 > 구성원' 계층 구조를 구현하는 'MemberService.has_role'(member_service.py:80-96)에서도 분명하게 드러납니다. 엔드포인트는 이를 전혀 사용하지 않습니다. `VALID_ROLES = {"owner", "admin", "member"}` 열거형 검사(member_service.py:62)는 *새 역할 문자열이 인식되는지* 검증할 뿐, *발신자가 이를 할당할 권한이 있는지*는 확인하지 않습니다. 결과적으로 회원은 자신의 멤버십 행에 `{"role": "owner"}`를 쓰고 한 번의 패치로 소유자가 될 수 있습니다.
## 체인 익스플로잇
1. 공격자는 계정을 등록하고 'W' 작업 공간에 '회원'으로 가입(또는 초대)합니다(가장 낮은 권한 계층 - 일반적으로 온보딩 중에 소유자가 누구나 추가하거나 초대 링크를 통해 자체 가입할 수 있음). 상태: 공격자는 JWT를 가지고 있으며 'Member(workspace_id=W, user_id=attacker, role="member")'입니다.
2. 공격자는 `Authorization: Bearer <attacker_jwt>` 및 본문 `{"role": "owner"}`와 함께 `PATCH /workspaces/W/members/<attacker_user_id>`를 보냅니다. 상태: 제어 흐름이 'update_member_role'로 들어갑니다.
3. `require_workspace_member(W, 공격자)`가 실행됩니다. 공격자가 구성원이기 때문에 기본 `min_role="member"`가 충족됩니다. 종속성은 공격자의 신원을 반환합니다. 상태: 경로 처리기가 더 이상 역할 게이트 없이 진행됩니다.
4. `MemberService.update_role(W, Attacker, "owner")`가 실행됩니다. `VALID_ROLES`는 `"소유자"`를 허용합니다. `self.get(W, Attacker)`는 공격자의 기존 멤버 행을 반환합니다. 다음 줄 `member.role = "owner"`는 공격자의 역할을 제자리에서 변경합니다. `await self._session.flush()` 커밋. 상태: 공격자는 이제 `Member(workspace_id=W, user_id=attacker, role="owner")`입니다.
5. 공격자는 'GET /auth/me'(또는 소유자 관리 엔드포인트)를 재발행하고 이제 작업 공간 소유자로 처리됩니다. 상태: 멤버 추가/제거, 설정 변경, 워크스페이스 삭제, 별도의 권고로 제출된 에이전트/문제/프로젝트/댓글 IDOR을 통해 모든 것을 추출하는 기능을 포함하여 워크스페이스에 대한 완전한 관리 제어권을 갖습니다.
6. 최종 상태: 공격자는 가장 낮은 작업 공간 권한부터 시작하여 하나의 HTTP 요청 내에서 작업 공간의 소유자를 보유합니다. 또한 동일한 프리미티브를 사용하면 공격자가 `{"role": "member"}`와 함께 `PATCH /workspaces/W/members/<owner_user_id>`를 전송하여 합법적인 소유자를 강등시킬 수 있습니다. 즉, 총 두 번의 요청으로 소유자를 잠글 수 있습니다.
## 보안 영향
**심각도:** 초임계. CVSS 9.1: 네트워크 공격, 낮은 복잡성, 낮은 권한(플랫폼에서 가장 낮은 계층), 사용자 상호 작용 없음, 범위 변경(공격자가 교차하는 권한 경계는 작업 공간 소유자, 다른 보안 주체임), 높은 기밀성 및 무결성(전체 작업 공간 제어), 가용성 주장 없음(공격자는 동반 `delete_workspace` 권고를 통해 작업 공간을 삭제할 수도 있지만 이는 별도의 결과입니다).
**공격자 기능:** 하나의 작업 영역 구성원 토큰과 하나의 PATCH 요청을 사용하면 공격자가 작업 영역 소유자가 됩니다. 거기에서 사용자를 소유자로 추가/제거하고, 모든 작업 공간 설정(`설정` JSON blob 포함)을 변경하고, 합법적인 소유자를 "멤버"로 강등하거나, 동반 `delete_workspace` 권고에 연결하여 작업 공간을 완전히 지울 수 있습니다. 등록을 통해 일부 기본 작업 영역에서 구성원 수준 계정이 생성되는 다중 테넌트 SaaS 배포에서는 이는 사실상 사전 인증입니다.
**전제 조건:** `praisonai-platform`은 다중 테넌트로 배포됩니다(둘 이상의 작업 공간이 존재하거나 배포에서 가입 시 구성원 액세스 권한을 부여함). 공격자는 대상 작업 공간에 멤버십 토큰을 가지고 있습니다.
**차등:** 소스 검사를 거쳐 엔드 투 엔드로 검증되었습니다. `require_workspace_member`의 `min_role` 매개변수(존재하고 기본값은 "member"이며 절대 재정의되지 않음)와 `MemberService.has_role`의 명확하게 계층화된 `owner > admin > member` 계층(존재하지만 기본값 외에는 호출되지 않음) 사이의 비대칭성은 매우 중요합니다. 아래 제안된 수정 사항을 사용하면 경로가 `min_role="owner"`로 확인되고 공격자의 구성원 수준 토큰이 종속성에서 게이트에 실패하며 권한 에스컬레이션이 서비스 계층에 도달하지 않습니다.
## 제안된 수정 사항
수정 사항은 두 부분으로 구성됩니다. 먼저, 경로는 `min_role="owner"`(또는 최소한 `"admin"`)를 사용하여 `require_workspace_member`를 해결해야 합니다. 둘째, 'MemberService.update_role'은 관리자가 실수로 다른 소유자를 생성할 수 없도록 대상의 역할을 호출자 자신의 역할보다 높게 설정하는 것을 거부해야 합니다.
``차이점
--- a/src/praisonai-플랫폼/praisonai_platform/api/routes/workspaces.py
+++ b/src/praisonai-platform/praisonai_platform/api/routes/workspaces.py
@@ -115,11 +115,16 @@
+def _require_owner(workspace_id: 문자열, 사용자, 세션):
+ require_workspace_member(workspace_id, user, session, min_role="owner")를 반환합니다.
+
@router.patch("/{workspace_id}/members/{user_id}", response_model=MemberResponse)
비동기 def update_member_role(
작업공간_ID: str,
user_id: 문자열,
본문: MemberUpdate,
- 사용자: AuthIdentity = 종속(require_workspace_member),
+ 사용자: AuthIdentity = 종속(_require_owner),
세션: AsyncSession = 종속(get_db),
):
member_svc = MemberService(세션)
+ 기다리고 있지 않은 경우 member_svc.has_role(workspace_id, user.id, "owner"):
+ HTTPException 발생(status_code=403, Detail="소유자만 구성원 역할을 변경할 수 있습니다.")
멤버 = 기다리고 member_svc.update_role(workspace_id, user_id, body.role)
서비스 계층의 심층 방어:
``차이점 --- a/src/praisonai-플랫폼/praisonai_platform/services/member_service.py +++ b/src/praisonai-platform/praisonai_platform/services/member_service.py @@ -55,7 +55,7 @@
컴패니언 엔드포인트 `add_member`, `remove_member`, `delete_workspace` 및 `update_workspace`는 동일한 `Depends(require_workspace_member)` 기본 최소 역할 패턴을 나타내며 자체 권고로 보관되므로 각각 별도의 CVE를 얻습니다.
왜 이 VPI인가 (설명가능 · 실험적)
VPI 산정 기준
| 영향도 | 96.00 |
| 악용 신호(추가 악용신호 없음) | ×1.00 |
| VPI | 96.00 |
VPI 공식 vpi-v1 기준