| 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("") |
| } |