package main import ( "encoding/json" "fmt" "strings" "github.com/golang/glog" admission "k8s.io/api/admission/v1beta1" networking "k8s.io/api/networking/v1beta1" meta "k8s.io/apimachinery/pkg/apis/meta/v1" ) // ingressFilter is a filter which allows or denies the creation of an ingress // backing a given domain with a namespace. It does so by operating on an // explicit list of allowed namespace/domain pairs, where each domain is either // a single domain or a DNS wildcard at a given root. // By default every domain is allowed in every namespace. However, the moment // an entry is added for a given domain (or wildcard that matches some // domains), this domain will only be allowed in that namespace. // // For example, with the given allowed domains: // - ns: example, domain: one.example.com // - ns: example, domain: *.google.com // The logic will be as follows: // - one.example.com will be only allowed in the example namespace // - any .google.com domain will be only allowed in the example namespace // - all other domains will be allowed everywhere. // // This logic allows for the easy use of arbitrary domains by k8s users within // their personal namespaces, but allows critical domains to only be allowed in // trusted namespaces. // // ingressFilter can be used straight away after constructing it as an empty // type. type ingressFilter struct { // allowed is a map from namespace to list of domain matchers. allowed map[string][]*domain } // domain is a matcher for either a single given domain, or a domain wildcard. // If this is a wildcard matcher, any amount of dot-delimited levels under the // domain will be permitted. type domain struct { // dns is either the domain name matched by this matcher (if wildcard == // false), or the root of a wildcard represented by this matcher (if // wildcard == true). dns string wildcard bool } // match returns whether this matcher matches a given domain. func (d *domain) match(dns string) bool { if !d.wildcard { return dns == d.dns } return strings.HasSuffix(dns, "."+d.dns) } // allow adds a given (namespace, dns) pair to the filter. The dns variable is // a string that is either a simple domain name, or a wildcard like // *.foo.example.com. An error is returned if the dns stirng could not be // parsed. func (i *ingressFilter) allow(ns, dns string) error { // If the filter is brand new, initialize it. if i.allowed == nil { i.allowed = make(map[string][]*domain) } // Try to parse the name as a wildcard. parts := strings.Split(dns, ".") wildcard := false for i, part := range parts { if i == 0 && part == "*" { wildcard = true continue } // Do some basic validation of the name. if part == "" || strings.Contains(part, "*") { return fmt.Errorf("invalid domain") } } if wildcard { if len(parts) < 2 { return fmt.Errorf("invalid domain") } dns = strings.Join(parts[1:], ".") } i.allowed[ns] = append(i.allowed[ns], &domain{ dns: dns, wildcard: wildcard, }) return nil } // domainAllowed returns whether a given domain is allowed to be backed by an // ingress within a given namespace. func (i *ingressFilter) domainAllowed(ns, domain string) bool { if i.allowed == nil { return true } domainFound := false // TODO(q3k): if this becomes too slow, build some inverted index for this. for n, ds := range i.allowed { for _, d := range ds { if !d.match(domain) { continue } // Domain matched, see if allowed in this namespace. domainFound = true if n == ns { return true } } // Otherwise, maybe it's allowed in another domain. } // No direct match found - if this domain has been at all matched before, // it means that it's a restriected domain and the requested namespace is // not one that's allowed to host it. Refuse. if domainFound { return false } // No direct match found, and this domain is not restricted. Allow. return true } func (i *ingressFilter) admit(req *admission.AdmissionRequest) (*admission.AdmissionResponse, error) { if req.Kind.Group != "networking.k8s.io" || req.Kind.Kind != "Ingress" { return nil, fmt.Errorf("not an ingress") } result := func(s string, args ...interface{}) (*admission.AdmissionResponse, error) { res := &admission.AdmissionResponse{ UID: req.UID, } if s == "" { res.Allowed = true } else { res.Allowed = false res.Result = &meta.Status{ Code: 403, Message: fmt.Sprintf("admitomatic: %s", fmt.Sprintf(s, args...)), } } return res, nil } // Permit any actions on critical system namespaes. See: // https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/ // “Avoiding operating on the kube-system namespace” if req.Namespace == "kube-system" { return result("") } switch req.Operation { case "CREATE": case "UPDATE": default: // We only care about creations/updates, everything else is referred to plain RBAC. return result("") } ingress := networking.Ingress{} err := json.Unmarshal(req.Object.Raw, &ingress) if err != nil { glog.Errorf("Unmarshaling Ingress failed: %v", err) return result("invalid object") } // Check TLS config for hosts. for j, t := range ingress.Spec.TLS { for k, h := range t.Hosts { if strings.Contains(h, "*") { // TODO(q3k): support wildcards return result("wildcard host %q (%d in TLS entry %d) is not permitted", h, k, j) } if !i.domainAllowed(req.Namespace, h) { return result("host %q (%d) in TLS entry %d is not allowed in namespace %q", h, k, j, req.Namespace) } } } // Check rules for hosts. for j, r := range ingress.Spec.Rules { h := r.Host // Per IngressRule spec: // If the host is unspecified, the Ingress routes all traffic based // on the specified IngressRuleValue. Host can be "precise" which is // a domain name without the terminating dot of a network host (e.g. // "foo.bar.com") or "wildcard", which is a domain name prefixed with // a single wildcard label (e.g. "*.foo.com"). // // We reject everything other than precise hosts. if h == "" { return result("empty host %q (in rule %d) is not permitted", h, j) } if strings.Contains(h, "*") { // TODO(q3k): support wildcards return result("wildcard host %q (in rule %d) is not permitted", h, j) } if !i.domainAllowed(req.Namespace, h) { return result("host %q (in rule %d) is not allowed in namespace %q", h, j, req.Namespace) } } // Only allow a trusted subset of n-i-c annotations. // TODO(q3k): allow opt-out for some namespaces allowed := map[string]bool{ "proxy-body-size": true, "ssl-redirect": true, "backend-protocol": true, "use-regex": true, // Used by cert-manager "whitelist-source-range": true, } prefix := "nginx.ingress.kubernetes.io/" for k, _ := range ingress.Annotations { if !strings.HasPrefix(k, prefix) { continue } k = strings.TrimPrefix(k, prefix) if !allowed[k] { return result("forbidden annotation %q", k) } } // All clear, accept this Ingress. return result("") }