Skip to content

Commit fab2809

Browse files
committed
Add client-go credential plugin to kuberc
Remove reference to internal types in kuberc types * Remove unserialized types from public APIs Also remove defaulting * Don't do conversion gen for plugin policy types Because the plugin policy types are explicitly allowed to be empty, they should not affect conversion. The autogenerated conversion functions for the `Preference` type will leave those fields empty. * Remove defaulting tests Comments and simplifications (h/t jordan liggitt) Signed-off-by: Peter Engelbert <[email protected]>
1 parent f41e30e commit fab2809

File tree

27 files changed

+1676
-64
lines changed

27 files changed

+1676
-64
lines changed

pkg/generated/openapi/zz_generated.openapi.go

Lines changed: 50 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

staging/src/k8s.io/cli-runtime/pkg/genericclioptions/command_headers.go

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ package genericclioptions
1919
import (
2020
"net/http"
2121
"strings"
22+
"sync/atomic"
2223

2324
"github.com/google/uuid"
2425
"github.com/spf13/cobra"
@@ -33,8 +34,9 @@ const (
3334
// round tripper to add Request headers before delegation. Implements
3435
// the go standard library "http.RoundTripper" interface.
3536
type CommandHeaderRoundTripper struct {
36-
Delegate http.RoundTripper
37-
Headers map[string]string
37+
Delegate http.RoundTripper
38+
Headers map[string]string
39+
SkipHeaders *atomic.Bool
3840
}
3941

4042
// CommandHeaderRoundTripper adds Request headers before delegating to standard
@@ -43,9 +45,14 @@ type CommandHeaderRoundTripper struct {
4345
//
4446
// https://github.com/kubernetes/enhancements/tree/master/keps/sig-cli/859-kubectl-headers
4547
func (c *CommandHeaderRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
48+
if c.shouldSkipHeaders() {
49+
return c.Delegate.RoundTrip(req)
50+
}
51+
4652
for header, value := range c.Headers {
4753
req.Header.Set(header, value)
4854
}
55+
4956
return c.Delegate.RoundTrip(req)
5057
}
5158

@@ -92,3 +99,11 @@ func (c *CommandHeaderRoundTripper) CancelRequest(req *http.Request) {
9299
cr.CancelRequest(req)
93100
}
94101
}
102+
103+
func (c *CommandHeaderRoundTripper) shouldSkipHeaders() bool {
104+
if c.SkipHeaders == nil {
105+
return false
106+
}
107+
108+
return c.SkipHeaders.Load()
109+
}

staging/src/k8s.io/client-go/plugin/pkg/client/auth/exec/exec.go

Lines changed: 156 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)