From 1aae505aa71715f858e99adff9db78e9b181628e Mon Sep 17 00:00:00 2001 From: David Montero Date: Mon, 8 Jun 2026 00:36:29 +0200 Subject: [PATCH] fix(P2.2-sec): visibility-gate cross-tenant custom-library resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The compile scope resolved a project OWNER's per-user custom libraries for ANY project_id a requester supplied, with no visibility check — so a requester who knew a victim's PRIVATE project_id + custom-lib name could compile a binary against the victim's private uploaded library. Replace the ungated get_project_owner hook with resolve_compile_owner(project_id, requester_id), which returns the owner ONLY when the requester IS the owner OR the project is shareable (public/unlisted); otherwise None -> the caller falls back to the requester's OWN store. Fails closed on any error. Also gate the server-side manifest fallback by the same rule (symmetry): a private project's declared library NAMES are no longer read into a non-owner's compile scope. The owner's own compile and shared/embed compiles of public/ unlisted projects keep their backend-authoritative scope unchanged. --- backend/app/api/routes/compile.py | 24 +++++++++++++------ backend/app/core/hooks.py | 39 +++++++++++++++++++------------ 2 files changed, 41 insertions(+), 22 deletions(-) diff --git a/backend/app/api/routes/compile.py b/backend/app/api/routes/compile.py index 7fd4f3fe..ba54ea5a 100644 --- a/backend/app/api/routes/compile.py +++ b/backend/app/api/routes/compile.py @@ -11,7 +11,7 @@ from pydantic import BaseModel from app.core.hooks import ( get_current_user_id, get_project_libraries, - get_project_owner, + resolve_compile_owner, record_compile, ) from app.services.arduino_cli import ArduinoCLIService @@ -234,19 +234,29 @@ async def _resolve_compile_scope( Owner = whose per-user custom libraries the manifest may reference: the project OWNER for a saved project (so a shared/embed compile finds that - owner's libs), else the REQUESTER for an unsaved compile (the libs they just - uploaded are their own). None for anon. + owner's libs) — but ONLY when the requester is the owner or the project is + shareable (public/unlisted), so a private project's custom libs are never + reachable by another user (resolve_compile_owner enforces this gate). Falls + back to the REQUESTER for an unsaved / private-non-owner / anon compile (the + libs they just uploaded are their own). """ + # Resolve the visibility-gated owner FIRST: a non-None result means the + # requester may read THIS project's server-side state (it is their own, or + # public/unlisted). That same gate decides whether the saved-project manifest + # may be honored — so a PRIVATE project's declared library NAMES are never + # exposed to a non-owner via the server-side fallback (symmetry with the + # owner-bytes gate; P2.2-sec). + gated_owner = await resolve_compile_owner(request.project_id, requester_id) + allowed_libraries: set[str] | None = None if request.libraries: allowed_libraries = set(request.libraries) - else: + elif gated_owner is not None: project_libs = await get_project_libraries(request.project_id) if project_libs: allowed_libraries = set(project_libs) - owner_id = await get_project_owner(request.project_id) - if owner_id is None: - owner_id = requester_id + + owner_id = gated_owner if gated_owner is not None else requester_id return allowed_libraries, owner_id diff --git a/backend/app/core/hooks.py b/backend/app/core/hooks.py index 8a0a3d0d..3db4b98e 100644 --- a/backend/app/core/hooks.py +++ b/backend/app/core/hooks.py @@ -175,30 +175,39 @@ def materialize_library_scope( return None -# ── get_project_owner ───────────────────────────────────────────────────────── -# Resolve a project's OWNER user-id from its id (the value stored as user_id on -# the project record). Used so a compile resolves the OWNER's per-user custom -# libraries, not the requester's. Returns None for an unknown project or when no -# overlay is loaded. +# ── resolve_compile_owner ───────────────────────────────────────────────────── +# Resolve WHOSE per-user custom libraries a compile may resolve for a project — +# applying a VISIBILITY gate. A compile of a saved project resolves the OWNER's +# custom libraries (so a shared / embed compile of someone's PUBLIC project still +# finds that owner's uploaded libs), but a requester must NOT be able to pull +# another user's PRIVATE custom libraries by supplying that user's project_id. +# The overlay returns the project owner ONLY when the requester IS the owner OR +# the project is shareable (public / unlisted); otherwise None, and the caller +# falls back to the requester's OWN store. `requester_id` is the authenticated +# caller (None for anon). Returns None for an unknown project or no overlay. -GetProjectOwnerHook = Callable[[str], Awaitable[Optional[str]]] +ResolveCompileOwnerHook = Callable[[str, Optional[str]], Awaitable[Optional[str]]] -_get_project_owner_hook: Optional[GetProjectOwnerHook] = None +_resolve_compile_owner_hook: Optional[ResolveCompileOwnerHook] = None -def register_get_project_owner(hook: GetProjectOwnerHook) -> None: - """Install the project-owner resolver. Called by overlays in register_pro.""" - global _get_project_owner_hook - _get_project_owner_hook = hook +def register_resolve_compile_owner(hook: ResolveCompileOwnerHook) -> None: + """Install the visibility-gated compile-owner resolver. Called in register_pro.""" + global _resolve_compile_owner_hook + _resolve_compile_owner_hook = hook -async def get_project_owner(project_id: Optional[str]) -> Optional[str]: - if _get_project_owner_hook is None or not project_id: +async def resolve_compile_owner( + project_id: Optional[str], requester_id: Optional[str] +) -> Optional[str]: + if _resolve_compile_owner_hook is None or not project_id: return None try: - return await _get_project_owner_hook(project_id) + return await _resolve_compile_owner_hook(project_id, requester_id) except Exception: - logger.exception("get_project_owner hook failed (treating as no owner)") + # Fail closed: on any error resolve no foreign owner (caller falls back + # to the requester's own store), never leak another user's libraries. + logger.exception("resolve_compile_owner hook failed (treating as requester-only)") return None