@@ -27,6 +27,7 @@ import (
2727 "net/http"
2828 "os"
2929 "os/exec"
30+ "path/filepath"
3031 "reflect"
3132 "strings"
3233 "sync"
@@ -39,6 +40,7 @@ import (
3940 "k8s.io/apimachinery/pkg/runtime/serializer"
4041 "k8s.io/apimachinery/pkg/util/dump"
4142 utilnet "k8s.io/apimachinery/pkg/util/net"
43+ "k8s.io/apimachinery/pkg/util/sets"
4244 "k8s.io/client-go/pkg/apis/clientauthentication"
4345 "k8s.io/client-go/pkg/apis/clientauthentication/install"
4446 clientauthenticationv1 "k8s.io/client-go/pkg/apis/clientauthentication/v1"
@@ -177,13 +179,29 @@ func newAuthenticator(c *cache, isTerminalFunc func(int) bool, config *api.ExecC
177179 connTracker ,
178180 )
179181
182+ if err := ValidatePluginPolicy (config .PluginPolicy ); err != nil {
183+ return nil , fmt .Errorf ("invalid plugin policy: %w" , err )
184+ }
185+
186+ allowlistLookup := sets .New [string ]()
187+ for _ , entry := range config .PluginPolicy .Allowlist {
188+ if entry .Name != "" {
189+ allowlistLookup .Insert (entry .Name )
190+ }
191+ }
192+
180193 a := & Authenticator {
181- cmd : config .Command ,
194+ // Clean is called to normalize the path to facilitate comparison with
195+ // the allowlist, when present
196+ cmd : filepath .Clean (config .Command ),
182197 args : config .Args ,
183198 group : gv ,
184199 cluster : cluster ,
185200 provideClusterInfo : config .ProvideClusterInfo ,
186201
202+ allowlistLookup : allowlistLookup ,
203+ execPluginPolicy : config .PluginPolicy ,
204+
187205 installHint : config .InstallHint ,
188206 sometimes : & sometimes {
189207 threshold : 10 ,
@@ -250,6 +268,9 @@ type Authenticator struct {
250268 cluster * clientauthentication.Cluster
251269 provideClusterInfo bool
252270
271+ allowlistLookup sets.Set [string ]
272+ execPluginPolicy api.PluginPolicy
273+
253274 // Used to avoid log spew by rate limiting install hint printing. We didn't do
254275 // this by interval based rate limiting alone since that way may have prevented
255276 // the install hint from showing up for kubectl users.
@@ -441,6 +462,12 @@ func (a *Authenticator) refreshCredsLocked() error {
441462 cmd .Stdin = a .stdin
442463 }
443464
465+ err = a .updateCommandAndCheckAllowlistLocked (cmd )
466+ incrementPolicyMetric (err )
467+ if err != nil {
468+ return err
469+ }
470+
444471 err = cmd .Run ()
445472 incrementCallsMetric (err )
446473 if err != nil {
@@ -545,3 +572,131 @@ func (a *Authenticator) wrapCmdRunErrorLocked(err error) error {
545572 return fmt .Errorf ("exec: %v" , err )
546573 }
547574}
575+
576+ // `updateCommandAndCheckAllowlistLocked` determines whether or not the specified executable may run
577+ // according to the credential plugin policy. If the plugin is allowed, `nil`
578+ // is returned. If the plugin is not allowed, an error must be returned
579+ // explaining why.
580+ func (a * Authenticator ) updateCommandAndCheckAllowlistLocked (cmd * exec.Cmd ) error {
581+ switch a .execPluginPolicy .PolicyType {
582+ case "" , api .PluginPolicyAllowAll :
583+ return nil
584+ case api .PluginPolicyDenyAll :
585+ return fmt .Errorf ("plugin %q not allowed: policy set to %q" , a .cmd , api .PluginPolicyDenyAll )
586+ case api .PluginPolicyAllowlist :
587+ return a .checkAllowlistLocked (cmd )
588+ default :
589+ return fmt .Errorf ("unknown plugin policy %q" , a .execPluginPolicy .PolicyType )
590+ }
591+ }
592+
593+ // `checkAllowlistLocked` checks the specified plugin against the allowlist,
594+ // and may update the Authenticator's allowlistLookup set.
595+ func (a * Authenticator ) checkAllowlistLocked (cmd * exec.Cmd ) error {
596+ // a.cmd is the original command as specified in the configuration, then filepath.Clean().
597+ // cmd.Path is the possibly-resolved command.
598+ // If either are an exact match in the allowlist, return success.
599+ if a .allowlistLookup .Has (a .cmd ) || a .allowlistLookup .Has (cmd .Path ) {
600+ return nil
601+ }
602+
603+ var cmdResolvedPath string
604+ var cmdResolvedErr error
605+ if cmd .Path != a .cmd {
606+ // cmd.Path changed, use the already-resolved LookPath results
607+ cmdResolvedPath = cmd .Path
608+ cmdResolvedErr = cmd .Err
609+ } else {
610+ // cmd.Path is unchanged, do LookPath ourselves
611+ cmdResolvedPath , cmdResolvedErr = exec .LookPath (cmd .Path )
612+ // update cmd.Path to cmdResolvedPath so we only run the resolved path
613+ if cmdResolvedPath != "" {
614+ cmd .Path = cmdResolvedPath
615+ }
616+ }
617+
618+ if cmdResolvedErr != nil {
619+ return fmt .Errorf ("plugin path %q cannot be resolved for credential plugin allowlist check: %w" , cmd .Path , cmdResolvedErr )
620+ }
621+
622+ // cmdResolvedPath may have changed, and the changed value may be in the allowlist
623+ if a .allowlistLookup .Has (cmdResolvedPath ) {
624+ return nil
625+ }
626+
627+ // There is no verbatim match
628+ a .resolveAllowListEntriesLocked (cmd .Path )
629+
630+ // allowlistLookup may have changed, recheck
631+ if a .allowlistLookup .Has (cmdResolvedPath ) {
632+ return nil
633+ }
634+
635+ return fmt .Errorf ("plugin path %q is not permitted by the credential plugin allowlist" , cmd .Path )
636+ }
637+
638+ // resolveAllowListEntriesLocked tries to resolve allowlist entries with LookPath,
639+ // and adds successfully resolved entries to allowlistLookup.
640+ // The optional commandHint can be used to limit which entries are resolved to ones which match the hint basename.
641+ func (a * Authenticator ) resolveAllowListEntriesLocked (commandHint string ) {
642+ hintName := filepath .Base (commandHint )
643+ for _ , entry := range a .execPluginPolicy .Allowlist {
644+ entryBasename := filepath .Base (entry .Name )
645+ if hintName != "" && hintName != entryBasename {
646+ // we got a hint, and this allowlist entry does not match it
647+ continue
648+ }
649+ entryResolvedPath , err := exec .LookPath (entry .Name )
650+ if err != nil {
651+ klog .V (5 ).ErrorS (err , "resolving credential plugin allowlist" , "name" , entry .Name )
652+ continue
653+ }
654+ if entryResolvedPath != "" {
655+ a .allowlistLookup .Insert (entryResolvedPath )
656+ }
657+ }
658+ }
659+
660+ func ValidatePluginPolicy (policy api.PluginPolicy ) error {
661+ switch policy .PolicyType {
662+ // "" is equivalent to "AllowAll"
663+ case "" , api .PluginPolicyAllowAll , api .PluginPolicyDenyAll :
664+ if policy .Allowlist != nil {
665+ return fmt .Errorf ("misconfigured credential plugin allowlist: plugin policy is %q but allowlist is non-nil" , policy .PolicyType )
666+ }
667+ return nil
668+ case api .PluginPolicyAllowlist :
669+ return validateAllowlist (policy .Allowlist )
670+ default :
671+ return fmt .Errorf ("unknown plugin policy: %q" , policy .PolicyType )
672+ }
673+ }
674+
675+ var emptyAllowlistEntry api.AllowlistEntry
676+
677+ func validateAllowlist (list []api.AllowlistEntry ) error {
678+ // This will be the case if the user has misspelled the field name for the
679+ // allowlist. Because this is a security knob, fail immediately rather than
680+ // proceed when the user has made a mistake.
681+ if list == nil {
682+ return fmt .Errorf ("credential plugin policy set to %q, but allowlist is unspecified" , api .PluginPolicyAllowlist )
683+ }
684+
685+ if len (list ) == 0 {
686+ return fmt .Errorf ("credential plugin policy set to %q, but allowlist is empty; use %q policy instead" , api .PluginPolicyAllowlist , api .PluginPolicyDenyAll )
687+ }
688+
689+ for i , item := range list {
690+ if item == emptyAllowlistEntry {
691+ return fmt .Errorf ("misconfigured credential plugin allowlist: empty allowlist entry #%d" , i + 1 )
692+ }
693+
694+ if cleaned := filepath .Clean (item .Name ); cleaned != item .Name {
695+ return fmt .Errorf ("non-normalized file path: %q vs %q" , item .Name , cleaned )
696+ } else if item .Name == "" {
697+ return fmt .Errorf ("empty file path: %q" , item .Name )
698+ }
699+ }
700+
701+ return nil
702+ }
0 commit comments