carlos / platform Public

console: a route's granted line never crosses the account it belongs to

Assemble groups routes by app NAME with no account filter, and app names are
unique per account rather than globally — so two accounts each owning an app
called "kass" both land in one AppView, and every per-route field rides that
bleed. Grants are the sharpest thing on it: "granted: host-role, port:587"
says which privileged port and which slice of the box's own authority an
operator handed another tenant's route.

Scoped fix, at the one place a RouteView is built: Caps is blank on any route
whose account is not the account being viewed, so the ladder renders no
granted line for it. Scoped there rather than in handleApp because Ladder is
assembled inside Assemble — a post-Assemble blank would leave every
LadderRoute's copy untouched, which is the only place Caps renders at all.
The full Assemble account filter stays the separate follow-up; MINOR-8's
comment now names capabilities in the blast radius and this guard.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 5e15c89117e91cf80f7595e709ef4115d8289f77 parent f5f28fa
2 files changed, +92 −4
  • internal/console/ladder_test.go +57 −0
  • internal/console/view.go +35 −4
diff --git a/internal/console/ladder_test.go b/internal/console/ladder_test.go
index 7d7049301..6db4ab588 100644
--- a/internal/console/ladder_test.go
+++ b/internal/console/ladder_test.go
@@ -360,6 +360,63 @@ func TestLadderShowsGrantedCapabilities(t *testing.T) {
}
}
+// TestLadderGrantedCapabilitiesNeverCrossAccounts is the scoped guard from the
+// 2026-08-03 review (I4). Assemble groups routes by app NAME with no account
+// filter, and app names are unique per ACCOUNT rather than globally
+// (Store.AppAccounts) — so two accounts each owning an app called "kass" both
+// land in one AppView, and every per-route field rides that bleed. Grants are
+// the sharpest thing to ride it: "granted: port:587, host-role" says which
+// privileged port and which slice of the box's own authority an operator has
+// handed ANOTHER tenant's route.
+//
+// The rest of the bleed (the row itself, its environment chip, its prod-wired
+// badge) is pre-existing and stays until Assemble takes a real account filter.
+// This pins the one field that was never allowed to cross: A's page renders
+// its OWN grants and carries nothing of B's, by any spelling.
+func TestLadderGrantedCapabilitiesNeverCrossAccounts(t *testing.T) {
+ fx := newConsoleFixture(t)
+ if err := fx.st.CreateAccount(42, "cdef", "Rival", "rival@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := fx.st.CreateAppIn(42, "kass", "rival@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ // paul's own route, with a grant of its own — the guard must not silence
+ // the render it was added for.
+ fx.addRoute(t, registry.Route{Host: "mx.kass.carlosapp.dev", Kind: "instance", Status: "ready",
+ Version: "v1", App: "kass", Channel: "stable", Caps: "port:25", Addr: "127.0.0.1:9014"})
+ // Same app name, another account, paul is not a member of it. Distinct
+ // tokens, so a leak cannot hide behind paul's own.
+ fx.addRoute(t, registry.Route{Host: "rival.kass.example.com", Kind: "instance", Status: "ready",
+ Version: "v1", App: "kass", Channel: "stable", Account: "cdef",
+ Caps: "host-role,port:587", Addr: "127.0.0.1:9015"})
+
+ c := signInPaul(t, fx)
+ _, _, body := get(t, c, fx.u("/apps/kass"))
+
+ block := rungBlock(t, body, "stable")
+ own := routeLine(t, block, "mx.kass.carlosapp.dev")
+ if !strings.Contains(own, "granted: port:25") {
+ t.Errorf("the viewing account's OWN grants stopped rendering:\n%s", own)
+ }
+ // Anywhere on the page, not just in the rival's own row: the tokens
+ // themselves are the secret, and where they are printed does not change
+ // that. (host-role is deliberately not asserted on — paul's fixture is
+ // free to grant it too; the port is the unambiguous sentinel.)
+ if strings.Contains(body, "port:587") {
+ t.Fatalf("the app page leaked another account's grant:\n%s", block)
+ }
+ // The rival's row itself is still expected on the page (the pre-existing
+ // grouping bleed above); it must simply carry no granted: line. Tolerant of
+ // the row being gone entirely, which is what the follow-up account filter
+ // will do — that is also no leak.
+ for _, li := range strings.Split(block, "<li") {
+ if strings.Contains(li, ">rival.kass.example.com</a>") && strings.Contains(li, "granted:") {
+ t.Fatalf("another account's route rendered a granted: line:\n%s", li)
+ }
+ }
+}
+
// Visibility, not permission (spec §5): a member sees the same environment
// and prod-wired chrome an owner does. Only the promote control is
// owner-gated — see TestLadderPromoteControlIsOwnerOnly.
diff --git a/internal/console/view.go b/internal/console/view.go
index 5182bc5ca..353077209 100644
--- a/internal/console/view.go
+++ b/internal/console/view.go
@@ -126,6 +126,11 @@ type RouteView struct {
// Read-only: grants are written by `carlos route -grant/-revoke` on the
// box, never from the console (spec §7 "nothing here touches the
// console's gates" — this adds no route, no handler, no write path).
+ //
+ // Blank on any route whose Account is not the account being viewed: see
+ // Assemble's byApp comment. Routes are grouped by app NAME, so this slice
+ // can hold another tenant's rows, and a grant is the one field here that
+ // names privileged ports and box authority.
Caps string
}
@@ -219,8 +224,25 @@ func Assemble(regPath string, s release.Store, pub ed25519.PublicKey, acct strin
// MINOR 8): the Environment chip now shows one tenant's bundle NAMES to
// the other, and prodWiredCanaryHosts' stable-environment set reads
// across the same boundary, so one tenant's stable route can make
- // another's canary badge prod-wired. The fix is at this source — filter
- // by release.RouteAccount(rt) == acct — not further downstream.
+ // another's canary badge prod-wired. Capabilities joined that list
+ // (2026-08-03 review, I4): Caps is the ladder's "granted: host-role,
+ // port:25" line, which says which privileged ports and which box
+ // authority an operator has handed one tenant's route — the most
+ // sensitive thing this bleed has carried so far. The fix is at this
+ // source — filter by release.RouteAccount(rt) == acct — not further
+ // downstream.
+ //
+ // That whole-view filter stays a separate change, because it also decides
+ // what the half-migrated AccountMismatch banner reads from. Until then,
+ // Caps ALONE is scoped here, at the one place a RouteView is built: a
+ // route belonging to another account arrives with Caps blank, so the
+ // ladder renders no granted line for it (ladder.go copies this field
+ // verbatim, and `{{if .Caps}}` in ladder.html renders nothing for ""),
+ // while every other cross-account field keeps behaving exactly as it did.
+ // Scoping it HERE rather than in handleApp is not a style choice: Ladder
+ // is assembled inside this function, so a post-Assemble blank in the
+ // handler would leave every LadderRoute's copy untouched — which is the
+ // only place Caps renders at all.
byApp := map[string][]RouteView{}
domainsByApp := map[string][]DomainView{}
// What the ROUTES say each app's account is. A disagreement with the
@@ -239,12 +261,21 @@ func Assemble(regPath string, s release.Store, pub ed25519.PublicKey, acct strin
routeAccount[rt.App] = a
}
}
+ rtAcct := release.RouteAccount(rt)
+ // The scoped guard described above: grants render only on rows the
+ // viewing account owns. Fails closed — a route whose stamped account
+ // disagrees with the account being viewed (the half-migrated state)
+ // shows no grants rather than someone else's.
+ capsFor := ""
+ if rtAcct == acct {
+ capsFor = humanizeCaps(rt.Caps)
+ }
byApp[rt.App] = append(byApp[rt.App], RouteView{
Host: rt.Host, Kind: rt.Kind, Status: rt.Status,
Version: rt.Version, Channel: ch, Hibernate: rt.Hibernate,
- Account: release.RouteAccount(rt),
+ Account: rtAcct,
Environment: rt.Environment,
- Caps: humanizeCaps(rt.Caps),
+ Caps: capsFor,
})
domainsByApp[rt.App] = append(domainsByApp[rt.App], domainView(rt, edgeIPs))
}