mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
fix(claude-code): restrict marketplace catalog mutations to proxy admins
This commit is contained in:
parent
141281b1d6
commit
346c065fe0
2 changed files with 95 additions and 0 deletions
|
|
@ -28,6 +28,7 @@ from fastapi.responses import JSONResponse
|
|||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.proxy._types import CommonProxyErrors, UserAPIKeyAuth
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.proxy.common_utils.resource_ownership import is_proxy_admin
|
||||
from litellm.repositories.table_repositories import ClaudeCodePluginRepository
|
||||
from litellm.types.proxy.claude_code_endpoints import (
|
||||
ListPluginsResponse,
|
||||
|
|
@ -221,6 +222,18 @@ def _name_conflict_error(name: str) -> HTTPException:
|
|||
)
|
||||
|
||||
|
||||
def _require_proxy_admin(user_api_key_dict: UserAPIKeyAuth) -> None:
|
||||
"""Catalog mutations are restricted to proxy admins: marketplace.json is served
|
||||
unauthenticated and any registered/updated entry is immediately installable by
|
||||
every user, so a non-admin key must never be able to add or overwrite one.
|
||||
"""
|
||||
if not is_proxy_admin(user_api_key_dict):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail={"error": "Only proxy admins may modify the Claude Code plugin marketplace."},
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/claude-code/plugins",
|
||||
tags=["Claude Code Marketplace"],
|
||||
|
|
@ -271,6 +284,8 @@ async def register_plugin(
|
|||
from prisma.errors import UniqueViolationError
|
||||
|
||||
try:
|
||||
_require_proxy_admin(user_api_key_dict)
|
||||
|
||||
prisma_client: Final = await _get_prisma_client()
|
||||
|
||||
if not re.match(r"^[a-z0-9-]+$", request.name):
|
||||
|
|
@ -468,6 +483,7 @@ async def get_plugin(
|
|||
async def update_plugin(
|
||||
plugin_name: str,
|
||||
request: UpdatePluginRequest,
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
):
|
||||
"""
|
||||
Update an existing plugin in the LiteLLM marketplace.
|
||||
|
|
@ -509,6 +525,8 @@ async def update_plugin(
|
|||
from prisma.errors import PrismaError
|
||||
|
||||
try:
|
||||
_require_proxy_admin(user_api_key_dict)
|
||||
|
||||
prisma_client: Final = await _get_prisma_client()
|
||||
|
||||
_validate_plugin_source(request.source)
|
||||
|
|
@ -570,6 +588,8 @@ async def enable_plugin(
|
|||
- plugin_name: The name of the plugin to enable
|
||||
"""
|
||||
try:
|
||||
_require_proxy_admin(user_api_key_dict)
|
||||
|
||||
prisma_client: Final = await _get_prisma_client()
|
||||
|
||||
plugin: Final[_PluginRecord | None] = await ClaudeCodePluginRepository(prisma_client).table.find_unique(
|
||||
|
|
@ -615,6 +635,8 @@ async def disable_plugin(
|
|||
- plugin_name: The name of the plugin to disable
|
||||
"""
|
||||
try:
|
||||
_require_proxy_admin(user_api_key_dict)
|
||||
|
||||
prisma_client: Final = await _get_prisma_client()
|
||||
|
||||
plugin: Final[_PluginRecord | None] = await ClaudeCodePluginRepository(prisma_client).table.find_unique(
|
||||
|
|
@ -660,6 +682,8 @@ async def delete_plugin(
|
|||
- plugin_name: The name of the plugin to delete
|
||||
"""
|
||||
try:
|
||||
_require_proxy_admin(user_api_key_dict)
|
||||
|
||||
prisma_client: Final = await _get_prisma_client()
|
||||
|
||||
plugin: Final[_PluginRecord | None] = await ClaudeCodePluginRepository(prisma_client).table.find_unique(
|
||||
|
|
|
|||
|
|
@ -18,6 +18,9 @@ from litellm.types.proxy.claude_code_endpoints import (
|
|||
UpdatePluginRequest,
|
||||
)
|
||||
from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_marketplace import (
|
||||
delete_plugin,
|
||||
disable_plugin,
|
||||
enable_plugin,
|
||||
get_marketplace,
|
||||
register_plugin,
|
||||
update_plugin,
|
||||
|
|
@ -72,6 +75,12 @@ _USER = UserAPIKeyAuth(
|
|||
user_id="test-user",
|
||||
)
|
||||
|
||||
_NON_ADMIN_USER = UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.INTERNAL_USER,
|
||||
api_key="sk-5678",
|
||||
user_id="regular-user",
|
||||
)
|
||||
|
||||
_GIT_SUBDIR_SOURCE = {
|
||||
"source": "git-subdir",
|
||||
"url": "https://github.com/org/monorepo.git",
|
||||
|
|
@ -151,6 +160,7 @@ async def test_update_plugin_replaces_existing_source():
|
|||
response = await update_plugin(
|
||||
plugin_name=name,
|
||||
request=UpdatePluginRequest(source=new_source, version="2.0.0", description="updated"),
|
||||
user_api_key_dict=_USER,
|
||||
)
|
||||
|
||||
assert response.status == "success"
|
||||
|
|
@ -170,6 +180,7 @@ async def test_update_plugin_not_found():
|
|||
await update_plugin(
|
||||
plugin_name="does-not-exist",
|
||||
request=UpdatePluginRequest(source=_GIT_SUBDIR_SOURCE),
|
||||
user_api_key_dict=_USER,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
|
|
@ -213,6 +224,7 @@ async def test_update_plugin_db_error_maps_to_structured_500():
|
|||
await update_plugin(
|
||||
plugin_name=name,
|
||||
request=UpdatePluginRequest(source={"source": "github", "repo": "org/replacement"}),
|
||||
user_api_key_dict=_USER,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 500
|
||||
|
|
@ -341,3 +353,62 @@ async def test_register_plugin_unknown_source_type():
|
|||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "git-subdir" in exc_info.value.detail["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_plugin_rejects_non_admin():
|
||||
"""A non-admin key cannot add an entry to the marketplace catalog."""
|
||||
request = RegisterPluginRequest(name="attacker-plugin", source=_GIT_SUBDIR_SOURCE)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await register_plugin(request=request, user_api_key_dict=_NON_ADMIN_USER)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
table = litellm.proxy.proxy_server.prisma_client.db.litellm_claudecodeplugintable
|
||||
assert await table.find_unique(where={"name": "attacker-plugin"}) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_plugin_rejects_non_admin_overwrite():
|
||||
"""A non-admin key cannot overwrite an existing plugin's source."""
|
||||
name = "trusted-plugin"
|
||||
await register_plugin(
|
||||
request=RegisterPluginRequest(name=name, source=_GIT_SUBDIR_SOURCE, version="1.0.0"),
|
||||
user_api_key_dict=_USER,
|
||||
)
|
||||
|
||||
malicious_source = {"source": "github", "repo": "attacker/malicious-repo"}
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await update_plugin(
|
||||
plugin_name=name,
|
||||
request=UpdatePluginRequest(source=malicious_source),
|
||||
user_api_key_dict=_NON_ADMIN_USER,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
stored = await _read_stored_manifest(name)
|
||||
assert stored["source"] == _GIT_SUBDIR_SOURCE
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enable_disable_delete_plugin_reject_non_admin():
|
||||
"""Non-admin keys cannot enable, disable, or delete catalog entries."""
|
||||
name = "trusted-plugin-2"
|
||||
await register_plugin(
|
||||
request=RegisterPluginRequest(name=name, source=_GIT_SUBDIR_SOURCE, version="1.0.0"),
|
||||
user_api_key_dict=_USER,
|
||||
)
|
||||
|
||||
for coro in (
|
||||
enable_plugin(plugin_name=name, user_api_key_dict=_NON_ADMIN_USER),
|
||||
disable_plugin(plugin_name=name, user_api_key_dict=_NON_ADMIN_USER),
|
||||
delete_plugin(plugin_name=name, user_api_key_dict=_NON_ADMIN_USER),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await coro
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
table = litellm.proxy.proxy_server.prisma_client.db.litellm_claudecodeplugintable
|
||||
assert (await table.find_unique(where={"name": name})).enabled is True
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue