Skip to content

Commit dfa7e14

Browse files
topher-loclaude
andcommitted
fix(registry): defense-in-depth cross-org check on sync_repository
The service method now accepts a RegistryRepository instance and trusts the caller to have done the org-scoped lookup. Both real callers (the HTTP route's get_repository_by_id and the MCP tool's list_repositories) do filter by org, so no exploit exists today, but a future caller that bypasses those lookups would expose IDOR. Add an inline assertion on repository.organization_id == self.organization_id inside sync_repository. On mismatch raise the existing domain exception RegistryNotFound("Registry repository not found"); the route catches it before the broader RegistryError clause and maps it to the same 404 it already returns for missing IDs. Cross-org probing is therefore indistinguishable from a missing repository. Add a focused unit test covering the cross-org rejection. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
1 parent f077c55 commit dfa7e14

3 files changed

Lines changed: 46 additions & 4 deletions

File tree

tests/unit/test_registry_repositories_service.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
from tracecat.auth.types import Role
1111
from tracecat.db.models import RegistryRepository
12-
from tracecat.exceptions import ScopeDeniedError
12+
from tracecat.exceptions import RegistryNotFound, ScopeDeniedError
1313
from tracecat.registry.repositories.schemas import RegistryRepositorySync
1414
from tracecat.registry.repositories.service import RegistryReposService
1515

@@ -115,3 +115,26 @@ async def test_list_repositories_requires_registry_read_scope(
115115
service = RegistryReposService(AsyncMock(), role=role_without_registry_scopes)
116116
with pytest.raises(ScopeDeniedError):
117117
await service.list_repositories()
118+
119+
120+
@pytest.mark.anyio
121+
async def test_sync_repository_rejects_cross_org_repository() -> None:
122+
"""Cross-org repository must surface RegistryNotFound (probing-resistant)."""
123+
role_with_update = Role(
124+
type="service",
125+
service_id="tracecat-api",
126+
workspace_id=uuid.uuid4(),
127+
organization_id=uuid.uuid4(),
128+
user_id=uuid.uuid4(),
129+
scopes=frozenset({"org:registry:update"}),
130+
)
131+
service = RegistryReposService(AsyncMock(), role=role_with_update)
132+
foreign_repository = RegistryRepository(
133+
organization_id=uuid.uuid4(), # different org
134+
origin="custom_actions",
135+
)
136+
137+
with pytest.raises(RegistryNotFound):
138+
await service.sync_repository(
139+
foreign_repository, RegistryRepositorySync(force=False)
140+
)

tracecat/registry/repositories/router.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
EntitlementRequired,
1818
RegistryActionValidationError,
1919
RegistryError,
20+
RegistryNotFound,
2021
TracecatCredentialsNotFoundError,
2122
TracecatValidationError,
2223
)
@@ -98,6 +99,13 @@ async def sync_registry_repository(
9899

99100
try:
100101
return await repos_service.sync_repository(repo, sync_params)
102+
except RegistryNotFound as e:
103+
# Service-level defense-in-depth (cross-org repository) collapses
104+
# into the same 404 response as a missing repository.
105+
raise HTTPException(
106+
status_code=status.HTTP_404_NOT_FOUND,
107+
detail="Registry repository not found",
108+
) from e
101109
except RegistryActionValidationError as e:
102110
logger.warning("Validation errors while syncing repository", exc=e)
103111
raise HTTPException(

tracecat/registry/repositories/service.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
from tracecat.authz.controls import require_scope
1111
from tracecat.db.models import RegistryRepository, RegistryVersion
12-
from tracecat.exceptions import RegistryError
12+
from tracecat.exceptions import RegistryError, RegistryNotFound
1313
from tracecat.registry.constants import DEFAULT_REGISTRY_ORIGIN
1414
from tracecat.registry.repositories.schemas import (
1515
RegistryRepositoryCreate,
@@ -100,9 +100,13 @@ async def sync_repository(
100100
the org-scoped lookup has happened.
101101
102102
Raises:
103+
RegistryNotFound: if the repository does not belong to the
104+
caller's organization. Surfaced as the same 404 the route
105+
returns for missing IDs so this defense-in-depth check
106+
cannot be used to probe for cross-org repository IDs.
103107
EntitlementRequired: for non-default origins without the entitlement.
104-
RegistryError, RegistryActionValidationError,
105-
TracecatCredentialsNotFoundError: surfaced from the underlying sync.
108+
RegistryActionValidationError, TracecatCredentialsNotFoundError:
109+
surfaced from the underlying sync.
106110
"""
107111
# parse_git_url and RegistryActionsService are imported lazily
108112
# because tracecat.git.utils and tracecat.registry.repository both
@@ -111,6 +115,13 @@ async def sync_repository(
111115
from tracecat.git.utils import parse_git_url
112116
from tracecat.registry.actions.service import RegistryActionsService
113117

118+
# Defense-in-depth: real callers already org-scope via
119+
# get_repository_by_id / list_repositories, but this method accepts a
120+
# RegistryRepository instance directly. Treat a wrong-org repository
121+
# the same as "not found" so this check cannot enable probing.
122+
if repository.organization_id != self.organization_id:
123+
raise RegistryNotFound("Registry repository not found")
124+
114125
if repository.origin != DEFAULT_REGISTRY_ORIGIN:
115126
await check_entitlement(
116127
self.session, self.role, Entitlement.CUSTOM_REGISTRY

0 commit comments

Comments
 (0)