Files
zooqueenandHanzo Dev e89a58f2ec A request naming an org the bearer is not a member of is refused, and a refused bearer never falls back to ?owner (red round 2, item 6).
The tenant was the named org when the signed membership held it and the home org otherwise, so a request aimed at another org was quietly answered from the caller's own. It now resolves to no org, and principal fails closed on any bearer that does not establish a user rather than taking the ?owner the caller named; only a call with no bearer reaches the service branch.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-24 05:57:49 -07:00

192 lines
8.4 KiB
Go

// Copyright 2026 Hanzo Industries Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package controllers
import (
"fmt"
"net/http"
"strings"
"testing"
"time"
"github.com/zap-proto/zip"
"github.com/hanzoai/compute/object"
)
// The node pools were fixed and the same split-brain was left standing on the
// machine and volume handlers. These prove it is gone from those too.
//
// The split-brain: the authorization filter derives the object's owner from
// `?id=` or the request BODY, while these handlers read `?owner=`. Send both,
// disagreeing, and the filter authorizes against the one you own while the
// handler provisions against the one you do not — on the victim's provider
// credentials, and onto the victim's invoice.
// tenantWire stands the machine and volume handlers up on a bare app, registered
// exactly as routers.Route registers them.
func tenantWire(t *testing.T) *zip.App {
t.Helper()
app := zip.New(zip.Config{ReadBufferSize: 16384})
handler := func(fn func(*ApiController)) zip.Handler {
return func(c *zip.Ctx) error { fn(New(c)); return nil }
}
app.Post("/v1/machines", handler((*ApiController).LaunchComputeMachine))
app.Get("/v1/volumes", handler((*ApiController).GetVolumes))
app.Post("/v1/volumes", handler((*ApiController).CreateVolume))
app.Get("/v1/volumes/:owner/:name", handler((*ApiController).GetVolume))
app.Delete("/v1/volumes/:owner/:name", handler((*ApiController).DeleteVolume))
app.Put("/v1/volumes/:owner/:name/attachment", handler((*ApiController).AttachVolume))
app.Delete("/v1/volumes/:owner/:name/attachment", handler((*ApiController).DetachVolume))
app.Put("/v1/volumes/:owner/:name/size", handler((*ApiController).ResizeVolume))
return app
}
// storedVolume plants a DB-only volume belonging to owner.
func storedVolume(t *testing.T, owner, name string) *object.Volume {
t.Helper()
now := time.Now().Format(time.RFC3339)
v := &object.Volume{
Owner: owner, Name: name, Id: "vol-" + name, Provider: "do",
Region: "nyc3", Size: 100, State: "Available",
CreatedTime: now, UpdatedTime: now,
}
if _, err := object.AddVolume(v); err != nil {
t.Fatalf("AddVolume(%s/%s): %v", owner, name, err)
}
return v
}
// `?owner=victim` with a body naming the attacker cleared authorization against
// the attacker's own org and then provisioned on the VICTIM's provider
// credentials, billed to the victim.
//
// The org a compute call runs as is decided in ONE place, so that place is the
// subject here rather than any single handler: an error message that happens to
// name an org is a weaker proof, and it disappears the moment a handler refuses
// earlier for an unrelated reason.
func TestComputeOrgComesFromTheTokenNotTheAddress(t *testing.T) {
mint := signer(t, "https://test.id")
app := zip.New(zip.Config{ReadBufferSize: 16384})
echo := func(c *zip.Ctx) error { return c.String(http.StatusOK, New(c).resolveComputeOrg()) }
app.Get("/v1/machines", echo)
app.Get("/v1/machines/:owner/:name", echo)
// An org the token's membership does not include resolves to none, and the
// caller fails closed — neither the named org nor a quiet substitute.
if got := get(t, app, "/v1/machines?owner=victimlaunch", mint("attackerlaunch")); got != "" {
t.Errorf("the query's org resolved to %q", got)
}
// The address is the newer vector: the owner moved out of the query and into
// a path segment, and it must not have gained authority on the way.
if got := get(t, app, "/v1/machines/victimlaunch/m1", mint("attackerlaunch")); got != "" {
t.Errorf("the address's org resolved to %q", got)
}
for _, path := range []string{"/v1/machines", "/v1/machines?owner=attackerlaunch", "/v1/machines/attackerlaunch/m1"} {
if got := get(t, app, path, mint("attackerlaunch")); got != "attackerlaunch" {
t.Errorf("%s resolved to %q, want the caller's own org", path, got)
}
}
// The control, and the actual contract: with no token there is nothing to
// override the address, so the address resolves. That is why an
// unauthenticated request must never reach a handler — routers/health_test.go
// is what holds that end.
if got := get(t, app, "/v1/machines?owner=victimlaunch", ""); got != "victimlaunch" {
t.Errorf("without a token the address must resolve, got %q", got)
}
}
// Every volume WRITE takes its tenant from the token's membership too. Addressed
// at an org the caller is not a member of, each is refused before any provider
// is looked up; addressed at its own org, each fails on the missing provider,
// named in the caller's org.
func TestVolumeWritesUseTheSignedOrgNotTheQuery(t *testing.T) {
mint := signer(t, "https://test.id")
app := tenantWire(t)
for name, tc := range map[string]struct{ method, path string }{
"create": {http.MethodPost, "/v1/volumes?owner=%s&provider=platformdo"},
"delete": {http.MethodDelete, "/v1/volumes/%s/vol-1?provider=platformdo"},
"attach": {http.MethodPut, "/v1/volumes/%s/vol-1/attachment?provider=platformdo&machine=m-1"},
"detach": {http.MethodDelete, "/v1/volumes/%s/vol-1/attachment?provider=platformdo"},
"resize": {http.MethodPut, "/v1/volumes/%s/vol-1/size?provider=platformdo&size=200"},
} {
t.Run(name, func(t *testing.T) {
_, body := ask(t, app, tc.method, fmt.Sprintf(tc.path, "victimvol"), mint("attackervol"), `{"owner":"attackervol","name":"vol-1","sizeGb":100}`)
if !strings.Contains(body, refuseNoOrg) || strings.Contains(body, "victimvol") {
t.Fatalf("%s addressed at another org was not refused: %s", name, body)
}
_, body = ask(t, app, tc.method, fmt.Sprintf(tc.path, "attackervol"), mint("attackervol"), `{"owner":"attackervol","name":"vol-1","sizeGb":100}`)
if !strings.Contains(body, "attackervol") {
t.Fatalf("%s must resolve the provider in the CALLER's org, got %s", name, body)
}
})
}
}
// The volume READS are scoped to the caller as well: another org's list or
// volume is refused, and the caller's own are served.
func TestVolumeReadsAreScopedToTheCaller(t *testing.T) {
mint := signer(t, "https://test.id")
app := tenantWire(t)
storedVolume(t, "victimvolread", "secret-disk")
storedVolume(t, "attackervolread", "own-disk")
list := get(t, app, "/v1/volumes?owner=victimvolread", mint("attackervolread"))
if strings.Contains(list, "secret-disk") || strings.Contains(list, "victimvolread") {
t.Fatalf("another org's volumes were listed: %s", list)
}
for _, path := range []string{"/v1/volumes", "/v1/volumes?owner=attackervolread"} {
if own := get(t, app, path, mint("attackervolread")); !strings.Contains(own, "own-disk") {
t.Fatalf("the caller must still see its OWN volumes at %s: %s", path, own)
}
}
one := get(t, app, "/v1/volumes/victimvolread/secret-disk", mint("attackervolread"))
if strings.Contains(one, "secret-disk") || strings.Contains(one, "victimvolread") {
t.Fatalf("another org's volume was readable: %s", one)
}
}
// No bearer and no service credential means no tenant, and a tenant-less
// provision is a configured cloud account waiting to happen.
func TestMachineAndVolumeWritesFailClosedWithoutAnOrg(t *testing.T) {
app := tenantWire(t)
for name, tc := range map[string]struct{ method, path, body string }{
"launch": {http.MethodPost, "/v1/machines?provider=do", `{"name":"m1"}`},
"create": {http.MethodPost, "/v1/volumes?provider=do", `{"name":"v1","sizeGb":10}`},
} {
t.Run(name, func(t *testing.T) {
_, body := ask(t, app, tc.method, tc.path, "", tc.body)
if !strings.Contains(body, refuseNoOrg) {
t.Fatalf("a tenant-less %s must be refused, got %s", name, body)
}
})
}
// Only the collection. An item address NAMES its org, so "no org context" is
// not a state it can be in — what must be refused there is reading ANOTHER
// org's item, which is the test above and the filter in front of it.
for name, path := range map[string]string{
"list": "/v1/volumes",
} {
t.Run(name, func(t *testing.T) {
if body := get(t, app, path, ""); !strings.Contains(body, refuseNoOrg) {
t.Fatalf("a tenant-less %s must be refused, got %s", name, body)
}
})
}
}