fix(claude-code): restrict marketplace catalog mutations to proxy admins

This commit is contained in:
ozolam 2026-08-11 21:34:41 +03:00
parent 141281b1d6
commit 346c065fe0
2 changed files with 95 additions and 0 deletions

View file

@ -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(

View file

@ -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