Skip to content

Commit 3b24f26

Browse files
committed
variables: fix filter on List RPC
The List RPC correctly authorized against the prefix argument. But when filtering results underneath the prefix, it only checked authorization for standard ACL tokens and not Workload Identity. This results in WI tokens being able to read List results (metadata only: variable paths and timestamps) for variables under the `nomad/` prefix that belong to other jobs in the same namespace. Fixes the filtering and split the `handleMixedAuthEndpoint` function into separate authentication and authorization steps so that we don't need to re-verify the claim token on each filtered object. Also includes: * update semgrep rule for mixed auth endpoints * variables: List returns empty set when all results are filtered
1 parent dd6a463 commit 3b24f26

File tree

4 files changed

+287
-97
lines changed

4 files changed

+287
-97
lines changed

.changelog/15012.txt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
```security
2+
variables: Fixed a bug where non-sensitive variable metadata (paths and raft indexes) was exposed via the template `nomadVarList` function to other jobs in the same namespace.
3+
```
4+
5+
```bug
6+
variables: Fixed a bug where getting empty results from listing variables resulted in a permission denied error.
7+
```

.semgrep/rpc_endpoint.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,15 @@ rules:
4545
...
4646
... := $T.handleMixedAuthEndpoint(...)
4747
...
48+
# Pattern used by endpoints that support both normal ACLs and
49+
# workload identity but break authentication and authorization up
50+
- pattern-not-inside: |
51+
if done, err := $A.$B.forward($METHOD, ...); done {
52+
return err
53+
}
54+
...
55+
... := $T.authorize(...)
56+
...
4857
# Pattern used by some Node endpoints.
4958
- pattern-not-inside: |
5059
if done, err := $A.$B.forward($METHOD, ...); done {

nomad/variables_endpoint.go

Lines changed: 72 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ func (sv *Variables) Read(args *structs.VariablesReadRequest, reply *structs.Var
218218
}
219219
defer metrics.MeasureSince([]string{"nomad", "variables", "read"}, time.Now())
220220

221-
_, err := sv.handleMixedAuthEndpoint(args.QueryOptions,
221+
_, _, err := sv.handleMixedAuthEndpoint(args.QueryOptions,
222222
acl.PolicyRead, args.Path)
223223
if err != nil {
224224
return err
@@ -269,8 +269,7 @@ func (sv *Variables) List(
269269
return sv.listAllVariables(args, reply)
270270
}
271271

272-
aclObj, err := sv.handleMixedAuthEndpoint(args.QueryOptions,
273-
acl.PolicyList, args.Prefix)
272+
aclObj, claims, err := sv.authenticate(args.QueryOptions)
274273
if err != nil {
275274
return err
276275
}
@@ -299,9 +298,12 @@ func (sv *Variables) List(
299298
filters := []paginator.Filter{
300299
paginator.GenericFilter{
301300
Allow: func(raw interface{}) (bool, error) {
302-
sv := raw.(*structs.VariableEncrypted)
303-
return strings.HasPrefix(sv.Path, args.Prefix) &&
304-
(aclObj == nil || aclObj.AllowVariableOperation(sv.Namespace, sv.Path, acl.PolicyList)), nil
301+
v := raw.(*structs.VariableEncrypted)
302+
if !strings.HasPrefix(v.Path, args.Prefix) {
303+
return false, nil
304+
}
305+
err := sv.authorize(aclObj, claims, v.Namespace, acl.PolicyList, v.Path)
306+
return err == nil, nil
305307
},
306308
},
307309
}
@@ -345,43 +347,23 @@ func (sv *Variables) List(
345347

346348
// listAllVariables is used to list variables held within
347349
// state where the caller has used the namespace wildcard identifier.
348-
func (s *Variables) listAllVariables(
350+
func (sv *Variables) listAllVariables(
349351
args *structs.VariablesListRequest,
350352
reply *structs.VariablesListResponse) error {
351353

352354
// Perform token resolution. The request already goes through forwarding
353355
// and metrics setup before being called.
354-
aclObj, err := s.srv.ResolveToken(args.AuthToken)
356+
aclObj, claims, err := sv.authenticate(args.QueryOptions)
355357
if err != nil {
356358
return err
357359
}
358360

359-
// allowFunc checks whether the caller has the read-job capability on the
360-
// passed namespace.
361-
allowFunc := func(ns string) bool {
362-
return aclObj.AllowVariableOperation(ns, "", acl.PolicyList)
363-
}
364-
365361
// Set up and return the blocking query.
366-
return s.srv.blockingRPC(&blockingOptions{
362+
return sv.srv.blockingRPC(&blockingOptions{
367363
queryOpts: &args.QueryOptions,
368364
queryMeta: &reply.QueryMeta,
369365
run: func(ws memdb.WatchSet, stateStore *state.StateStore) error {
370366

371-
// Identify which namespaces the caller has access to. If they do
372-
// not have access to any, send them an empty response. Otherwise,
373-
// handle any error in a traditional manner.
374-
_, err := allowedNSes(aclObj, stateStore, allowFunc)
375-
switch err {
376-
case structs.ErrPermissionDenied:
377-
reply.Data = make([]*structs.VariableMetadata, 0)
378-
return nil
379-
case nil:
380-
// Fallthrough.
381-
default:
382-
return err
383-
}
384-
385367
// Get all the variables stored within state.
386368
iter, err := stateStore.Variables(ws)
387369
if err != nil {
@@ -396,15 +378,17 @@ func (s *Variables) listAllVariables(
396378
paginator.StructsTokenizerOptions{
397379
WithNamespace: true,
398380
WithID: true,
399-
},
400-
)
381+
})
401382

402383
filters := []paginator.Filter{
403384
paginator.GenericFilter{
404385
Allow: func(raw interface{}) (bool, error) {
405-
sv := raw.(*structs.VariableEncrypted)
406-
return strings.HasPrefix(sv.Path, args.Prefix) &&
407-
(aclObj == nil || aclObj.AllowVariableOperation(sv.Namespace, sv.Path, acl.PolicyList)), nil
386+
v := raw.(*structs.VariableEncrypted)
387+
if !strings.HasPrefix(v.Path, args.Prefix) {
388+
return false, nil
389+
}
390+
err := sv.authorize(aclObj, claims, v.Namespace, acl.PolicyList, v.Path)
391+
return err == nil, nil
408392
},
409393
},
410394
}
@@ -413,8 +397,8 @@ func (s *Variables) listAllVariables(
413397
// responsible for appending a variable to the stubs array.
414398
paginatorImpl, err := paginator.NewPaginator(iter, tokenizer, filters, args.QueryOptions,
415399
func(raw interface{}) error {
416-
sv := raw.(*structs.VariableEncrypted)
417-
svStub := sv.VariableMetadata
400+
v := raw.(*structs.VariableEncrypted)
401+
svStub := v.VariableMetadata
418402
svs = append(svs, &svStub)
419403
return nil
420404
})
@@ -437,7 +421,7 @@ func (s *Variables) listAllVariables(
437421

438422
// Use the index table to populate the query meta as we have no way
439423
// of tracking the max index on deletes.
440-
return s.srv.setReplyQueryMeta(stateStore, state.TableVariables, &reply.QueryMeta)
424+
return sv.srv.setReplyQueryMeta(stateStore, state.TableVariables, &reply.QueryMeta)
441425
},
442426
})
443427
}
@@ -475,24 +459,31 @@ func (sv *Variables) decrypt(v *structs.VariableEncrypted) (*structs.VariableDec
475459

476460
// handleMixedAuthEndpoint is a helper to handle auth on RPC endpoints that can
477461
// either be called by external clients or by workload identity
478-
func (sv *Variables) handleMixedAuthEndpoint(args structs.QueryOptions, cap, pathOrPrefix string) (*acl.ACL, error) {
462+
func (sv *Variables) handleMixedAuthEndpoint(args structs.QueryOptions, cap, pathOrPrefix string) (*acl.ACL, *structs.IdentityClaims, error) {
463+
464+
aclObj, claims, err := sv.authenticate(args)
465+
if err != nil {
466+
return aclObj, claims, err
467+
}
468+
err = sv.authorize(aclObj, claims, args.RequestNamespace(), cap, pathOrPrefix)
469+
if err != nil {
470+
return aclObj, claims, err
471+
}
472+
473+
return aclObj, claims, nil
474+
}
475+
476+
func (sv *Variables) authenticate(args structs.QueryOptions) (*acl.ACL, *structs.IdentityClaims, error) {
479477

480478
// Perform the initial token resolution.
481479
aclObj, err := sv.srv.ResolveToken(args.AuthToken)
482480
if err == nil {
483-
// Perform our ACL validation. If the object is nil, this means ACLs
484-
// are not enabled, otherwise trigger the allowed namespace function.
485-
if aclObj != nil {
486-
if !aclObj.AllowVariableOperation(args.RequestNamespace(), pathOrPrefix, cap) {
487-
return nil, structs.ErrPermissionDenied
488-
}
489-
}
490-
return aclObj, nil
481+
return aclObj, nil, nil
491482
}
492483
if helper.IsUUID(args.AuthToken) {
493484
// early return for ErrNotFound or other errors if it's formed
494485
// like an ACLToken.SecretID
495-
return nil, err
486+
return nil, nil, err
496487
}
497488

498489
// Attempt to verify the token as a JWT with a workload
@@ -502,27 +493,46 @@ func (sv *Variables) handleMixedAuthEndpoint(args structs.QueryOptions, cap, pat
502493
metrics.IncrCounter([]string{
503494
"nomad", "variables", "invalid_allocation_identity"}, 1)
504495
sv.logger.Trace("allocation identity was not valid", "error", err)
505-
return nil, structs.ErrPermissionDenied
496+
return nil, nil, structs.ErrPermissionDenied
506497
}
498+
return nil, claims, nil
499+
}
507500

508-
// The workload identity gets access to paths that match its
509-
// identity, without having to go thru the ACL system
510-
err = sv.authValidatePrefix(claims, args.RequestNamespace(), pathOrPrefix)
511-
if err == nil {
512-
return aclObj, nil
501+
func (sv *Variables) authorize(aclObj *acl.ACL, claims *structs.IdentityClaims, ns, cap, pathOrPrefix string) error {
502+
503+
if aclObj == nil && claims == nil {
504+
return nil // ACLs aren't enabled
513505
}
514506

515-
// If the workload identity doesn't match the implicit permissions
516-
// given to paths, check for its attached ACL policies
517-
aclObj, err = sv.srv.ResolveClaims(claims)
518-
if err != nil {
519-
return nil, err // this only returns an error when the state store has gone wrong
507+
// Perform normal ACL validation. If the ACL object is nil, that means we're
508+
// working with an identity claim.
509+
if aclObj != nil {
510+
if !aclObj.AllowVariableOperation(ns, pathOrPrefix, cap) {
511+
return structs.ErrPermissionDenied
512+
}
513+
return nil
520514
}
521-
if aclObj != nil && aclObj.AllowVariableOperation(
522-
args.RequestNamespace(), pathOrPrefix, cap) {
523-
return aclObj, nil
515+
516+
if claims != nil {
517+
// The workload identity gets access to paths that match its
518+
// identity, without having to go thru the ACL system
519+
err := sv.authValidatePrefix(claims, ns, pathOrPrefix)
520+
if err == nil {
521+
return nil
522+
}
523+
524+
// If the workload identity doesn't match the implicit permissions
525+
// given to paths, check for its attached ACL policies
526+
aclObj, err = sv.srv.ResolveClaims(claims)
527+
if err != nil {
528+
return err // this only returns an error when the state store has gone wrong
529+
}
530+
if aclObj != nil && aclObj.AllowVariableOperation(
531+
ns, pathOrPrefix, cap) {
532+
return nil
533+
}
524534
}
525-
return nil, structs.ErrPermissionDenied
535+
return structs.ErrPermissionDenied
526536
}
527537

528538
// authValidatePrefix asserts that the requested path is valid for

0 commit comments

Comments
 (0)