event stream: fix wildcard namespace bypass (#25089)

This commit is contained in:
Michael Smithhisler
2025-02-11 11:06:29 -05:00
committed by GitHub
parent 92c90af542
commit c4f232f23e
7 changed files with 148 additions and 85 deletions

3
.changelog/25089.txt Normal file
View File

@@ -0,0 +1,3 @@
```release-note:security
event stream: fixes vulnerability CVE-2025-0937, where using a wildcard namespace to subscribe to the events API grants a user with "read" capabilites on any namespace, the ability to read events from all namespaces.
```

View File

@@ -41,6 +41,10 @@ func (e *Event) stream(conn io.ReadWriteCloser) {
} }
authErr := e.srv.Authenticate(nil, &args) authErr := e.srv.Authenticate(nil, &args)
if authErr != nil {
handleJsonResultError(structs.ErrPermissionDenied, pointer.Of(int64(403)), encoder)
return
}
// forward to appropriate region // forward to appropriate region
if args.Region != e.srv.config.Region { if args.Region != e.srv.config.Region {
@@ -52,16 +56,27 @@ func (e *Event) stream(conn io.ReadWriteCloser) {
} }
e.srv.MeasureRPCRate("event", structs.RateMetricRead, &args) e.srv.MeasureRPCRate("event", structs.RateMetricRead, &args)
if authErr != nil {
resolvedACL, err := e.srv.ResolveACL(&args)
if err != nil {
handleJsonResultError(structs.ErrPermissionDenied, pointer.Of(int64(403)), encoder) handleJsonResultError(structs.ErrPermissionDenied, pointer.Of(int64(403)), encoder)
return
}
validatedNses, err := e.validateACL(args.Namespace, args.Topics, resolvedACL)
if err != nil {
handleJsonResultError(structs.ErrPermissionDenied, pointer.Of(int64(403)), encoder)
return
} }
// Generate the subscription request // Generate the subscription request
subReq := &stream.SubscribeRequest{ subReq := &stream.SubscribeRequest{
Token: args.AuthToken, Token: args.AuthToken,
Topics: args.Topics, Topics: args.Topics,
Index: uint64(args.Index), Index: uint64(args.Index),
Namespace: args.Namespace, // Namespaces is set once, in the event a users ACL is updated to include
// more NSes, the current event stream will not include the new NSes.
Namespaces: validatedNses,
Authenticate: func() error { Authenticate: func() error {
if err := e.srv.Authenticate(nil, &args); err != nil { if err := e.srv.Authenticate(nil, &args); err != nil {
return err return err
@@ -70,7 +85,8 @@ func (e *Event) stream(conn io.ReadWriteCloser) {
if err != nil { if err != nil {
return err return err
} }
return validateACL(args.Namespace, args.Topics, resolvedACL) _, err = e.validateACL(args.Namespace, args.Topics, resolvedACL)
return err
}, },
} }
@@ -225,7 +241,26 @@ func handleJsonResultError(err error, code *int64, encoder *codec.Encoder) {
}) })
} }
func validateACL(namespace string, topics map[structs.Topic][]string, aclObj *acl.ACL) error { // validateACL handles wildcard namespaces by replacing it with all existing namespaces
// and validates the user has the appropriate ACL to read topics in each one.
func (e *Event) validateACL(namespace string, topics map[structs.Topic][]string, resolvedAcl *acl.ACL) ([]string, error) {
nses := []string{}
if namespace == structs.AllNamespacesSentinel {
ns, _ := e.srv.State().NamespaceNames()
nses = append(nses, ns...)
} else {
nses = append(nses, namespace)
}
for _, ns := range nses {
if err := validateNsOp(ns, topics, resolvedAcl); err != nil {
return nil, err
}
}
return nses, nil
}
func validateNsOp(namespace string, topics map[structs.Topic][]string, aclObj *acl.ACL) error {
for topic := range topics { for topic := range topics {
switch topic { switch topic {
case structs.TopicDeployment, case structs.TopicDeployment,

View File

@@ -312,7 +312,7 @@ OUTER:
} }
} }
func TestEventStream_validateACL(t *testing.T) { func TestEventStream_validateNsOp(t *testing.T) {
ci.Parallel(t) ci.Parallel(t)
require := require.New(t) require := require.New(t)
@@ -480,12 +480,74 @@ func TestEventStream_validateACL(t *testing.T) {
testACL, err := acl.NewACL(tc.Management, []*acl.Policy{p}) testACL, err := acl.NewACL(tc.Management, []*acl.Policy{p})
require.NoError(err) require.NoError(err)
err = validateACL(tc.Namespace, tc.Topics, testACL) err = validateNsOp(tc.Namespace, tc.Topics, testACL)
require.Equal(tc.ExpectedErr, err) require.Equal(tc.ExpectedErr, err)
}) })
} }
} }
func TestEventStream_validateACL(t *testing.T) {
ci.Parallel(t)
s1, _, cleanupS := TestACLServer(t, nil)
defer cleanupS()
testutil.WaitForLeader(t, s1.RPC)
ns1 := mock.Namespace()
err := s1.State().UpsertNamespaces(0, []*structs.Namespace{ns1})
must.NoError(t, err)
testEvent := &Event{srv: s1}
t.Run("single namespace ACL errors on wildcard", func(t *testing.T) {
policy, err := acl.Parse(mock.NamespacePolicy(ns1.Name, "", []string{acl.NamespaceCapabilityReadJob}))
must.NoError(t, err)
// does not contain policy for default NS
testAcl, err := acl.NewACL(false, []*acl.Policy{policy})
must.NoError(t, err)
topics := map[structs.Topic][]string{
structs.TopicJob: {"*"},
}
_, err = testEvent.validateACL("*", topics, testAcl)
must.Error(t, err)
})
t.Run("all namespace ACL succeeds on wildcard", func(t *testing.T) {
policy1, err := acl.Parse(mock.NamespacePolicy("default", "", []string{acl.NamespaceCapabilityReadJob}))
must.NoError(t, err)
policy2, err := acl.Parse(mock.NamespacePolicy(ns1.Name, "", []string{acl.NamespaceCapabilityReadJob}))
must.NoError(t, err)
testAcl, err := acl.NewACL(false, []*acl.Policy{policy1, policy2})
must.NoError(t, err)
topics := map[structs.Topic][]string{
structs.TopicJob: {"*"},
}
nses, err := testEvent.validateACL("*", topics, testAcl)
must.NoError(t, err)
must.Eq(t, nses, []string{"default", ns1.Name})
})
t.Run("single namespace ACL succeeds with correct NS", func(t *testing.T) {
policy, err := acl.Parse(mock.NamespacePolicy("default", "", []string{acl.NamespaceCapabilityReadJob}))
must.NoError(t, err)
testAcl, err := acl.NewACL(false, []*acl.Policy{policy})
must.NoError(t, err)
topics := map[structs.Topic][]string{
structs.TopicJob: {"*"},
}
nses, err := testEvent.validateACL("default", topics, testAcl)
must.NoError(t, err)
must.Eq(t, nses, []string{"default"})
})
}
// TestEventStream_ACL_Update_Close_Stream asserts that an active subscription // TestEventStream_ACL_Update_Close_Stream asserts that an active subscription
// is closed after the token is no longer valid // is closed after the token is no longer valid
func TestEventStream_ACL_Update_Close_Stream(t *testing.T) { func TestEventStream_ACL_Update_Close_Stream(t *testing.T) {

View File

@@ -3676,7 +3676,7 @@ func TestFSM_ACLEvents(t *testing.T) {
Topics: map[structs.Topic][]string{ Topics: map[structs.Topic][]string{
tc.reqTopic: {"*"}, tc.reqTopic: {"*"},
}, },
Namespace: "default", Namespaces: []string{"default"},
} }
sub, err := broker.Subscribe(subReq) sub, err := broker.Subscribe(subReq)
@@ -3730,7 +3730,7 @@ func TestFSM_EventBroker_JobRegisterFSMEvents(t *testing.T) {
Topics: map[structs.Topic][]string{ Topics: map[structs.Topic][]string{
structs.TopicJob: {"*"}, structs.TopicJob: {"*"},
}, },
Namespace: "default", Namespaces: []string{"default"},
} }
sub, err := broker.Subscribe(subReq) sub, err := broker.Subscribe(subReq)

