Skip to content

Commit b21a874

Browse files
authored
feature: support globbing in mtls filters (#4178)
ref: #4073 --------- Signed-off-by: Sandor Szücs <sandor.szuecs@zalando.de>
1 parent 8e821b9 commit b21a874

3 files changed

Lines changed: 219 additions & 13 deletions

File tree

docs/reference/filters.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -653,8 +653,9 @@ Example:
653653

654654
### mtlsSanDNS
655655

656-
This authz filter checks DNS of the SAN value of the provided certificate. You have
657-
to use `mtlsAuthn()` to verify validity.
656+
This authz filter checks DNS of the SAN value of the provided
657+
certificate. It supports exact match and wildcard domain match.
658+
You have to use `mtlsAuthn()` to verify validity.
658659

659660
Parameters are one or more:
660661

@@ -664,6 +665,7 @@ Example:
664665

665666
```
666667
* -> mtlsAuthn() -> mtlsSanDNS("my.host.example") -> "http://10.2.5.21:8080";
668+
* -> mtlsAuthn() -> mtlsSanDNS("*.host.example") -> "http://10.2.5.21:8080";
667669
```
668670

669671
### mtlsSanIP
@@ -683,8 +685,9 @@ Example:
683685

684686
### mtlsSanURI
685687

686-
This authz filter checks URIs of the SAN value of the provided certificate. You have
687-
to use `mtlsAuthn()` to verify validity.
688+
This authz filter checks URIs of the SAN value of the provided
689+
certificate. It supports exact match and globbing. You have to use
690+
`mtlsAuthn()` to verify validity.
688691

689692
Parameters are one or more:
690693

@@ -694,6 +697,7 @@ Example:
694697

695698
```
696699
* -> mtlsAuthn() -> mtlsSanURI("spiffe://my-service.example/app1") -> "http://10.2.5.21:8080";
700+
* -> mtlsAuthn() -> mtlsSanURI("spiffe://my-service.example/*") -> "http://10.2.5.21:8080";
697701
```
698702

699703

filters/tls/mtls.go

Lines changed: 61 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"net/http"
66
"net/netip"
77
"net/url"
8+
"path"
89
"strings"
910

1011
"go4.org/netipx"
@@ -91,12 +92,14 @@ type mtlsFilter struct {
9192
// mtlsIssuerDN allow-list (RFC 2253 DN strings)
9293
allowedDN map[string]struct{}
9394

94-
// mtlsSAN* allow-lists: hostnames, URIs, and IP/CIDR ranges are stored
95+
// mtlsSan* allow-lists: hostnames, URIs, and IP/CIDR ranges are stored
9596
// separately so the hot path can use a single IPSet.Contains call for IPs
9697
// instead of re-parsing every pattern on each request.
97-
allowedHostnames map[string]struct{} // lowercased
98-
allowedURIs map[string]struct{} // exact match
99-
allowedIPs *netipx.IPSet
98+
allowedHostnames map[string]struct{} // lowercased exact match
99+
allowedHostnameSuffixes []string // lowercased DNS domain suffixes for wildcard matches
100+
allowedURIs map[string]struct{} // exact match
101+
allowedURIGlobs []string // glob patterns (pre-validated, path.Match semantics)
102+
allowedIPs *netipx.IPSet
100103

101104
// mtlsAuthn: verfiy options created at filter-creation time.
102105
verifyOpt x509.VerifyOptions
@@ -184,6 +187,17 @@ func isValidHostname(s string) bool {
184187
return true
185188
}
186189

190+
// matchesDNSWildcard reports whether the lowercased hostname name matches the
191+
// wildcard pattern "*.suffix". It requires exactly one non-empty label before
192+
// suffix, matching RFC 6125 single-label wildcard semantics.
193+
func matchesDNSWildcard(name, suffix string) bool {
194+
if !strings.HasSuffix(name, "."+suffix) {
195+
return false
196+
}
197+
label := name[:len(name)-len(suffix)-1]
198+
return label != "" && !strings.Contains(label, ".")
199+
}
200+
187201
func (ms *mtlsSpec) Name() string {
188202
return ms.typ.String()
189203
}
@@ -282,8 +296,12 @@ func (ms *mtlsSpec) CreateFilter(args []any) (filters.Filter, error) {
282296
}
283297
if !isValidHostname(s) {
284298
return nil, filters.ErrInvalidFilterParameters
299+
}
300+
lower := strings.ToLower(s)
301+
if strings.HasPrefix(lower, "*.") {
302+
mf.allowedHostnameSuffixes = append(mf.allowedHostnameSuffixes, lower[2:])
285303
} else {
286-
mf.allowedHostnames[strings.ToLower(s)] = struct{}{}
304+
mf.allowedHostnames[lower] = struct{}{}
287305
}
288306
}
289307

@@ -296,6 +314,12 @@ func (ms *mtlsSpec) CreateFilter(args []any) (filters.Filter, error) {
296314
}
297315
if u, err := url.Parse(s); err != nil || u.Scheme == "" {
298316
return nil, filters.ErrInvalidFilterParameters
317+
}
318+
if strings.ContainsAny(s, "*") {
319+
if _, err := path.Match(s, ""); err != nil {
320+
return nil, filters.ErrInvalidFilterParameters
321+
}
322+
mf.allowedURIGlobs = append(mf.allowedURIGlobs, s)
299323
} else {
300324
mf.allowedURIs[s] = struct{}{}
301325
}
@@ -389,20 +413,48 @@ func (mf *mtlsFilter) Request(ctx filters.FilterContext) {
389413
case mtlsSanDNS:
390414
// Check hostname SANs against the allowlist.
391415
for _, dns := range leafCert.DNSNames {
392-
if _, ok := mf.allowedHostnames[strings.ToLower(dns)]; ok {
416+
lower := strings.ToLower(dns)
417+
if _, ok := mf.allowedHostnames[lower]; ok {
393418
allowed = true
394419
auditCertData.WriteString("SAN DNS: ")
395420
auditCertData.WriteString(dns)
421+
break
422+
}
423+
// Wildcard match: pattern suffix is stored without the leading "*.".
424+
// A name matches only when it has exactly one label before the suffix.
425+
for _, suffix := range mf.allowedHostnameSuffixes {
426+
if matchesDNSWildcard(lower, suffix) {
427+
allowed = true
428+
auditCertData.WriteString("SAN DNS: ")
429+
auditCertData.WriteString(dns)
430+
break
431+
}
432+
}
433+
if allowed {
434+
break
396435
}
397436
}
398437

399438
case mtlsSanURI:
400-
// Check URI SANs against the allowlist (exact string match).
439+
// Check URI SANs against the allowlist (exact or glob match).
401440
for _, u := range leafCert.URIs {
402-
if _, ok := mf.allowedURIs[u.String()]; ok {
441+
uStr := u.String()
442+
if _, ok := mf.allowedURIs[uStr]; ok {
403443
allowed = true
404444
auditCertData.WriteString("SAN URI: ")
405-
auditCertData.WriteString(u.String())
445+
auditCertData.WriteString(uStr)
446+
break
447+
}
448+
for _, glob := range mf.allowedURIGlobs {
449+
if ok, _ := path.Match(glob, uStr); ok {
450+
allowed = true
451+
auditCertData.WriteString("SAN URI: ")
452+
auditCertData.WriteString(uStr)
453+
break
454+
}
455+
}
456+
if allowed {
457+
break
406458
}
407459
}
408460

filters/tls/mtls_test.go

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,16 @@ func TestNewMtlsSanDNS_CreateFilter(t *testing.T) {
363363
args: []any{"10.0.0.0/8", "example.com", "spiffe://trust-domain/svc"},
364364
wantErr: true,
365365
},
366+
{
367+
name: "valid glob *.host.example",
368+
args: []any{"*.host.example"},
369+
wantErr: false,
370+
},
371+
{
372+
name: "mix exact and glob",
373+
args: []any{"exact.example.com", "*.host.example"},
374+
wantErr: false,
375+
},
366376
} {
367377
t.Run(tt.name, func(t *testing.T) {
368378
f, err := spec.CreateFilter(tt.args)
@@ -466,6 +476,16 @@ func TestNewMtlsSanURI_CreateFilter(t *testing.T) {
466476
args: []any{"10.0.0.0/8", "example.com", "spiffe://trust-domain/svc"},
467477
wantErr: true,
468478
},
479+
{
480+
name: "valid URI glob spiffe://services.example/applications/*",
481+
args: []any{"spiffe://services.example/applications/*"},
482+
wantErr: false,
483+
},
484+
{
485+
name: "mix exact URI and glob",
486+
args: []any{"spiffe://other.example/svc", "spiffe://services.example/applications/*"},
487+
wantErr: false,
488+
},
469489
} {
470490
t.Run(tt.name, func(t *testing.T) {
471491
f, err := spec.CreateFilter(tt.args)
@@ -1102,6 +1122,136 @@ func TestMtlsSAN_Request(t *testing.T) {
11021122
expectedStatus: http.StatusForbidden,
11031123
expectServed: true,
11041124
},
1125+
// DNS glob matching
1126+
{
1127+
name: "DNS glob *.host.example matches foo.host.example",
1128+
tlsState: buildConnStateWithSANs(nil, []string{"foo.host.example"}, nil, nil),
1129+
spec: NewMtlsSanDNS(),
1130+
filterArgs: []any{"*.host.example"},
1131+
expectedStatus: 0,
1132+
expectServed: false,
1133+
},
1134+
{
1135+
name: "DNS glob *.host.example is case-insensitive",
1136+
tlsState: buildConnStateWithSANs(nil, []string{"FOO.Host.Example"}, nil, nil),
1137+
spec: NewMtlsSanDNS(),
1138+
filterArgs: []any{"*.host.example"},
1139+
expectedStatus: 0,
1140+
expectServed: false,
1141+
},
1142+
{
1143+
name: "DNS glob *.host.example does not match two-level sub foo.bar.host.example",
1144+
tlsState: buildConnStateWithSANs(nil, []string{"foo.bar.host.example"}, nil, nil),
1145+
spec: NewMtlsSanDNS(),
1146+
filterArgs: []any{"*.host.example"},
1147+
expectedStatus: http.StatusForbidden,
1148+
expectServed: true,
1149+
},
1150+
{
1151+
name: "DNS glob *.host.example does not match host.example itself",
1152+
tlsState: buildConnStateWithSANs(nil, []string{"host.example"}, nil, nil),
1153+
spec: NewMtlsSanDNS(),
1154+
filterArgs: []any{"*.host.example"},
1155+
expectedStatus: http.StatusForbidden,
1156+
expectServed: true,
1157+
},
1158+
{
1159+
name: "DNS glob does not match unrelated domain",
1160+
tlsState: buildConnStateWithSANs(nil, []string{"evil.com"}, nil, nil),
1161+
spec: NewMtlsSanDNS(),
1162+
filterArgs: []any{"*.host.example"},
1163+
expectedStatus: http.StatusForbidden,
1164+
expectServed: true,
1165+
},
1166+
{
1167+
name: "DNS mix exact and glob — exact matches",
1168+
tlsState: buildConnStateWithSANs(nil, []string{"exact.example.com"}, nil, nil),
1169+
spec: NewMtlsSanDNS(),
1170+
filterArgs: []any{"exact.example.com", "*.host.example"},
1171+
expectedStatus: 0,
1172+
expectServed: false,
1173+
},
1174+
{
1175+
name: "DNS mix exact and glob — glob matches",
1176+
tlsState: buildConnStateWithSANs(nil, []string{"sub.host.example"}, nil, nil),
1177+
spec: NewMtlsSanDNS(),
1178+
filterArgs: []any{"exact.example.com", "*.host.example"},
1179+
expectedStatus: 0,
1180+
expectServed: false,
1181+
},
1182+
// URI glob matching
1183+
{
1184+
name: "URI glob spiffe://services.example/applications/* matches leaf path",
1185+
tlsState: buildConnStateWithSANs(nil, nil, nil,
1186+
[]*url.URL{{Scheme: "spiffe", Host: "services.example", Path: "/applications/myapp"}}),
1187+
spec: NewMtlsSanURI(),
1188+
filterArgs: []any{"spiffe://services.example/applications/*"},
1189+
expectedStatus: 0,
1190+
expectServed: false,
1191+
},
1192+
{
1193+
name: "URI glob spiffe://services.example/applications/* matches leaf path without query",
1194+
tlsState: buildConnStateWithSANs(nil, nil, nil,
1195+
[]*url.URL{{Scheme: "spiffe", Host: "services.example", Path: "/applications/myapp", RawQuery: "role=admin"}}),
1196+
spec: NewMtlsSanURI(),
1197+
filterArgs: []any{"spiffe://services.example/applications/*"},
1198+
expectedStatus: 0,
1199+
expectServed: false,
1200+
},
1201+
{
1202+
name: "URI glob spiffe://services.example/applications/* matches leaf path Syamala",
1203+
tlsState: buildConnStateWithSANs(nil, nil, nil,
1204+
[]*url.URL{{Scheme: "spiffe", Host: "services.example", Path: "/applications/myappXrole=admin"}}),
1205+
spec: NewMtlsSanURI(),
1206+
filterArgs: []any{"spiffe://services.example/applications/*"},
1207+
expectedStatus: 0,
1208+
expectServed: false,
1209+
},
1210+
{
1211+
name: "URI glob spiffe://services.example/applications/* does not match nested path",
1212+
tlsState: buildConnStateWithSANs(nil, nil, nil,
1213+
[]*url.URL{{Scheme: "spiffe", Host: "services.example", Path: "/applications/a/b"}}),
1214+
spec: NewMtlsSanURI(),
1215+
filterArgs: []any{"spiffe://services.example/applications/*"},
1216+
expectedStatus: http.StatusForbidden,
1217+
expectServed: true,
1218+
},
1219+
{
1220+
name: "URI glob does not match different host",
1221+
tlsState: buildConnStateWithSANs(nil, nil, nil,
1222+
[]*url.URL{{Scheme: "spiffe", Host: "evil.example", Path: "/applications/myapp"}}),
1223+
spec: NewMtlsSanURI(),
1224+
filterArgs: []any{"spiffe://services.example/applications/*"},
1225+
expectedStatus: http.StatusForbidden,
1226+
expectServed: true,
1227+
},
1228+
{
1229+
name: "URI mix exact and glob — glob matches",
1230+
tlsState: buildConnStateWithSANs(nil, nil, nil,
1231+
[]*url.URL{{Scheme: "spiffe", Host: "services.example", Path: "/applications/myapp"}}),
1232+
spec: NewMtlsSanURI(),
1233+
filterArgs: []any{"spiffe://other.example/svc", "spiffe://services.example/applications/*"},
1234+
expectedStatus: 0,
1235+
expectServed: false,
1236+
},
1237+
{
1238+
name: "URI mix exact and glob — exact matches",
1239+
tlsState: buildConnStateWithSANs(nil, nil, nil,
1240+
[]*url.URL{{Scheme: "spiffe", Host: "other.example", Path: "/svc"}}),
1241+
spec: NewMtlsSanURI(),
1242+
filterArgs: []any{"spiffe://other.example/svc", "spiffe://services.example/applications/*"},
1243+
expectedStatus: 0,
1244+
expectServed: false,
1245+
},
1246+
{
1247+
name: "URI glob — none match — 403 Forbidden",
1248+
tlsState: buildConnStateWithSANs(nil, nil, nil,
1249+
[]*url.URL{{Scheme: "spiffe", Host: "evil.example", Path: "/applications/myapp"}}),
1250+
spec: NewMtlsSanURI(),
1251+
filterArgs: []any{"spiffe://other.example/svc", "spiffe://services.example/applications/*"},
1252+
expectedStatus: http.StatusForbidden,
1253+
expectServed: true,
1254+
},
11051255
} {
11061256
t.Run(tt.name, func(t *testing.T) {
11071257
t.Parallel()

0 commit comments

Comments
 (0)