Implement annotation validation (#9673)
* Add validation to all annotations * Add annotation validation for fcgi * Fix reviews and fcgi e2e * Add flag to disable cross namespace validation * Add risk, flag for validation, tests * Add missing formating * Enable validation by default on tests * Test validation flag * remove ajp from list * Finalize validation changes * Add validations to CI * Update helm docs * Fix code review * Use a better name for annotation risk
This commit is contained in:
parent
86c00a2310
commit
c5f348ea2e
109 changed files with 4320 additions and 586 deletions
|
|
@ -32,13 +32,56 @@ import (
|
|||
"k8s.io/ingress-nginx/pkg/util/file"
|
||||
)
|
||||
|
||||
const (
|
||||
authSecretTypeAnnotation = "auth-secret-type" //#nosec G101
|
||||
authRealmAnnotation = "auth-realm"
|
||||
authTypeAnnotation = "auth-type"
|
||||
// This should be exported as it is imported by other packages
|
||||
AuthSecretAnnotation = "auth-secret" //#nosec G101
|
||||
)
|
||||
|
||||
var (
|
||||
authTypeRegex = regexp.MustCompile(`basic|digest`)
|
||||
authTypeRegex = regexp.MustCompile(`basic|digest`)
|
||||
authSecretTypeRegex = regexp.MustCompile(`auth-file|auth-map`)
|
||||
|
||||
// AuthDirectory default directory used to store files
|
||||
// to authenticate request
|
||||
AuthDirectory = "/etc/ingress-controller/auth"
|
||||
)
|
||||
|
||||
var AuthSecretConfig = parser.AnnotationConfig{
|
||||
Validator: parser.ValidateRegex(*parser.BasicCharsRegex, true),
|
||||
Scope: parser.AnnotationScopeLocation,
|
||||
Risk: parser.AnnotationRiskMedium, // Medium as it allows a subset of chars
|
||||
Documentation: `This annotation defines the name of the Secret that contains the usernames and passwords which are granted access to the paths defined in the Ingress rules. `,
|
||||
}
|
||||
|
||||
var authSecretAnnotations = parser.Annotation{
|
||||
Group: "authentication",
|
||||
Annotations: parser.AnnotationFields{
|
||||
AuthSecretAnnotation: AuthSecretConfig,
|
||||
authSecretTypeAnnotation: {
|
||||
Validator: parser.ValidateRegex(*authSecretTypeRegex, true),
|
||||
Scope: parser.AnnotationScopeLocation,
|
||||
Risk: parser.AnnotationRiskLow,
|
||||
Documentation: `This annotation what is the format of auth-secret value. Can be "auth-file" that defines the content of an htpasswd file, or "auth-map" where each key
|
||||
is a user and each value is the password.`,
|
||||
},
|
||||
authRealmAnnotation: {
|
||||
Validator: parser.ValidateRegex(*parser.CharsWithSpace, false),
|
||||
Scope: parser.AnnotationScopeLocation,
|
||||
Risk: parser.AnnotationRiskMedium, // Medium as it allows a subset of chars
|
||||
Documentation: `This annotation defines the realm (message) that should be shown to user when authentication is requested.`,
|
||||
},
|
||||
authTypeAnnotation: {
|
||||
Validator: parser.ValidateRegex(*authTypeRegex, true),
|
||||
Scope: parser.AnnotationScopeLocation,
|
||||
Risk: parser.AnnotationRiskLow,
|
||||
Documentation: `This annotation defines the basic authentication type. Should be "basic" or "digest"`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const (
|
||||
fileAuth = "auth-file"
|
||||
mapAuth = "auth-map"
|
||||
|
|
@ -85,13 +128,18 @@ func (bd1 *Config) Equal(bd2 *Config) bool {
|
|||
}
|
||||
|
||||
type auth struct {
|
||||
r resolver.Resolver
|
||||
authDirectory string
|
||||
r resolver.Resolver
|
||||
authDirectory string
|
||||
annotationConfig parser.Annotation
|
||||
}
|
||||
|
||||
// NewParser creates a new authentication annotation parser
|
||||
func NewParser(authDirectory string, r resolver.Resolver) parser.IngressAnnotation {
|
||||
return auth{r, authDirectory}
|
||||
return auth{
|
||||
r: r,
|
||||
authDirectory: authDirectory,
|
||||
annotationConfig: authSecretAnnotations,
|
||||
}
|
||||
}
|
||||
|
||||
// Parse parses the annotations contained in the ingress
|
||||
|
|
@ -99,7 +147,7 @@ func NewParser(authDirectory string, r resolver.Resolver) parser.IngressAnnotati
|
|||
// and generated an htpasswd compatible file to be used as source
|
||||
// during the authentication process
|
||||
func (a auth) Parse(ing *networking.Ingress) (interface{}, error) {
|
||||
at, err := parser.GetStringAnnotation("auth-type", ing)
|
||||
at, err := parser.GetStringAnnotation(authTypeAnnotation, ing, a.annotationConfig.Annotations)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -109,12 +157,15 @@ func (a auth) Parse(ing *networking.Ingress) (interface{}, error) {
|
|||
}
|
||||
|
||||
var secretType string
|
||||
secretType, err = parser.GetStringAnnotation("auth-secret-type", ing)
|
||||
secretType, err = parser.GetStringAnnotation(authSecretTypeAnnotation, ing, a.annotationConfig.Annotations)
|
||||
if err != nil {
|
||||
if ing_errors.IsValidationError(err) {
|
||||
return nil, err
|
||||
}
|
||||
secretType = fileAuth
|
||||
}
|
||||
|
||||
s, err := parser.GetStringAnnotation("auth-secret", ing)
|
||||
s, err := parser.GetStringAnnotation(AuthSecretAnnotation, ing, a.annotationConfig.Annotations)
|
||||
if err != nil {
|
||||
return nil, ing_errors.LocationDenied{
|
||||
Reason: fmt.Errorf("error reading secret name from annotation: %w", err),
|
||||
|
|
@ -131,6 +182,13 @@ func (a auth) Parse(ing *networking.Ingress) (interface{}, error) {
|
|||
if sns == "" {
|
||||
sns = ing.Namespace
|
||||
}
|
||||
secCfg := a.r.GetSecurityConfiguration()
|
||||
// We don't accept different namespaces for secrets.
|
||||
if !secCfg.AllowCrossNamespaceResources && sns != ing.Namespace {
|
||||
return nil, ing_errors.LocationDenied{
|
||||
Reason: fmt.Errorf("cross namespace usage of secrets is not allowed"),
|
||||
}
|
||||
}
|
||||
|
||||
name := fmt.Sprintf("%v/%v", sns, sname)
|
||||
secret, err := a.r.GetSecret(name)
|
||||
|
|
@ -140,7 +198,10 @@ func (a auth) Parse(ing *networking.Ingress) (interface{}, error) {
|
|||
}
|
||||
}
|
||||
|
||||
realm, _ := parser.GetStringAnnotation("auth-realm", ing)
|
||||
realm, err := parser.GetStringAnnotation(authRealmAnnotation, ing, a.annotationConfig.Annotations)
|
||||
if ing_errors.IsValidationError(err) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
passFilename := fmt.Sprintf("%v/%v-%v-%v.passwd", a.authDirectory, ing.GetNamespace(), ing.UID, secret.UID)
|
||||
|
||||
|
|
@ -210,3 +271,12 @@ func dumpSecretAuthMap(filename string, secret *api.Secret) error {
|
|||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a auth) GetDocumentation() parser.AnnotationFields {
|
||||
return a.annotationConfig.Annotations
|
||||
}
|
||||
|
||||
func (a auth) Validate(anns map[string]string) error {
|
||||
maxrisk := parser.StringRiskToRisk(a.r.GetSecurityConfiguration().AnnotationsRiskLevel)
|
||||
return parser.CheckAnnotationRisk(anns, maxrisk, authSecretAnnotations.Annotations)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import (
|
|||
api "k8s.io/api/core/v1"
|
||||
networking "k8s.io/api/networking/v1"
|
||||
meta_v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/client-go/tools/cache"
|
||||
"k8s.io/ingress-nginx/internal/ingress/annotations/parser"
|
||||
ing_errors "k8s.io/ingress-nginx/internal/ingress/errors"
|
||||
"k8s.io/ingress-nginx/internal/ingress/resolver"
|
||||
|
|
@ -79,13 +80,18 @@ type mockSecret struct {
|
|||
}
|
||||
|
||||
func (m mockSecret) GetSecret(name string) (*api.Secret, error) {
|
||||
if name != "default/demo-secret" {
|
||||
if name != "default/demo-secret" && name != "otherns/demo-secret" {
|
||||
return nil, fmt.Errorf("there is no secret with name %v", name)
|
||||
}
|
||||
|
||||
ns, _, err := cache.SplitMetaNamespaceKey(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &api.Secret{
|
||||
ObjectMeta: meta_v1.ObjectMeta{
|
||||
Namespace: api.NamespaceDefault,
|
||||
Namespace: ns,
|
||||
Name: "demo-secret",
|
||||
},
|
||||
Data: map[string][]byte{"auth": []byte("foo:$apr1$OFG3Xybp$ckL0FHDAkoXYIlH9.cysT0")},
|
||||
|
|
@ -106,13 +112,91 @@ func TestIngressAuthBadAuthType(t *testing.T) {
|
|||
ing := buildIngress()
|
||||
|
||||
data := map[string]string{}
|
||||
data[parser.GetAnnotationWithPrefix("auth-type")] = "invalid"
|
||||
data[parser.GetAnnotationWithPrefix(authTypeAnnotation)] = "invalid"
|
||||
ing.SetAnnotations(data)
|
||||
|
||||
_, dir, _ := dummySecretContent(t)
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
expected := ing_errors.NewLocationDenied("invalid authentication type")
|
||||
expected := ing_errors.NewValidationError("nginx.ingress.kubernetes.io/auth-type")
|
||||
_, err := NewParser(dir, &mockSecret{}).Parse(ing)
|
||||
if err.Error() != expected.Error() {
|
||||
t.Errorf("expected '%v' but got '%v'", expected, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngressInvalidRealm(t *testing.T) {
|
||||
ing := buildIngress()
|
||||
|
||||
data := map[string]string{}
|
||||
data[parser.GetAnnotationWithPrefix(authTypeAnnotation)] = "basic"
|
||||
data[parser.GetAnnotationWithPrefix(authRealmAnnotation)] = "something weird ; location trying to { break }"
|
||||
data[parser.GetAnnotationWithPrefix(AuthSecretAnnotation)] = "demo-secret"
|
||||
ing.SetAnnotations(data)
|
||||
|
||||
_, dir, _ := dummySecretContent(t)
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
expected := ing_errors.NewValidationError("nginx.ingress.kubernetes.io/auth-realm")
|
||||
_, err := NewParser(dir, &mockSecret{}).Parse(ing)
|
||||
if err.Error() != expected.Error() {
|
||||
t.Errorf("expected '%v' but got '%v'", expected, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngressInvalidDifferentNamespace(t *testing.T) {
|
||||
ing := buildIngress()
|
||||
|
||||
data := map[string]string{}
|
||||
data[parser.GetAnnotationWithPrefix(authTypeAnnotation)] = "basic"
|
||||
data[parser.GetAnnotationWithPrefix(AuthSecretAnnotation)] = "otherns/demo-secret"
|
||||
ing.SetAnnotations(data)
|
||||
|
||||
_, dir, _ := dummySecretContent(t)
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
expected := ing_errors.LocationDenied{
|
||||
Reason: errors.New("cross namespace usage of secrets is not allowed"),
|
||||
}
|
||||
_, err := NewParser(dir, &mockSecret{}).Parse(ing)
|
||||
if err.Error() != expected.Error() {
|
||||
t.Errorf("expected '%v' but got '%v'", expected, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngressInvalidDifferentNamespaceAllowed(t *testing.T) {
|
||||
ing := buildIngress()
|
||||
|
||||
data := map[string]string{}
|
||||
data[parser.GetAnnotationWithPrefix(authTypeAnnotation)] = "basic"
|
||||
data[parser.GetAnnotationWithPrefix(AuthSecretAnnotation)] = "otherns/demo-secret"
|
||||
ing.SetAnnotations(data)
|
||||
|
||||
_, dir, _ := dummySecretContent(t)
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
r := mockSecret{}
|
||||
r.AllowCrossNamespace = true
|
||||
_, err := NewParser(dir, r).Parse(ing)
|
||||
if err != nil {
|
||||
t.Errorf("not expecting an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngressInvalidSecretName(t *testing.T) {
|
||||
ing := buildIngress()
|
||||
|
||||
data := map[string]string{}
|
||||
data[parser.GetAnnotationWithPrefix(authTypeAnnotation)] = "basic"
|
||||
data[parser.GetAnnotationWithPrefix(AuthSecretAnnotation)] = "demo-secret;xpto"
|
||||
ing.SetAnnotations(data)
|
||||
|
||||
_, dir, _ := dummySecretContent(t)
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
expected := ing_errors.LocationDenied{
|
||||
Reason: errors.New("error reading secret name from annotation: annotation nginx.ingress.kubernetes.io/auth-secret contains invalid value"),
|
||||
}
|
||||
_, err := NewParser(dir, &mockSecret{}).Parse(ing)
|
||||
if err.Error() != expected.Error() {
|
||||
t.Errorf("expected '%v' but got '%v'", expected, err)
|
||||
|
|
@ -123,7 +207,7 @@ func TestInvalidIngressAuthNoSecret(t *testing.T) {
|
|||
ing := buildIngress()
|
||||
|
||||
data := map[string]string{}
|
||||
data[parser.GetAnnotationWithPrefix("auth-type")] = "basic"
|
||||
data[parser.GetAnnotationWithPrefix(authTypeAnnotation)] = "basic"
|
||||
ing.SetAnnotations(data)
|
||||
|
||||
_, dir, _ := dummySecretContent(t)
|
||||
|
|
@ -142,9 +226,9 @@ func TestIngressAuth(t *testing.T) {
|
|||
ing := buildIngress()
|
||||
|
||||
data := map[string]string{}
|
||||
data[parser.GetAnnotationWithPrefix("auth-type")] = "basic"
|
||||
data[parser.GetAnnotationWithPrefix("auth-secret")] = "demo-secret"
|
||||
data[parser.GetAnnotationWithPrefix("auth-realm")] = "-realm-"
|
||||
data[parser.GetAnnotationWithPrefix(authTypeAnnotation)] = "basic"
|
||||
data[parser.GetAnnotationWithPrefix(AuthSecretAnnotation)] = "demo-secret"
|
||||
data[parser.GetAnnotationWithPrefix(authRealmAnnotation)] = "-realm-"
|
||||
ing.SetAnnotations(data)
|
||||
|
||||
_, dir, _ := dummySecretContent(t)
|
||||
|
|
@ -173,9 +257,9 @@ func TestIngressAuthWithoutSecret(t *testing.T) {
|
|||
ing := buildIngress()
|
||||
|
||||
data := map[string]string{}
|
||||
data[parser.GetAnnotationWithPrefix("auth-type")] = "basic"
|
||||
data[parser.GetAnnotationWithPrefix("auth-secret")] = "invalid-secret"
|
||||
data[parser.GetAnnotationWithPrefix("auth-realm")] = "-realm-"
|
||||
data[parser.GetAnnotationWithPrefix(authTypeAnnotation)] = "basic"
|
||||
data[parser.GetAnnotationWithPrefix(AuthSecretAnnotation)] = "invalid-secret"
|
||||
data[parser.GetAnnotationWithPrefix(authRealmAnnotation)] = "-realm-"
|
||||
ing.SetAnnotations(data)
|
||||
|
||||
_, dir, _ := dummySecretContent(t)
|
||||
|
|
@ -191,10 +275,10 @@ func TestIngressAuthInvalidSecretKey(t *testing.T) {
|
|||
ing := buildIngress()
|
||||
|
||||
data := map[string]string{}
|
||||
data[parser.GetAnnotationWithPrefix("auth-type")] = "basic"
|
||||
data[parser.GetAnnotationWithPrefix("auth-secret")] = "demo-secret"
|
||||
data[parser.GetAnnotationWithPrefix("auth-secret-type")] = "invalid-type"
|
||||
data[parser.GetAnnotationWithPrefix("auth-realm")] = "-realm-"
|
||||
data[parser.GetAnnotationWithPrefix(authTypeAnnotation)] = "basic"
|
||||
data[parser.GetAnnotationWithPrefix(AuthSecretAnnotation)] = "demo-secret"
|
||||
data[parser.GetAnnotationWithPrefix(authSecretTypeAnnotation)] = "invalid-type"
|
||||
data[parser.GetAnnotationWithPrefix(authRealmAnnotation)] = "-realm-"
|
||||
ing.SetAnnotations(data)
|
||||
|
||||
_, dir, _ := dummySecretContent(t)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue