Skip to content

Commit 3eb59c6

Browse files
committed
fix: address PR #222 review comments (security, race condition, defensive coding)
- Block unauthorized vertex provider requests in default routing path (privilege escalation) - Add IDOR protection to get_result endpoint with ownership check - Remove os.environ mutation in vertex branch to prevent race conditions - Pass resolved_key to decide_provider to fix whitespace BYOK mismatch - Add defensive exception handling in _get_user_doc - Extract _to_admin_response helper, add ObjectId validation and self-demotion guard - Fix llm_policy docstring indentation - Use pytest.raises for idiomatic exception tests - Fix admin page: separate accessDenied state, cleanup setTimeout on unmount - Document allowlist env vars in .env.example
1 parent 9f0b4c2 commit 3eb59c6

9 files changed

Lines changed: 79 additions & 45 deletions

File tree

backend/.env.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ VERTEX_API_KEY=your_vertex_express_api_key_here
2323
GOOGLE_CLOUD_PROJECT=your_gcp_project_id
2424
GOOGLE_CLOUD_LOCATION=asia-northeast3
2525

26+
# Vertex AI role-based routing allowlists (comma-separated)
27+
# VERTEX_PREMIUM_USER_IDS=user_id_1,user_id_2
28+
# VERTEX_ADMIN_USER_IDS=admin_id_1,admin_id_2
29+
# VERTEX_PREMIUM_EMAILS=premium@example.com
30+
# VERTEX_ADMIN_EMAILS=admin@example.com
31+
2632
# GitHub Personal Access Token
2733
# Required permissions: repo (read access)
2834
# Generate at: https://github.com/settings/tokens

backend/app/api/routes/admin.py

Lines changed: 30 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
import logging
99
from typing import Any
1010

11+
from bson import ObjectId
12+
from bson.errors import InvalidId
1113
from fastapi import APIRouter, Depends, HTTPException
1214
from pydantic import BaseModel, Field
1315

@@ -50,27 +52,27 @@ class AdminUserResponse(BaseModel):
5052
created_at: str | None = None
5153

5254

55+
def _to_admin_response(u: dict) -> AdminUserResponse:
56+
return AdminUserResponse(
57+
id=str(u["_id"]),
58+
username=u.get("username", ""),
59+
email=u.get("email"),
60+
github_id=str(u.get("github_id", "")),
61+
avatar_url=u.get("avatar_url"),
62+
role=u.get("role", "user"),
63+
plan=u.get("plan", "free"),
64+
created_at=str(u.get("created_at", "")),
65+
)
66+
67+
5368
@router.get("/users", response_model=list[AdminUserResponse])
5469
async def list_users(
5570
_admin: User = Depends(require_admin),
5671
) -> list[AdminUserResponse]:
5772
"""List all users with their roles and plans."""
5873
user_repo = UserRepository()
5974
users = await user_repo.list(limit=500)
60-
61-
return [
62-
AdminUserResponse(
63-
id=str(u["_id"]),
64-
username=u.get("username", ""),
65-
email=u.get("email"),
66-
github_id=str(u.get("github_id", "")),
67-
avatar_url=u.get("avatar_url"),
68-
role=u.get("role", "user"),
69-
plan=u.get("plan", "free"),
70-
created_at=str(u.get("created_at", "")),
71-
)
72-
for u in users
73-
]
75+
return [_to_admin_response(u) for u in users]
7476

7577

7678
@router.patch("/users/{user_id}", response_model=AdminUserResponse)
@@ -80,6 +82,11 @@ async def update_user_role(
8082
_admin: User = Depends(require_admin),
8183
) -> AdminUserResponse:
8284
"""Update a user's role and/or plan."""
85+
try:
86+
ObjectId(user_id)
87+
except (InvalidId, Exception):
88+
raise HTTPException(status_code=400, detail="Invalid user ID format")
89+
8390
update_data: dict[str, Any] = {}
8491

8592
if update.role is not None:
@@ -88,6 +95,11 @@ async def update_user_role(
8895
status_code=400,
8996
detail=f"Invalid role: {update.role}. Must be one of {VALID_ROLES}",
9097
)
98+
if update.role != "admin" and user_id == _admin.id:
99+
raise HTTPException(
100+
status_code=400,
101+
detail="Cannot demote yourself. Ask another admin.",
102+
)
91103
update_data["role"] = update.role
92104

