fix(P2.2-sec): visibility-gate cross-tenant custom-library resolution

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.
This commit is contained in:
David Montero 2026-06-08 00:36:29 +02:00
parent d3f06265ac
commit 1aae505aa7
2 changed files with 41 additions and 22 deletions

View File

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

View File

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