package membership_test // Regression for audit report 0008, vector N2: with the broad "$JS.API.>" grant // removed (issue 0006b), a registered peer that belongs to no room can no longer // read the control-plane KV buckets over NATS, while the per-room JetStream API of // a peer's OWN rooms keeps working. The auditor's ephemeral attack populated the // KV control plane and had a registered non-member harvest the allowlist, the room // graph and the sealed-key metadata directly through "$JS.API.>". import ( "context" "encoding/hex" "path/filepath" "testing" "time" "github.com/enmanuel/unibus/pkg/busauth" "github.com/enmanuel/unibus/pkg/embeddednats" "github.com/enmanuel/unibus/pkg/frame" "github.com/enmanuel/unibus/pkg/membership" "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" server "github.com/nats-io/nats-server/v2/server" ) // startACLNatsInternal is startACLNats plus a recognized internal service identity // (so the test can seed the KV control plane with full permissions, exactly as the // decentralized membershipd does at bootstrap). func startACLNatsInternal(t *testing.T, store membership.Store, internalPubHex string) *server.Server { t.Helper() auth := busauth.NewNkeyAuthenticatorACLInternal(store.IsAuthorized, aclPermsFunc(store), internalPubHex) ns, err := embeddednats.StartServer(embeddednats.ServerConfig{ StoreDir: t.TempDir(), Host: "127.0.0.1", Port: aclFreePort(t), Auth: auth, }) if err != nil { t.Fatalf("acl nats: %v", err) } t.Cleanup(func() { ns.Shutdown(); ns.WaitForShutdown() }) return ns } // TestAttack0008_N2 reproduces the control-plane KV leak and proves it is closed. // // error : eve (registered, member of no room) cannot read the KV buckets — the // JetStream KV API and the raw $KV subject space are both denied. // golden: the owner of a persisted room can still drive the JetStream API of HER // OWN room's stream (so persisted-room history keeps working). // edge : eve cannot reach another room's stream API either (cross-room JS deny). func TestAttack0008_N2(t *testing.T) { dir := t.TempDir() // The HTTP control-plane store stays SQLite; the KV buckets below stand in for // the decentralized control plane the attack targets. store, err := membership.Open(filepath.Join(dir, "unibus.db")) if err != nil { t.Fatalf("store: %v", err) } t.Cleanup(func() { store.Close() }) ceo, eve, internalID := mustID(t), mustID(t), mustID(t) ceoEP := frame.EndpointID(ceo.SignPub) mustAddUser(t, store, ceo, "ceo-root-admin") mustAddUser(t, store, eve, "eve") // registered, member of nothing // A persisted room owned by ceo: ceo is a member, so her per-room JS is allowed. if err := store.CreateRoom( membership.RoomInfo{RoomID: "PRIVROOM", Subject: "room.board.ma-deal", Encrypt: true, Persist: true, OwnerEndpoint: ceoEP}, ceo.SignPub, ceo.KexPub, []byte("sealed-self"), ); err != nil { t.Fatalf("create room: %v", err) } internalPubHex := hex.EncodeToString(internalID.SignPub) ns := startACLNatsInternal(t, store, internalPubHex) url := ns.ClientURL() // Seed the KV control plane with the privileged internal identity (full perms), // simulating the decentralized buckets the attack reads. intErr := make(chan error, 4) intNC := nkeyConn(t, url, internalID, intErr) intJS, err := jetstream.New(intNC) if err != nil { t.Fatalf("internal jetstream: %v", err) } kvStore, err := membership.OpenJetStream(intJS, membership.JetStreamConfig{Replicas: 1, OpTimeout: 3 * time.Second}) if err != nil { t.Fatalf("open kv buckets: %v", err) } if err := kvStore.AddUser(hex.EncodeToString(ceo.SignPub), "ceo-root-admin", membership.RoleAdmin); err != nil { t.Fatalf("seed kv user: %v", err) } // Each JetStream op gets its own short context: a DENIED request never gets a // reply, so it blocks until its own deadline — a shared context would be // exhausted by the first denied call and starve the rest. freshCtx := func(d time.Duration) (context.Context, context.CancelFunc) { return context.WithTimeout(context.Background(), d) } // --- error: eve cannot read the control-plane KV buckets ------------------ eveErr := make(chan error, 8) eveNC := nkeyConn(t, url, eve, eveErr) eveJS, err := jetstream.New(eveNC) if err != nil { t.Fatalf("eve jetstream: %v", err) } // (a) The KV API: binding the bucket requires STREAM.INFO.KV_UNIBUS_users, which // eve has no permission for, so this must fail (no leak of users). kvCtx, kvCancel := freshCtx(2 * time.Second) if kv, err := eveJS.KeyValue(kvCtx, "UNIBUS_users"); err == nil { if e, gerr := kv.Get(kvCtx, hex.EncodeToString(ceo.SignPub)); gerr == nil { kvCancel() t.Fatalf("eve read the control-plane KV users bucket: %q (N2 leak still open)", string(e.Value())) } kvCancel() t.Fatalf("eve was able to BIND the KV users bucket (N2 leak still open)") } kvCancel() // (b) The raw KV subject space: a direct subscribe must be a permissions // violation (delivered async to the error handler). drain(eveErr) if _, err := eveNC.Subscribe("$KV.UNIBUS_users.>", func(*nats.Msg) {}); err != nil { t.Fatalf("eve sub $KV: %v", err) } _ = eveNC.Flush() if e := waitErr(eveErr, 1*time.Second); e == nil { t.Fatalf("eve subscribing to $KV.UNIBUS_users.> must raise a permissions violation") } // --- edge: eve cannot reach another room's stream API --------------------- edgeCtx, edgeCancel := freshCtx(2 * time.Second) if _, err := eveJS.Stream(edgeCtx, "UNIBUS_PRIVROOM"); err == nil { edgeCancel() t.Fatalf("eve reached the foreign room stream API (cross-room JS not isolated)") } edgeCancel() // --- golden: ceo can drive the JetStream API of HER OWN room's stream ------ ceoErr := make(chan error, 4) ceoNC := nkeyConn(t, url, ceo, ceoErr) ceoJS, err := jetstream.New(ceoNC) if err != nil { t.Fatalf("ceo jetstream: %v", err) } goldenCtx, goldenCancel := freshCtx(5 * time.Second) defer goldenCancel() if _, err := ceoJS.CreateOrUpdateStream(goldenCtx, jetstream.StreamConfig{ Name: "UNIBUS_PRIVROOM", Subjects: []string{"room.board.ma-deal"}, Storage: jetstream.FileStorage, }); err != nil { t.Fatalf("ceo could not manage her OWN room stream (per-room JS broken): %v", err) } }