Cosmic Bull

Rendered from pearl/r/permission_registry/FIXES.md at commit 6a510c665a53 in the project repository. The committed file is the source of truth; this page is a rendering of it.

permission_registry — audit & remediation layer

Target: gno.land/r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/permission_registry (pearl-1) Upstream: SillyZir/permission_registry @ facb9f0c4650dddb1bac9b6332a408dbecd7199d Audit date: 2026-09-21

Layering

The port was applied in two strictly separated layers:

  1. Mechanical port (port.py, commit c984fcc) — namespace rewrite plus the intermediate-gno-0.9 → pearl-1 API-era substitutions, every replacement pinned to an exact occurrence count. No behavioral change. port.py is now neutered: it exits immediately, because rerunning it would regenerate pre-remediation source over the fixed files.
  2. Remediation (this document) — the audit-driven changes below. The committed .gno files are the deployable source of truth.

Port fidelity was verified independently of port.py: applying only the documented substitutions to the upstream bytes reproduces the ported files exactly.

Findings

Severity per security.md § Severity calibration. RED = block-worthy; YELLOW = material unless the trust assumption is explicit and reasonable.

R1 — RED — namespace monopolization of a shared permissionless registry

MaxResources = 200 was the only bound on resource creation, and CreateResource is open to any address. A single funded key could create 200 junk resources for ~200 cheap transactions and permanently deny the registry to every other tenant: DeleteResource is admin-only, so the attacker never has to release a slot. For a shared multi-tenant ACL registry this bricks the application for all future users at negligible cost and with no privilege.

Fix: added MaxResourcesPerAdmin = 20 enforced in CreateResource and (at consent time) in AcceptAdmin, backed by an O(1) adminResources map[address]int that is decremented on delete and on handoff and pruned to zero-free. Raised MaxResources 200 → 1000 so the global cap is a pure state bound rather than a de-facto anti-squat defense.

Residual, stated honestly: this raises the cost of monopolization from one funded key to fifty, but does not eliminate sybil exhaustion. A permissionless shared namespace cannot fully solve that without a fee, a stake, or an allowlist — each of which would change this application's approved economic and trust model, and is therefore out of scope for this port.

Y1 — YELLOW (material) — latent Class-2 caller identity (stack-walking caller())

func caller() address { return unsafe.PreviousRealm().Address() }   // non-crossing helper
func CreateResource(_ realm, resourceName string) { ... caller() ... }  // cur discarded

security.md lists unsafe.PreviousRealm() used as caller identity inside a non-crossing function as RED (Class 2 — it returns the realm before the most recent boundary, not the immediate caller). Every path this realm currently exposes reaches caller() directly from a crossing frame, so the resolved address was correct in practice — the finding is latent, not live. But the entrypoints discarded their realm parameter entirely, so nothing structural tied identity to the caller; any future non-crossing exported helper calling caller() would have silently resolved its importer's caller instead of its importer.

Fix: all eight entrypoints now take cur realm and derive identity inline via cur.Previous().Address(). The caller() helper is deleted so it cannot be reintroduced. mustBeAdmin takes the resolved address as a parameter.

This is the same finding and the same fix as in fee_split, timelock_guardian and upgrade_registry.

Y3 — YELLOW (material) — unbounded Render output

Render walked every resource, every permission and every holder with no cap. Against the declared limits that is 1000 × 50 × 200 rendered holder entries. Render is reachable by any viewer through gnoweb and vm/qrender, so unlike state growth its cost lands on third parties, not on whoever grew the state.

Fix: output bounded by MaxRenderResources = 20, MaxRenderPermissions = 8, MaxRenderHolders = 10, with explicit truncation notices naming the bounded query to use for complete data (ListResources / GetPermissions / Has). Holder sets are sorted in full before truncation, so the shown subset is a deterministic function of state rather than of map layout. The true total is printed so truncation is never silent.

Y4 — YELLOW (material) — one-step TransferAdmin permanently bricks a resource

address.IsValid() only checks bech32 form. A well-formed but unowned destination passed the check and the transfer committed immediately, after which the resource could never again be granted on, revoked from, transferred or deleted — and its slot was lost from both the global cap and the former admin's quota forever.

Fix: two-step handoff — TransferAdmin nominates, AcceptAdmin (nominee-only) completes, CancelAdminTransfer (admin-only) withdraws. Nomination changes nothing: the sitting admin retains full control until consent. The nominee's quota is checked at consent time so a nomination can never push an account past its limit without that account agreeing. A nomination cannot outlive its resource (DeleteResource clears it), and self-nomination is rejected. GetPendingAdmin exposes the pending state and Render discloses it.

Matches the AcceptTargetOwnership pattern already shipped in timelock_guardian.

Y5 — YELLOW (material) — no stray-send guard

The realm holds no banker, exposes no payable path and has no withdrawal function. Coins attached to a MsgCall against any entrypoint would sit at the realm address permanently unrecoverable (security.md § operational treats fund-stranding as block-worthy).

Fix: rejectStraySend(cur) on all eight crossing entrypoints; the abort reverts the transfer. Same helper as the three sibling realms.

Y6 — YELLOW — declared bounds with no test coverage

MaxPermissionsPerResource and MaxHoldersPerPermission were both enforced in code and entirely untested, so a regression in either would have shipped silently.

Fix: TestGrant_PermissionCap (including that pruning an emptied permission frees a slot) and TestGrant_HolderCap.

Y7 — YELLOW — undocumented integrator contract on Has

Has is the realm's whole reason to exist: other realms import it to make authorization decisions. It takes the subject address explicitly and performs no caller authentication. An integrator that derives that address inside a non-crossing helper via unsafe.PreviousRealm() reproduces Class-2 designation-forgery in the consumer — the exact shape Y1 removed from this realm.

Fix: explicit INTEGRATOR CONTRACT block on Has stating that the consumer must derive the address from its own crossing entrypoint's cur.Previous().Address().

Assessed and NOT changed

Test coverage

25 tests (from 17), all passing on the chain-matched pearl toolchain; lint clean; whole pearl/ workspace regression green. New: TestCreate_PerAdminCap, TestGrant_PermissionCap, TestGrant_HolderCap, TestTransferAdmin_TwoStep, TestTransferAdmin_Cancel, TestTransferAdmin_QuotaCheckedAtConsent, TestTransferAdmin_NominationDiesWithResource, TestStraySendRejected, TestRender_Bounded. TestCreate_GlobalCap now fills the registry across a fleet of addresses, which the per-admin quota requires.