blob: b7bdf91fcbe3ac45837ef112cc591c83aa11a865 [file] [log] [blame]
Serge Bazanski64956532021-01-30 19:19:32 +01001package main
2
3import (
Serge Bazanski5d2c8fc2021-01-30 21:23:53 +01004 "encoding/json"
Serge Bazanski64956532021-01-30 19:19:32 +01005 "fmt"
6 "strings"
7
Serge Bazanski5d2c8fc2021-01-30 21:23:53 +01008 "github.com/golang/glog"
Serge Bazanski64956532021-01-30 19:19:32 +01009 admission "k8s.io/api/admission/v1beta1"
Serge Bazanski5d2c8fc2021-01-30 21:23:53 +010010 networking "k8s.io/api/networking/v1beta1"
11 meta "k8s.io/apimachinery/pkg/apis/meta/v1"
Serge Bazanski64956532021-01-30 19:19:32 +010012)
13
14// ingressFilter is a filter which allows or denies the creation of an ingress
15// backing a given domain with a namespace. It does so by operating on an
16// explicit list of allowed namespace/domain pairs, where each domain is either
17// a single domain or a DNS wildcard at a given root.
18// By default every domain is allowed in every namespace. However, the moment
19// an entry is added for a given domain (or wildcard that matches some
20// domains), this domain will only be allowed in that namespace.
21//
22// For example, with the given allowed domains:
23// - ns: example, domain: one.example.com
24// - ns: example, domain: *.google.com
25// The logic will be as follows:
26// - one.example.com will be only allowed in the example namespace
27// - any .google.com domain will be only allowed in the example namespace
28// - all other domains will be allowed everywhere.
29//
30// This logic allows for the easy use of arbitrary domains by k8s users within
31// their personal namespaces, but allows critical domains to only be allowed in
32// trusted namespaces.
33//
34// ingressFilter can be used straight away after constructing it as an empty
35// type.
36type ingressFilter struct {
37 // allowed is a map from namespace to list of domain matchers.
38 allowed map[string][]*domain
Serge Bazanskic1f37252023-06-19 21:56:29 +000039
40 // anythingGoesNamespaces are namespaces that are opted out of security
41 // checks.
42 anythingGoesNamespaces []string
Serge Bazanski64956532021-01-30 19:19:32 +010043}
44
45// domain is a matcher for either a single given domain, or a domain wildcard.
46// If this is a wildcard matcher, any amount of dot-delimited levels under the
47// domain will be permitted.
48type domain struct {
49 // dns is either the domain name matched by this matcher (if wildcard ==
50 // false), or the root of a wildcard represented by this matcher (if
51 // wildcard == true).
52 dns string
53 wildcard bool
54}
55
56// match returns whether this matcher matches a given domain.
57func (d *domain) match(dns string) bool {
58 if !d.wildcard {
59 return dns == d.dns
60 }
61 return strings.HasSuffix(dns, "."+d.dns)
62}
63
64// allow adds a given (namespace, dns) pair to the filter. The dns variable is
65// a string that is either a simple domain name, or a wildcard like
66// *.foo.example.com. An error is returned if the dns stirng could not be
67// parsed.
68func (i *ingressFilter) allow(ns, dns string) error {
69 // If the filter is brand new, initialize it.
70 if i.allowed == nil {
71 i.allowed = make(map[string][]*domain)
72 }
73
74 // Try to parse the name as a wildcard.
75 parts := strings.Split(dns, ".")
76 wildcard := false
77 for i, part := range parts {
78 if i == 0 && part == "*" {
79 wildcard = true
80 continue
81 }
82 // Do some basic validation of the name.
83 if part == "" || strings.Contains(part, "*") {
84 return fmt.Errorf("invalid domain")
85 }
86 }
87 if wildcard {
88 if len(parts) < 2 {
89 return fmt.Errorf("invalid domain")
90 }
91 dns = strings.Join(parts[1:], ".")
92 }
93 i.allowed[ns] = append(i.allowed[ns], &domain{
94 dns: dns,
95 wildcard: wildcard,
96 })
97 return nil
98}
99
100// domainAllowed returns whether a given domain is allowed to be backed by an
101// ingress within a given namespace.
102func (i *ingressFilter) domainAllowed(ns, domain string) bool {
103 if i.allowed == nil {
104 return true
105 }
106
107 domainFound := false
108 // TODO(q3k): if this becomes too slow, build some inverted index for this.
109 for n, ds := range i.allowed {
110 for _, d := range ds {
111 if !d.match(domain) {
112 continue
113 }
114 // Domain matched, see if allowed in this namespace.
115 domainFound = true
116 if n == ns {
117 return true
118 }
119 }
120 // Otherwise, maybe it's allowed in another domain.
121 }
122 // No direct match found - if this domain has been at all matched before,
123 // it means that it's a restriected domain and the requested namespace is
124 // not one that's allowed to host it. Refuse.
125 if domainFound {
126 return false
127 }
128 // No direct match found, and this domain is not restricted. Allow.
129 return true
130}
131
132func (i *ingressFilter) admit(req *admission.AdmissionRequest) (*admission.AdmissionResponse, error) {
133 if req.Kind.Group != "networking.k8s.io" || req.Kind.Kind != "Ingress" {
134 return nil, fmt.Errorf("not an ingress")
135 }
Serge Bazanski5d2c8fc2021-01-30 21:23:53 +0100136
137 result := func(s string, args ...interface{}) (*admission.AdmissionResponse, error) {
138 res := &admission.AdmissionResponse{
139 UID: req.UID,
140 }
141 if s == "" {
142 res.Allowed = true
143 } else {
144 res.Allowed = false
145 res.Result = &meta.Status{
146 Code: 403,
147 Message: fmt.Sprintf("admitomatic: %s", fmt.Sprintf(s, args...)),
148 }
149 }
150 return res, nil
151 }
152
153 // Permit any actions on critical system namespaes. See:
154 // https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/
155 // “Avoiding operating on the kube-system namespace”
156 if req.Namespace == "kube-system" {
157 return result("")
158 }
Serge Bazanskic1f37252023-06-19 21:56:29 +0000159 for _, ns := range i.anythingGoesNamespaces {
160 if ns == req.Namespace {
161 return result("")
162 }
163 }
Serge Bazanski5d2c8fc2021-01-30 21:23:53 +0100164
165 switch req.Operation {
166 case "CREATE":
167 case "UPDATE":
168 default:
169 // We only care about creations/updates, everything else is referred to plain RBAC.
170 return result("")
171 }
172
173 ingress := networking.Ingress{}
174 err := json.Unmarshal(req.Object.Raw, &ingress)
175 if err != nil {
176 glog.Errorf("Unmarshaling Ingress failed: %v", err)
177 return result("invalid object")
178 }
179
180 // Check TLS config for hosts.
181 for j, t := range ingress.Spec.TLS {
182 for k, h := range t.Hosts {
183 if strings.Contains(h, "*") {
184 // TODO(q3k): support wildcards
185 return result("wildcard host %q (%d in TLS entry %d) is not permitted", h, k, j)
186 }
187 if !i.domainAllowed(req.Namespace, h) {
188 return result("host %q (%d) in TLS entry %d is not allowed in namespace %q", h, k, j, req.Namespace)
189 }
190 }
191 }
192
193 // Check rules for hosts.
194 for j, r := range ingress.Spec.Rules {
195 h := r.Host
196 // Per IngressRule spec:
197 // If the host is unspecified, the Ingress routes all traffic based
198 // on the specified IngressRuleValue. Host can be "precise" which is
199 // a domain name without the terminating dot of a network host (e.g.
200 // "foo.bar.com") or "wildcard", which is a domain name prefixed with
201 // a single wildcard label (e.g. "*.foo.com").
202 //
203 // We reject everything other than precise hosts.
204 if h == "" {
205 return result("empty host %q (in rule %d) is not permitted", h, j)
206 }
207 if strings.Contains(h, "*") {
208 // TODO(q3k): support wildcards
209 return result("wildcard host %q (in rule %d) is not permitted", h, j)
210 }
211 if !i.domainAllowed(req.Namespace, h) {
212 return result("host %q (in rule %d) is not allowed in namespace %q", h, j, req.Namespace)
213 }
214 }
215
216 // Only allow a trusted subset of n-i-c annotations.
217 // TODO(q3k): allow opt-out for some namespaces
218 allowed := map[string]bool{
219 "proxy-body-size": true,
220 "ssl-redirect": true,
221 "backend-protocol": true,
Serge Bazanski89a16f42021-06-06 12:28:28 +0000222 "use-regex": true,
Serge Bazanski943ab5b2021-02-08 00:33:45 +0100223 // Used by cert-manager
224 "whitelist-source-range": true,
Serge Bazanski5d2c8fc2021-01-30 21:23:53 +0100225 }
226 prefix := "nginx.ingress.kubernetes.io/"
227 for k, _ := range ingress.Annotations {
228 if !strings.HasPrefix(k, prefix) {
229 continue
230 }
231 k = strings.TrimPrefix(k, prefix)
232 if !allowed[k] {
233 return result("forbidden annotation %q", k)
234 }
235 }
236
237 // All clear, accept this Ingress.
238 return result("")
Serge Bazanski64956532021-01-30 19:19:32 +0100239}