93105
if update.plan is not None:
@@ -107,15 +119,8 @@ async def update_user_role(
107119
if not updated:
108120
raise HTTPException(status_code=404, detail="User not found")
109121

110-
logger.info(f"[Admin] User {user_id} updated: {update_data} by admin {_admin.id}")
111-
112-
return AdminUserResponse(
113-
id=str(updated["_id"]),
114-
username=updated.get("username", ""),
115-
email=updated.get("email"),
116-
github_id=str(updated.get("github_id", "")),
117-
avatar_url=updated.get("avatar_url"),
118-
role=updated.get("role", "user"),
119-
plan=updated.get("plan", "free"),
120-
created_at=str(updated.get("created_at", "")),
122+
logger.info(
123+
"[Admin] User %s updated: %s by admin %s", user_id, update_data, _admin.id
121124
)
125+
126+
return _to_admin_response(updated)

backend/app/api/routes/evaluate.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,11 @@ async def get_result(
302302
raise CorkedError("Authentication required to view this evaluation")
303303

304304
try:
305+
if not is_public_demo:
306+
progress = await get_evaluation_progress(evaluation_id)
307+
if progress.get("user_id") != user.id:
308+
raise CorkedError("Access denied: evaluation belongs to another user")
309+
305310
result = await get_evaluation_result(evaluation_id)
306311
except EmptyCellarError:
307312
raise EmptyCellarError(f"Evaluation not found: {evaluation_id}") from None

backend/app/providers/llm.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import os
21
from dataclasses import dataclass
32
from typing import Optional
43

@@ -124,7 +123,6 @@ def build_llm(
124123
resolved_model = model or PROVIDER_DEFAULTS["vertex"]
125124
if not settings.VERTEX_API_KEY:
126125
raise ValueError("VERTEX_API_KEY is required for Vertex AI Express")
127-
os.environ["GOOGLE_API_KEY"] = settings.VERTEX_API_KEY
128126
vertex_kwargs: dict = {
129127
"model": resolved_model,
130128
"temperature": resolved_temperature,

backend/app/providers/llm_policy.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ async def invoke_with_policy(
125125
Args:
126126
llm: The LangChain LLM instance
127127
messages: Messages to send to the LLM
128-
provider: Provider name (gemini, vertex)
128+
provider: Provider name (gemini, vertex)
129129
config: Retry configuration (defaults to RetryConfig())
130130
langchain_config: Optional config to pass to llm.ainvoke()
131131
on_retry: Optional callback(attempt, delay, message) for logging

backend/app/services/evaluation_service.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,14 @@ async def _prepare_repo_context(
6666
async def _get_user_doc(user_id: str | None) -> dict | None:
6767
if not user_id:
6868
return None
69-
user_repo = UserRepository()
70-
return await user_repo.get_by_id(user_id)
69+
try:
70+
user_repo = UserRepository()
71+
return await user_repo.get_by_id(user_id)
72+
except Exception:
73+
logger.warning(
74+
"Failed to fetch user doc for provider routing", extra={"user_id": user_id}
75+
)
76+
return None
7177

7278

7379
def _create_initial_state(
@@ -227,7 +233,9 @@ async def run_evaluation_pipeline(
227233
repo_url, api_key, github_token
228234
)
229235
user_doc = await _get_user_doc(user_id)
230-
provider_decision = decide_provider(user_doc, provider, api_key)
236+
provider_decision = decide_provider(
237+
user_doc, provider, resolved_key if api_key else None
238+
)
231239
state = _create_initial_state(
232240
repo_url, repo_context, criteria, user_id=user_id or ""
233241
)
@@ -493,7 +501,9 @@ async def run_evaluation_pipeline_with_events(
493501
repo_url, api_key, github_token
494502
)
495503
user_doc = await _get_user_doc(user_id)
496-
provider_decision = decide_provider(user_doc, provider, api_key)
504+
provider_decision = decide_provider(
505+
user_doc, provider, resolved_key if api_key else None
506+
)
497507
state = _create_initial_state(
498508
repo_url,
499509
repo_context,

backend/app/services/provider_routing.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,4 +59,10 @@ def decide_provider(
5959
if _is_premium(user_doc):
6060
return ProviderDecision(provider="vertex", reason="premium")
6161

62+
# Block unauthorized vertex requests — only admin/premium may use vertex
63+
if requested_provider and requested_provider.lower() == "vertex":
64+
return ProviderDecision(
65+
provider="gemini", reason="unauthorized_vertex_fallback"
66+
)
67+
6268
return ProviderDecision(provider=requested_provider or "gemini", reason="default")

backend/tests/test_llm_provider.py

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from unittest.mock import patch
22

3+
import pytest
34
from langchain_google_genai import ChatGoogleGenerativeAI
45

56
from app.providers.llm import (
@@ -57,18 +58,12 @@ def test_build_llm_vertex_routing(self, mock_settings, mock_vertex):
5758
def test_build_llm_vertex_missing_key_raises(self):
5859
with patch("app.providers.llm.settings") as mock_settings:
5960
mock_settings.VERTEX_API_KEY = ""
60-
try:
61+
with pytest.raises(ValueError, match="VERTEX_API_KEY"):
6162
build_llm("vertex", None, None, 0.1, 128)
62-
assert False, "Should have raised ValueError"
63-
except ValueError as e:
64-
assert "VERTEX_API_KEY" in str(e)
6563

6664
def test_build_llm_unsupported_provider_raises(self):
67-
try:
65+
with pytest.raises(ValueError, match="Unsupported provider"):
6866
build_llm("openai", "test-key", None, 0.1, 128)
69-
assert False, "Should have raised ValueError"
70-
except ValueError as e:
71-
assert "Unsupported provider" in str(e)
7267

7368
def test_build_llm_default_is_gemini(self):
7469
llm = build_llm(None, "test-key", None, 0.1, 128)

frontend/src/app/admin/page.tsx

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client';
22

3-
import { useEffect, useState, useCallback } from 'react';
3+
import { useEffect, useState, useCallback, useRef } from 'react';
44
import { useAuth } from '@/contexts/AuthContext';
55
import { useRouter } from 'next/navigation';
66
import { Shield, Users, Crown, Loader2, AlertCircle, Check } from 'lucide-react';
@@ -42,6 +42,8 @@ export default function AdminPage() {
4242
const [error, setError] = useState<string | null>(null);
4343
const [saving, setSaving] = useState<string | null>(null);
4444
const [saved, setSaved] = useState<string | null>(null);
45+
const [accessDenied, setAccessDenied] = useState(false);
46+
const savedTimerRef = useRef<ReturnType<typeof setTimeout>>(null);
4547

4648
const fetchUsers = useCallback(async () => {
4749
if (!token) return;
@@ -52,7 +54,7 @@ export default function AdminPage() {
5254
headers: { Authorization: `Bearer ${token}` },
5355
});
5456
if (res.status === 403) {
55-
setError('관리자 권한이 없습니다.');
57+
setAccessDenied(true);
5658
return;
5759
}
5860
if (!res.ok) throw new Error(`HTTP ${res.status}`);
@@ -72,6 +74,12 @@ export default function AdminPage() {
7274
if (isAuthenticated) fetchUsers();
7375
}, [isAuthenticated, authLoading, fetchUsers, router]);
7476

77+
useEffect(() => {
78+
return () => {
79+
if (savedTimerRef.current) clearTimeout(savedTimerRef.current);
80+
};
81+
}, []);
82+
7583
const updateUser = async (userId: string, field: 'role' | 'plan', value: string) => {
7684
if (!token) return;
7785
setSaving(userId);
@@ -91,7 +99,8 @@ export default function AdminPage() {
9199
const updated: AdminUser = await res.json();
92100
setUsers((prev) => prev.map((u) => (u.id === userId ? updated : u)));
93101
setSaved(userId);
94-
setTimeout(() => setSaved(null), 1500);
102+
if (savedTimerRef.current) clearTimeout(savedTimerRef.current);
103+
savedTimerRef.current = setTimeout(() => setSaved(null), 1500);
95104
} catch (err) {
96105
setError(err instanceof Error ? err.message : 'Update failed');
97106
} finally {
@@ -107,7 +116,7 @@ export default function AdminPage() {
107116
);
108117
}
109118

110-
if (error === '관리자 권한이 없습니다.') {
119+
if (accessDenied) {
111120
return (
112121
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-4">
113122
<Shield className="w-16 h-16 text-red-400" />
@@ -129,7 +138,7 @@ export default function AdminPage() {
129138
</div>
130139
</div>
131140

132-
{error && error !== '관리자 권한이 없습니다.' && (
141+
{error && (
133142
<div className="mb-6 flex items-center gap-2 rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
134143
<AlertCircle size={16} />
135144
{error}

0 commit comments

Comments
 (0)