carlos / platform Public

console: cliRoute's host check and the self binding (#155)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev e42e2258abc4d424398bd70567b1572f14b875f1 parent ed7512c
4 files changed, +154 −2
  • internal/console/apiinstances.go +10 −2
  • internal/console/authcli.go +15 −0
  • internal/console/instancetoken_gate_test.go +103 −0
  • internal/console/selfbinding.go +26 −0
diff --git a/internal/console/apiinstances.go b/internal/console/apiinstances.go
index 00eee4776..c43d6ca08 100644
--- a/internal/console/apiinstances.go
+++ b/internal/console/apiinstances.go
@@ -91,8 +91,16 @@ func (s *Server) instanceGateSaying(w http.ResponseWriter, r *http.Request, noun
}
// Keyed on the TOKEN, not the address (see cliInstanceBurst): the
// principal here is frequently an app, which has no address of its own,
- // and a signup burst is the workload rather than the abuse.
- if !s.cli.instances.allow(hashToken(bearer(r)), time.Now()) {
+ // and a signup burst is the workload rather than the abuse. An
+ // instance-bound token is the exception: its tokens for one incarnation
+ // share one budget, so minting more of them buys nothing, while a
+ // sibling's or a replacement's stays its own (spec
+ // 2026-10-16-instance-bound-tokens §3.5).
+ key := hashToken(bearer(r))
+ if p, _ := principalFrom(r.Context()); p.Instance != "" {
+ key = principalKey(p)
+ }
+ if !s.cli.instances.allow(key, time.Now()) {
jsonError(w, http.StatusTooManyRequests, c.tooMany(noun))
return "", nil, "", false
}
diff --git a/internal/console/authcli.go b/internal/console/authcli.go
index 9a0b20ed7..dc502e731 100644
--- a/internal/console/authcli.go
+++ b/internal/console/authcli.go
@@ -439,6 +439,12 @@ func principalFrom(ctx context.Context) (Principal, bool) {
// so a door added without naming its roles is unreachable by every service
// principal rather than open to all of them.
func (s *Server) cliRoute(pattern string, allow svcrole.Set, h http.HandlerFunc) {
+ if allow.Permits(svcrole.Self) && !strings.Contains(pattern, "{host}") {
+ // A Self door can only ever answer for one host; one without a host
+ // in its path would 404 every request (spec
+ // 2026-10-16-instance-bound-tokens §4.1).
+ panic("console: " + pattern + " admits svcrole.Self but has no {host}")
+ }
if s.cliRoles == nil {
s.cliRoles = map[string]svcrole.Set{}
}
@@ -468,6 +474,15 @@ func (s *Server) cliRoute(pattern string, allow svcrole.Set, h http.HandlerFunc)
"this credential holds role %q; this door needs one of %v", p.Role, allow.Roles()))
return
}
+ // The instance-bound credential's host check (spec
+ // 2026-10-16-instance-bound-tokens §4.1). Like the role gate it reads
+ // only the path and the caller's own binding, so it leaks nothing,
+ // and its answer is the stranger's 404 byte for byte. Exact
+ // comparison: another spelling of the same host fails closed.
+ if p.Instance != "" && r.PathValue("host") != p.Instance {
+ http.NotFound(w, r)
+ return
+ }
h(w, r.WithContext(context.WithValue(r.Context(), cliRouteKey{}, p)))
})
}
diff --git a/internal/console/instancetoken_gate_test.go b/internal/console/instancetoken_gate_test.go
new file mode 100644
index 000000000..d166176c2
--- /dev/null
+++ b/internal/console/instancetoken_gate_test.go
@@ -0,0 +1,103 @@
+package console
+
+import (
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/svcrole"
+)
+
+// cliRoute's host check (spec 2026-10-16-instance-bound-tokens §4.1): a
+// Self token reaches only the {host} it is bound to, spelled exactly, and
+// every other host is the stranger's 404 byte for byte, with the handler
+// never run.
+func TestCLIRouteSelfHostCheck(t *testing.T) {
+ fx := newConsoleFixture(t)
+ raw := mintBoundToken(t, fx.st, OpsAccountID, "kass", "a.example.com", testBorn)
+ hit := 0
+ fx.srv.cliRoute("GET /api/cli/test/{account}/{app}/instances/{host}/probe", svcrole.AllowSelf(svcrole.Set{}),
+ func(w http.ResponseWriter, r *http.Request) { hit++; w.WriteHeader(http.StatusNoContent) })
+ want := canonicalNotFound(t)
+ base := fx.ts.URL + "/api/cli/test/" + fx.sqid + "/kass/instances/"
+ for _, host := range []string{"b.example.com", "A.example.com", "a.example.com.", "a.example.co", "a.example.com%2e", "nope"} {
+ st, h, body := rawReq(t, http.MethodGet, base+host+"/probe", raw, "")
+ assertNotFound(t, "self token on "+host, want, st, h, body)
+ }
+ if hit != 0 {
+ t.Fatalf("handler ran %d times for hosts the token is not bound to", hit)
+ }
+ if st, _, body := rawReq(t, http.MethodGet, base+"a.example.com/probe", raw, ""); st != http.StatusNoContent {
+ t.Fatalf("own host = %d %s", st, body)
+ }
+ // Any other caller is untouched by the check: an admin credential has
+ // no binding, and a person never meets the role gate at all.
+ admin := serviceToken(t, fx.st, OpsAccountID, "root", "", svcrole.Admin)
+ if st, _, body := rawReq(t, http.MethodGet, base+"b.example.com/probe", admin, ""); st != http.StatusForbidden {
+ t.Fatalf("admin at a Self-only door = %d %s, want the role 403", st, body)
+ }
+}
+
+func TestCLIRoutePanicsOnSelfDoorWithoutHost(t *testing.T) {
+ fx := newConsoleFixture(t)
+ defer func() {
+ if recover() == nil {
+ t.Fatal("a Self door without {host} registered")
+ }
+ }()
+ fx.srv.cliRoute("GET /api/cli/test/{account}/{app}/nohost", svcrole.AllowSelf(svcrole.Set{}), func(http.ResponseWriter, *http.Request) {})
+}
+
+func TestSelfBindingRefusesAnotherIncarnation(t *testing.T) {
+ b := selfBinding{Host: "a.example.com", Born: testBorn}
+ same := &provision.Record{Host: "a.example.com", CreatedAt: time.Unix(0, testBorn)}
+ reborn := &provision.Record{Host: "a.example.com", CreatedAt: time.Unix(0, testBorn+1)}
+ other := &provision.Record{Host: "b.example.com", CreatedAt: time.Unix(0, testBorn)}
+ if b.refuses(same) {
+ t.Fatal("refused its own incarnation")
+ }
+ if !b.refuses(reborn) || !b.refuses(other) || !b.refuses(nil) {
+ t.Fatal("accepted a replacement, a sibling or no record")
+ }
+ if (selfBinding{}).refuses(reborn) || (selfBinding{}).refuses(nil) {
+ t.Fatal("the zero binding refused")
+ }
+ p := Principal{Instance: "a.example.com", InstanceBorn: testBorn}
+ if bindingOf(p) != b {
+ t.Fatalf("bindingOf = %+v", bindingOf(p))
+ }
+}
+
+// instanceGate's budget is per incarnation for an instance token (spec
+// §3.5): tokens for one incarnation share it, so minting more buys nothing,
+// while a sibling's and a replacement incarnation's stay their own.
+func TestInstanceLimiterIsPerIncarnation(t *testing.T) {
+ fx := newConsoleFixture(t)
+ fx.srv.cli.instances = newRateLimiter(2, cliRateWindow)
+ fx.srv.cliRoute("GET /api/cli/test/{account}/{app}/instances/{host}/limit", svcrole.AllowSelf(svcrole.Set{}),
+ func(w http.ResponseWriter, r *http.Request) {
+ if _, _, _, ok := fx.srv.instanceGate(w, r); ok {
+ w.WriteHeader(http.StatusNoContent)
+ }
+ })
+ a1 := mintBoundToken(t, fx.st, OpsAccountID, "kass", "a.example.com", testBorn)
+ a2 := mintBoundToken(t, fx.st, OpsAccountID, "kass", "a.example.com", testBorn)
+ reborn := mintBoundToken(t, fx.st, OpsAccountID, "kass", "a.example.com", testBorn+1)
+ b := mintBoundToken(t, fx.st, OpsAccountID, "kass", "b.example.com", testBorn)
+ url := func(host string) string { return fx.ts.URL + "/api/cli/test/" + fx.sqid + "/kass/instances/" + host + "/limit" }
+ for i, tok := range []string{a1, a1} {
+ if st, _, body := rawReq(t, http.MethodGet, url("a.example.com"), tok, ""); st != http.StatusNoContent {
+ t.Fatalf("request %d = %d %s", i, st, body)
+ }
+ }
+ if st, _, _ := rawReq(t, http.MethodGet, url("a.example.com"), a2, ""); st != http.StatusTooManyRequests {
+ t.Fatalf("a second token for the same incarnation got a budget of its own: %d", st)
+ }
+ if st, _, body := rawReq(t, http.MethodGet, url("a.example.com"), reborn, ""); st != http.StatusNoContent {
+ t.Fatalf("the replacement incarnation was charged for the old one's requests: %d %s", st, body)
+ }
+ if st, _, body := rawReq(t, http.MethodGet, url("b.example.com"), b, ""); st != http.StatusNoContent {
+ t.Fatalf("the sibling was charged: %d %s", st, body)
+ }
+}
diff --git a/internal/console/selfbinding.go b/internal/console/selfbinding.go
new file mode 100644
index 000000000..f53d34f99
--- /dev/null
+++ b/internal/console/selfbinding.go
@@ -0,0 +1,26 @@
+package console
+
+import "github.com/carlosframework/platform/internal/provision"
+
+// selfBinding is the instance incarnation a Self principal may act on (spec
+// 2026-10-16-instance-bound-tokens §4.3). The zero value, every other
+// caller's, binds nothing and refuses nothing. It travels as a value into
+// every helper that reads the record, because a check made once in a
+// handler misses a retry that re-reads a replacement record.
+type selfBinding struct {
+ Host string
+ Born int64
+}
+
+func bindingOf(p Principal) selfBinding {
+ return selfBinding{Host: p.Instance, Born: p.InstanceBorn}
+}
+
+// refuses reports whether rec is not the incarnation b names. Callers answer
+// http.NotFound when it does, before anything that depends on rec.
+func (b selfBinding) refuses(rec *provision.Record) bool {
+ if b.Host == "" {
+ return false
+ }
+ return rec == nil || rec.Host != b.Host || rec.CreatedAt.UnixNano() != b.Born
+}