View File

@@ -97,7 +97,7 @@ func EventsForIndex(t *testing.T, s *StateStore, index uint64) []structs.Event {
Topics: map[structs.Topic][]string{ Topics: map[structs.Topic][]string{
"*": {"*"}, "*": {"*"},
}, },
Namespace: "default", Namespaces: []string{"default"},
Index: index, Index: index,
StartExactlyAtIndex: true, StartExactlyAtIndex: true,
}) })

View File

@@ -6,6 +6,7 @@ package stream
import ( import (
"context" "context"
"errors" "errors"
"slices"
"sync/atomic" "sync/atomic"
"github.com/hashicorp/nomad/nomad/structs" "github.com/hashicorp/nomad/nomad/structs"
@@ -48,9 +49,9 @@ type Subscription struct {
} }
type SubscribeRequest struct { type SubscribeRequest struct {
Token string Token string
Index uint64 Index uint64
Namespace string Namespaces []string
Topics map[structs.Topic][]string Topics map[structs.Topic][]string
@@ -130,15 +131,10 @@ func filter(req *SubscribeRequest, events []structs.Event) []structs.Event {
allTopicKeys := req.Topics[structs.TopicAll] allTopicKeys := req.Topics[structs.TopicAll]
// Return all events if subscribed to all namespaces and all topics
if req.Namespace == "*" && len(allTopicKeys) == 1 && allTopicKeys[0] == string(structs.TopicAll) {
return events
}
var result []structs.Event var result []structs.Event
for _, event := range events { for _, event := range events {
if req.Namespace != "*" && event.Namespace != "" && event.Namespace != req.Namespace { if event.Namespace != "" && !slices.Contains(req.Namespaces, event.Namespace) {
continue continue
} }

View File

@@ -44,8 +44,10 @@ func TestFilter_AllKeys(t *testing.T) {
func TestFilter_PartialMatch_Topic(t *testing.T) { func TestFilter_PartialMatch_Topic(t *testing.T) {
ci.Parallel(t) ci.Parallel(t)
events := make([]structs.Event, 0, 5) event1 := structs.Event{Topic: "Test", Key: "One"}
events = append(events, structs.Event{Topic: "Test", Key: "One"}, structs.Event{Topic: "Test", Key: "Two"}, structs.Event{Topic: "Exclude", Key: "Two"}) event2 := structs.Event{Topic: "Test", Key: "Two"}
event3 := structs.Event{Topic: "Exclude", Key: "Two"}
events := []structs.Event{event1, event2, event3}
req := &SubscribeRequest{ req := &SubscribeRequest{
Topics: map[structs.Topic][]string{ Topics: map[structs.Topic][]string{
@@ -53,7 +55,7 @@ func TestFilter_PartialMatch_Topic(t *testing.T) {
}, },
} }
actual := filter(req, events) actual := filter(req, events)
expected := []structs.Event{{Topic: "Test", Key: "One"}, {Topic: "Test", Key: "Two"}} expected := []structs.Event{event1, event2}
require.Equal(t, expected, actual) require.Equal(t, expected, actual)
require.Equal(t, 2, cap(actual)) require.Equal(t, 2, cap(actual))
@@ -62,11 +64,10 @@ func TestFilter_PartialMatch_Topic(t *testing.T) {
func TestFilter_Match_TopicAll_SpecificKey(t *testing.T) { func TestFilter_Match_TopicAll_SpecificKey(t *testing.T) {
ci.Parallel(t) ci.Parallel(t)
events := []structs.Event{ event1 := structs.Event{Topic: "Match", Key: "Two"}
{Topic: "Match", Key: "Two"}, event2 := structs.Event{Topic: "NoMatch", Key: "One"}
{Topic: "NoMatch", Key: "One"}, event3 := structs.Event{Topic: "OtherMatch", Key: "Two"}
{Topic: "OtherMatch", Key: "Two"}, events := []structs.Event{event1, event2, event3}
}
req := &SubscribeRequest{ req := &SubscribeRequest{
Topics: map[structs.Topic][]string{ Topics: map[structs.Topic][]string{
@@ -75,21 +76,17 @@ func TestFilter_Match_TopicAll_SpecificKey(t *testing.T) {
} }
actual := filter(req, events) actual := filter(req, events)
expected := []structs.Event{ expected := []structs.Event{event1, event3}
{Topic: "Match", Key: "Two"},
{Topic: "OtherMatch", Key: "Two"},
}
require.Equal(t, expected, actual) require.Equal(t, expected, actual)
} }
func TestFilter_Match_TopicAll_SpecificKey_Plus(t *testing.T) { func TestFilter_Match_TopicAll_SpecificKey_Plus(t *testing.T) {
ci.Parallel(t) ci.Parallel(t)
events := []structs.Event{ event1 := structs.Event{Topic: "FirstTwo", Key: "Two"}
{Topic: "FirstTwo", Key: "Two"}, event2 := structs.Event{Topic: "Test", Key: "One"}
{Topic: "Test", Key: "One"}, event3 := structs.Event{Topic: "SecondTwo", Key: "Two"}
{Topic: "SecondTwo", Key: "Two"}, events := []structs.Event{event1, event2, event3}
}
req := &SubscribeRequest{ req := &SubscribeRequest{
Topics: map[structs.Topic][]string{ Topics: map[structs.Topic][]string{
@@ -99,19 +96,16 @@ func TestFilter_Match_TopicAll_SpecificKey_Plus(t *testing.T) {
} }
actual := filter(req, events) actual := filter(req, events)
expected := []structs.Event{ expected := []structs.Event{event1, event2, event3}
{Topic: "FirstTwo", Key: "Two"},
{Topic: "Test", Key: "One"},
{Topic: "SecondTwo", Key: "Two"},
}
require.Equal(t, expected, actual) require.Equal(t, expected, actual)
} }
func TestFilter_PartialMatch_Key(t *testing.T) { func TestFilter_PartialMatch_Key(t *testing.T) {
ci.Parallel(t) ci.Parallel(t)
events := make([]structs.Event, 0, 5) event1 := structs.Event{Topic: "Test", Key: "One"}
events = append(events, structs.Event{Topic: "Test", Key: "One"}, structs.Event{Topic: "Test", Key: "Two"}) event2 := structs.Event{Topic: "Test", Key: "Two"}
events := []structs.Event{event1, event2}
req := &SubscribeRequest{ req := &SubscribeRequest{
Topics: map[structs.Topic][]string{ Topics: map[structs.Topic][]string{
@@ -119,7 +113,7 @@ func TestFilter_PartialMatch_Key(t *testing.T) {
}, },
} }
actual := filter(req, events) actual := filter(req, events)
expected := []structs.Event{{Topic: "Test", Key: "One"}} expected := []structs.Event{event1}
require.Equal(t, expected, actual) require.Equal(t, expected, actual)
require.Equal(t, 1, cap(actual)) require.Equal(t, 1, cap(actual))
@@ -147,66 +141,39 @@ func TestFilter_NoMatch(t *testing.T) {
func TestFilter_Namespace(t *testing.T) { func TestFilter_Namespace(t *testing.T) {
ci.Parallel(t) ci.Parallel(t)
events := make([]structs.Event, 0, 5) event1 := structs.Event{Topic: "Test", Key: "One", Namespace: "foo"}
events = append(events, structs.Event{Topic: "Test", Key: "One", Namespace: "foo"}, structs.Event{Topic: "Test", Key: "Two"}, structs.Event{Topic: "Test", Key: "Two", Namespace: "bar"}) event2 := structs.Event{Topic: "Test", Key: "Two", Namespace: "foo"}
event3 := structs.Event{Topic: "Test", Key: "Two", Namespace: "bar"}
events := []structs.Event{event1, event2, event3}
req := &SubscribeRequest{ req := &SubscribeRequest{
Topics: map[structs.Topic][]string{ Topics: map[structs.Topic][]string{
"*": {"*"}, "*": {"*"},
}, },
Namespace: "foo", Namespaces: []string{"foo"},
} }
actual := filter(req, events) actual := filter(req, events)
expected := []structs.Event{ // expect namespace "bar" to be filtered out
{Topic: "Test", Key: "One", Namespace: "foo"}, expected := []structs.Event{event1, event2}
{Topic: "Test", Key: "Two"},
}
require.Equal(t, expected, actual) require.Equal(t, expected, actual)
require.Equal(t, 2, cap(actual)) require.Equal(t, 2, cap(actual))
} }
func TestFilter_NamespaceAll(t *testing.T) {
ci.Parallel(t)
events := make([]structs.Event, 0, 5)
events = append(events,
structs.Event{Topic: "Test", Key: "One", Namespace: "foo"},
structs.Event{Topic: "Test", Key: "Two", Namespace: "bar"},
structs.Event{Topic: "Test", Key: "Three", Namespace: "default"},
)
req := &SubscribeRequest{
Topics: map[structs.Topic][]string{
"*": {"*"},
},
Namespace: "*",
}
actual := filter(req, events)
expected := []structs.Event{
{Topic: "Test", Key: "One", Namespace: "foo"},
{Topic: "Test", Key: "Two", Namespace: "bar"},
{Topic: "Test", Key: "Three", Namespace: "default"},
}
require.Equal(t, expected, actual)
}
func TestFilter_FilterKeys(t *testing.T) { func TestFilter_FilterKeys(t *testing.T) {
ci.Parallel(t) ci.Parallel(t)
events := make([]structs.Event, 0, 5) event1 := structs.Event{Topic: "Test", Key: "One", FilterKeys: []string{"extra-key"}}
events = append(events, structs.Event{Topic: "Test", Key: "One", FilterKeys: []string{"extra-key"}}, structs.Event{Topic: "Test", Key: "Two"}, structs.Event{Topic: "Test", Key: "Two"}) event2 := structs.Event{Topic: "Test", Key: "Two"}
event3 := structs.Event{Topic: "Test", Key: "Two"}
events := []structs.Event{event1, event2, event3}
req := &SubscribeRequest{ req := &SubscribeRequest{
Topics: map[structs.Topic][]string{ Topics: map[structs.Topic][]string{
"Test": {"extra-key"}, "Test": {"extra-key"},
}, },
Namespace: "foo",
} }
actual := filter(req, events) actual := filter(req, events)
expected := []structs.Event{ expected := []structs.Event{event1}
{Topic: "Test", Key: "One", FilterKeys: []string{"extra-key"}},
}
require.Equal(t, expected, actual) require.Equal(t, expected, actual)
require.Equal(t, 1, cap(actual)) require.Equal(t, 1, cap(actual))