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