Skip to content

Commit 97b8962

Browse files
authored
validate password for local users (#1015)
- hash password - check username does not exists - user can't delete himself - user can't deactivate himself
1 parent a25920a commit 97b8962

File tree

8 files changed

+557
-10
lines changed

8 files changed

+557
-10
lines changed

docs.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,11 @@ places a `field.cattle.io/creatorId` annotation with the name of the user as the
104104

105105
If `field.cattle.io/no-creator-rbac` annotation is set, `field.cattle.io/creatorId` does not get set.
106106

107+
For secrets stored in the `cattle-local-user-passwords` namespace containing local users passwords:
108+
- Verifies the password has the minimum required length.
109+
- Verifies the password is not the same as the username.
110+
- Encrypts the password using pbkdf2.
111+
107112
#### On delete
108113

109114
Checks if there are any RoleBindings owned by this secret which provide access to a role granting access to this secret.
@@ -518,6 +523,10 @@ When a Token is updated, the following checks take place:
518523

519524
### Validation Checks
520525

526+
#### Create
527+
528+
Verifies there aren't any other users with the same username.
529+
521530
#### Update and Delete
522531

523532
When a user is updated or deleted, a check occurs to ensure that the user making the request has permissions greater than or equal to the user being updated or deleted. To get the user's groups, the user's UserAttributes are checked. This is best effort, because UserAttributes are only updated when a User logs in, so it may not be perfectly up to date.
@@ -530,6 +539,8 @@ Users can update the following fields if they had not been set. But after gettin
530539

531540
- UserName
532541

542+
A user can't deactivate or delete himself.
543+
533544
## UserAttribute
534545

535546
### Validation Checks

pkg/resources/core/v1/secret/Secret.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ places a `field.cattle.io/creatorId` annotation with the name of the user as the
1212

1313
If `field.cattle.io/no-creator-rbac` annotation is set, `field.cattle.io/creatorId` does not get set.
1414

15+
For secrets stored in the `cattle-local-user-passwords` namespace containing local users passwords:
16+
- Verifies the password has the minimum required length.
17+
- Verifies the password is not the same as the username.
18+
- Encrypts the password using pbkdf2.
19+
1520
### On delete
1621

1722
Checks if there are any RoleBindings owned by this secret which provide access to a role granting access to this secret.

pkg/resources/core/v1/secret/mutator.go

Lines changed: 127 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
package secret
22

33
import (
4+
"crypto/pbkdf2"
5+
"crypto/rand"
6+
"crypto/sha3"
47
"fmt"
8+
"strconv"
9+
"unicode/utf8"
510

611
"github.com/rancher/webhook/pkg/admission"
12+
ctrlv3 "github.com/rancher/webhook/pkg/generated/controllers/management.cattle.io/v3"
713
objectsv1 "github.com/rancher/webhook/pkg/generated/objects/core/v1"
814
"github.com/rancher/webhook/pkg/patch"
915
"github.com/rancher/webhook/pkg/resources/common"
@@ -14,6 +20,7 @@ import (
1420
corev1 "k8s.io/api/core/v1"
1521
rbacv1 "k8s.io/api/rbac/v1"
1622
apierrors "k8s.io/apimachinery/pkg/api/errors"
23+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
1724
"k8s.io/apimachinery/pkg/runtime/schema"
1825
"k8s.io/utils/trace"
1926
)
@@ -22,22 +29,37 @@ const (
2229
mutatorRoleBindingOwnerIndex = "webhook.cattle.io/role-binding-index"
2330
secretKind = "Secret"
2431
ownerFormat = "%s/%s"
32+
localUserPasswordsNamespace = "cattle-local-user-passwords"
33+
passwordHashAnnotation = "cattle.io/password-hash"
34+
pbkdf2sha3512Hash = "pbkdf2sha3512"
35+
bcryptHash = "bcrypt"
36+
iterations = 210000
37+
keyLength = 32
38+
passwordMinLengthSetting = "password-min-length"
2539
)
2640

41+
type passwordHasher func(password string) ([]byte, []byte, error)
42+
2743
var gvr = corev1.SchemeGroupVersion.WithResource("secrets")
2844

2945
// Mutator implements admission.MutatingAdmissionWebhook.
3046
type Mutator struct {
3147
roleController v1.RoleController
3248
roleBindingController v1.RoleBindingController
49+
hasher passwordHasher
50+
settingCache ctrlv3.SettingCache
51+
userCache ctrlv3.UserCache
3352
}
3453

3554
// NewMutator returns a new mutator which mutates secret objects, and related resources
36-
func NewMutator(roleController v1.RoleController, roleBindingController v1.RoleBindingController) *Mutator {
55+
func NewMutator(roleController v1.RoleController, roleBindingController v1.RoleBindingController, settingCache ctrlv3.SettingCache, userCache ctrlv3.UserCache) *Mutator {
3756
roleBindingController.Cache().AddIndexer(mutatorRoleBindingOwnerIndex, roleBindingIndexer)
3857
return &Mutator{
3958
roleController: roleController,
4059
roleBindingController: roleBindingController,
60+
settingCache: settingCache,
61+
userCache: userCache,
62+
hasher: hashPassword,
4163
}
4264

4365
}
@@ -64,7 +86,7 @@ func (m *Mutator) GVR() schema.GroupVersionResource {
6486

6587
// Operations returns list of operations handled by this mutator.
6688
func (m *Mutator) Operations() []admissionregistrationv1.OperationType {
67-
return []admissionregistrationv1.OperationType{admissionregistrationv1.Create, admissionregistrationv1.Delete}
89+
return []admissionregistrationv1.OperationType{admissionregistrationv1.Create, admissionregistrationv1.Delete, admissionregistrationv1.Update}
6890
}
6991

7092
// MutatingWebhook returns the MutatingWebhook used for this CRD.
@@ -75,7 +97,7 @@ func (m *Mutator) MutatingWebhook(clientConfig admissionregistrationv1.WebhookCl
7597
mutatingWebhook.MatchConditions = []admissionregistrationv1.MatchCondition{
7698
{
7799
Name: "filter-by-secret-type-cloud-credential",
78-
Expression: `request.operation == 'DELETE' || (object != null && object.type == "provisioning.cattle.io/cloud-credential")`,
100+
Expression: `request.operation == 'DELETE' || (object != null && object.type == "provisioning.cattle.io/cloud-credential" && request.operation == 'CREATE') || (object != null && object.metadata.namespace == "` + localUserPasswordsNamespace + `")`,
79101
},
80102
}
81103

@@ -96,11 +118,18 @@ func (m *Mutator) Admit(request *admission.Request) (*admissionv1.AdmissionRespo
96118
if err != nil {
97119
return nil, err
98120
}
121+
if request.Namespace == localUserPasswordsNamespace {
122+
return m.admitLocalUserPassword(secret, request)
123+
}
99124
switch request.Operation {
100125
case admissionv1.Create:
101126
return m.admitCreate(secret, request)
102127
case admissionv1.Delete:
103128
return m.admitDelete(secret)
129+
case admissionv1.Update:
130+
return &admissionv1.AdmissionResponse{
131+
Allowed: true,
132+
}, nil
104133
default:
105134
return nil, fmt.Errorf("operation type %q not handled", request.Operation)
106135
}
@@ -179,3 +208,98 @@ func amendRulesToOnlyPermitDelete(rules []rbacv1.PolicyRule, secretName string)
179208
}
180209
return rules, amended
181210
}
211+
212+
// admitLocalUserPassword handles the secrets that contains the local user passwords, which are stored in the cattle-local-user-passwords namespace.
213+
// If the annotation cattle.io/password-hash is not present in the secret, the webhook will encrypt it using pbkdf2. The secret is mutated to include the hashed password, the salt and the user as owner reference.
214+
func (m *Mutator) admitLocalUserPassword(secret *corev1.Secret, request *admission.Request) (*admissionv1.AdmissionResponse, error) {
215+
if secret.Annotations[passwordHashAnnotation] == pbkdf2sha3512Hash ||
216+
secret.Annotations[passwordHashAnnotation] == bcryptHash ||
217+
request.Operation == admissionv1.Delete {
218+
// no need to do anything if password is encrypted or is a delete operation.
219+
return &admissionv1.AdmissionResponse{
220+
Allowed: true,
221+
}, nil
222+
}
223+
user, err := m.userCache.Get(secret.Name)
224+
if err != nil {
225+
if apierrors.IsNotFound(err) {
226+
return admission.ResponseBadRequest(fmt.Sprintf("user %s does not exist. User must be created before the secret", secret.Name)), nil
227+
}
228+
return nil, err
229+
}
230+
password := string(secret.Data["password"])
231+
passwordMinLength, err := m.getPasswordMinLength()
232+
if err != nil {
233+
return nil, err
234+
}
235+
if utf8.RuneCountInString(password) < passwordMinLength {
236+
return admission.ResponseBadRequest(fmt.Sprintf("password must be at least %v characters", passwordMinLength)), nil
237+
}
238+
if request.UserInfo.Username == password {
239+
return admission.ResponseBadRequest("password cannot be the same as username"), nil
240+
}
241+
hashedPassword, salt, err := m.hasher(password)
242+
if err != nil {
243+
return nil, err
244+
}
245+
response := &admissionv1.AdmissionResponse{}
246+
newSecret := secret.DeepCopy()
247+
if newSecret.Annotations == nil {
248+
newSecret.Annotations = map[string]string{}
249+
}
250+
newSecret.Annotations[passwordHashAnnotation] = pbkdf2sha3512Hash
251+
newSecret.Data["password"] = hashedPassword
252+
newSecret.Data["salt"] = salt
253+
newSecret.OwnerReferences = []metav1.OwnerReference{
254+
{
255+
APIVersion: user.APIVersion,
256+
Kind: user.Kind,
257+
Name: user.Name,
258+
UID: user.UID,
259+
},
260+
}
261+
if err := patch.CreatePatch(request.Object.Raw, newSecret, response); err != nil {
262+
return nil, fmt.Errorf("failed to create patch: %w", err)
263+
}
264+
response.Allowed = true
265+
266+
return response, nil
267+
}
268+
269+
// getPasswordMinLength gets the min length for passwords from the settings.
270+
func (m *Mutator) getPasswordMinLength() (int, error) {
271+
setting, err := m.settingCache.Get("password-min-length")
272+
if err != nil {
273+
return 0, err
274+
}
275+
var passwordMinLength int
276+
if setting.Value != "" {
277+
passwordMinLength, err = strconv.Atoi(setting.Value)
278+
if err != nil {
279+
return 0, err
280+
}
281+
} else {
282+
passwordMinLength, err = strconv.Atoi(setting.Default)
283+
if err != nil {
284+
return 0, err
285+
}
286+
}
287+
288+
return passwordMinLength, nil
289+
}
290+
291+
// hashPassword hashes the password using pbkdf2.
292+
func hashPassword(password string) ([]byte, []byte, error) {
293+
salt := make([]byte, 32)
294+
_, err := rand.Read(salt)
295+
if err != nil {
296+
return nil, nil, fmt.Errorf("failed to generate salt: %w", err)
297+
}
298+
299+
passwordHashed, err := pbkdf2.Key(sha3.New512, password, salt, iterations, keyLength)
300+
if err != nil {
301+
return nil, nil, err
302+
}
303+
304+
return passwordHashed, salt, nil
305+
}

0 commit comments

Comments
 (0)