carlos / platform Public

tenant-iso-routes

Fixes items 4 and 5 of discussion 214 (the 2026-10-03 tenant-isolation audit). Items 1 to 3 belong to the isolated-exec layout work. Rebased onto main 9bed97b5.

Item 4: only operators state upstream addresses

Before: the console's instances create and PATCH doors took any addrs the shape rule allowed. Only link-local was refused. A member could therefore point a public hostname at 127.0.0.1:8090 (the console), at 10/8, ULA, CGNAT and so on, and the edge would proxy traffic there.

Ruling: the "escalated separately" note is GitHub #170, now discussion 50. Paul chose option (a): members may not state upstream addresses at all, public addresses included, until the "own fleet" allowance exists. Operators keep today's rule. Existing tenants must not be affected. (Paul's earlier ruling from 2026-08-26 was lost in the #170 → discussion 50 migration.)

What changes:

  • refuseMemberUpstreams in internal/console/apiinstances.go runs on both doors after ValidateShape.
  • It refuses any address the record does not already carry with 403 "only a platform operator can point an instance at its own backends". The exception is a person with the operator bit (store.IsOperator).
  • A service credential never counts as an operator. A failed operator lookup answers 503.

What does not change:

  • Addresses already on a record pass, whoever sends them. A tenant whose route an operator set up can keep its upstreams, reorder them, drop a dead one, or PATCH probe, environment, steering or gate. Restating a legacy Addr through addrs also passes.
  • Unchanged code: records in buckets, ValidateShape, publiclyDialable, reconcile and routing, and every operator path. The provision diff is only two comment lines that point at the new rule.
  • On DNS names: addrs already refused them, and members can now state no address at all.

Behaviour change to check: an app or service token can no longer add an upstream to its own app's instance. A test comment called that "the door a signup-time app uses to repoint its own upstreams". I found no documented or in-repo user of it, but if a production app adds upstreams with an app token, it will now get 403. Worth checking against the live audit log before landing.

docs/site/cli.md has the new paragraph.

Item 5: loopback X-Forwarded-For needs the edge's proof

Before: the console believed X-Forwarded-For from any loopback peer. A tenant process on the console box could rotate its IP and get past the per-IP device-code login limits.

What changes:

  • New package internal/edgeproof. The edge signs what it sends upstream in an X-Carlos-Forwarded-Proof header: an HMAC with the mesh key over a separate label, the canonical Host, the XFF chain and a timestamp (±30s).
  • Binding the Host stops a tenant app replaying the proofs it receives on its own traffic to the console. The console verifies against r.Host.
  • setForwarded always deletes a proof the client sent.
  • backhaulProxy sends no proof across a hop, so the console can only latch on a proof from its own box's edge.
  • The console checks proofs for loopback peers, with the keys cached 30s and the last good pair kept. Peers in PLATFORM_EDGE_IPS are trusted as before.
  • Rollout latch: the console keeps the old trust until it sees its first valid proof. It then records that in edge_proof_seen (a new additive table) and requires proofs from then on, across restarts.
  • The mesh-key loader moved into edgeproof.

Operator rollout step: roll every edge before the console, or together with it. With the backhaul strip this is a precaution, not a requirement. If an edge is rolled back after the latch, every client lands in one rate-limit bucket; the console logs this once.

Not checked live: whether the console's bucket credentials can read control/edge-mesh-key. If they cannot, the latch never closes and behaviour stays as it is today.

Tests

  • Item 4: - TestAPIInstanceUpstreamsAreOperatorOnly: member create refused for public, loopback, private and ULA addresses; create without addrs allowed; member PATCH of probe/environment/steering keeps the upstreams; member add refused; restate and drop allowed; operator create and PATCH allowed; app token add refused. - TestExistingUpstreamRecordsKeepServing: a pre-existing legacy private Addr record still passes ValidateShape, survives a member probe PATCH, and can be restated. Reconcile minting of such records is covered by the unchanged reconcile_test.
  • Item 5: - TestClientIPLoopbackNeedsTheEdgeProof: forged, stale, wrong-chain and wrong-host proofs; host spelling; an extra XFF line; the latch and its persistence; configured edge peers. - TestEdgeProofSeenMark, TestEdgeSignsTheForwardedChain, TestEdgeWithoutAKeyStripsTheProof, TestForwardProofIsNotAHopAuth, TestBackhaulCarriesNoForwardProof
  • Existing tests that create addr-backed records now grant the creating person the operator bit (letStateUpstreams), so their addresses are as on main. Two of them now restate an existing upstream rather than add one: the app-token step in TestAPIInstanceUpdateRefusalMatrix, and the ordinary-app service PATCH in TestControlPlaneAppRefusesAServiceInstancePatch. instances_e2e_test makes its CLI user an operator.

Gates

  • gofmt -l is clean, and go build ./... and go vet ./... pass.
  • make test with GOFLAGS=-buildvcs=false: every failure is a known environment one, and each also fails on main. They are socket-path-length tests in activator, edge (TestListenPurgeClose*) and instanceexec, plus the root package reading ~/.carlos/config. I confirmed the root one on origin/main with the same command.
  • internal/console and internal/provision pass on the rebased tip.
Commits 0
Discussions 1 1 open
Tasks 0/0 done

Activity