Skip to content

Commit 7842393

Browse files
momesginMo Mesgin
andauthored
CA cert bundle validation (#17632)
* add validation to ca bundle certificate field + info tooltip * add isBase64EncodedCert helper * fix validation update issue with removed items + comments --------- Co-authored-by: Mo Mesgin <mmesgin@Mos-M2-MacBook-Pro.local>
1 parent 143ff28 commit 7842393

7 files changed

Lines changed: 227 additions & 16 deletions

File tree

shell/assets/translations/en-us.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8791,6 +8791,9 @@ registryConfig:
87918791
toolTip: 'When an image needs to be pulled from the given registry hostname, this information will be used to verify the identity of the registry and authenticate to it.'
87928792
addLabel: Add Registry
87938793
description: "Define the TLS and credential configuration for each registry hostname and mirror."
8794+
caBundle:
8795+
tooltip: Accepts a CA PEM certificate or a base64 encoded CA PEM.
8796+
validationError: Invalid CA cert bundle. Must be a CA PEM certificate or a base64 encoded CA PEM.
87948797

87958798
##############################
87968799
### Advanced Settings

shell/edit/provisioning.cattle.io.cluster/rke2.vue

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,7 @@ export default {
298298
isEmpty,
299299
AGENT_CONFIGURATION_TYPES,
300300
basicsValid: true,
301+
registryConfigValid: true,
301302
originalIngressController: this.value.spec.rkeConfig.machineGlobalConfig?.[INGRESS_CONTROLLER] || INGRESS_NONE,
302303
};
303304
},
@@ -906,7 +907,8 @@ export default {
906907
return this.validationPassed &&
907908
this.fvFormIsValid &&
908909
this.etcdConfigValid &&
909-
this.basicsValid;
910+
this.basicsValid &&
911+
this.registryConfigValid;
910912
},
911913
nginxSupported() {
912914
if (this.serverArgs?.disable?.options.includes(RKE2_INGRESS_NGINX)) {
@@ -2688,6 +2690,7 @@ export default {
26882690
<Tab
26892691
:name="REGISTRIES_TAB_NAME"
26902692
label-key="cluster.tabs.registry"
2693+
:error="!registryConfigValid"
26912694
>
26922695
<Registries
26932696
v-if="isActiveTabRegistries"
@@ -2703,6 +2706,7 @@ export default {
27032706
@custom-registry-changed="toggleCustomRegistry"
27042707
@registry-host-changed="handleRegistryHostChanged"
27052708
@registry-secret-changed="handleRegistrySecretChanged"
2709+
@registry-validation-changed="(val) => registryConfigValid = val"
27062710
/>
27072711
</Tab>
27082712

shell/edit/provisioning.cattle.io.cluster/tabs/registries/RegistryConfigs.vue

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@ import SelectOrCreateAuthSecret from '@shell/components/form/SelectOrCreateAuthS
77
import CreateEditView from '@shell/mixins/create-edit-view';
88
import SecretSelector from '@shell/components/form/SecretSelector';
99
import { SECRET_TYPES as TYPES } from '@shell/config/secret';
10-
import { isBase64 } from '@shell/utils/string';
10+
import { isBase64EncodedCert } from '@shell/utils/string';
1111
import { base64Decode, base64Encode } from '@shell/utils/crypto';
1212
1313
export default {
14-
emits: ['updateConfigs'],
14+
emits: ['updateConfigs', 'validation-changed'],
1515
1616
components: {
1717
ArrayListGrouped,
@@ -62,6 +62,34 @@ export default {
6262
return TYPES.TLS;
6363
},
6464
},
65+
66+
caBundleRules() {
67+
return [
68+
(value) => {
69+
if (!value) {
70+
return undefined;
71+
}
72+
73+
const isPem = value.trimStart().startsWith('-----BEGIN ');
74+
const isValidBase64 = isBase64EncodedCert(value);
75+
76+
return (!isPem && !isValidBase64) ? this.t('registryConfig.caBundle.validationError') : undefined;
77+
}
78+
];
79+
},
80+
81+
// Derives validation state from entries directly, so it auto-updates when entries are added or removed
82+
allCaBundlesValid() {
83+
return this.entries.every((entry) => {
84+
return this.caBundleRules.every((rule) => !rule(entry.caBundle));
85+
});
86+
},
87+
},
88+
89+
watch: {
90+
allCaBundlesValid(valid) {
91+
this.$emit('validation-changed', valid);
92+
},
6593
},
6694
6795
mounted() {
@@ -78,7 +106,8 @@ export default {
78106
79107
const caBundle = configMap[hostname].caBundle ?? this.defaultAddValue.caBundle;
80108
81-
configMap[hostname].caBundle = isBase64(caBundle) ? base64Decode(caBundle) : caBundle;
109+
// Decode base64 for display so the user sees readable PEM text
110+
configMap[hostname].caBundle = isBase64EncodedCert(caBundle) ? base64Decode(caBundle) : caBundle;
82111
83112
configMap[hostname].tlsSecretName = configMap[hostname].tlsSecretName ?? this.defaultAddValue.tlsSecretName;
84113
}
@@ -103,7 +132,8 @@ export default {
103132
104133
configs[h] = {
105134
...entry,
106-
caBundle: entry.caBundle ? base64Encode(entry.caBundle) : null
135+
// If the value is already base64, use as-is to avoid double-encoding
136+
caBundle: entry.caBundle ? (isBase64EncodedCert(entry.caBundle) ? entry.caBundle : base64Encode(entry.caBundle)) : null
107137
};
108138
109139
delete configs[h].hostname;
@@ -192,6 +222,9 @@ export default {
192222
type="multiline"
193223
label="CA Cert Bundle"
194224
:mode="mode"
225+
:rules="caBundleRules"
226+
:require-dirty="false"
227+
:tooltip="t('registryConfig.caBundle.tooltip')"
195228
/>
196229
197230
<div>

shell/edit/provisioning.cattle.io.cluster/tabs/registries/__tests__/RegistryConfigs.test.ts

Lines changed: 132 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ import { _EDIT } from '@shell/config/query-params';
44
import { PROV_CLUSTER } from '@shell/edit/provisioning.cattle.io.cluster/__tests__/utils/cluster';
55
import RegistryConfigs from '@shell/edit/provisioning.cattle.io.cluster/tabs/registries/RegistryConfigs.vue';
66

7+
const VALID_BASE64_CERT = 'LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0t';
8+
const VALID_PEM_TEXT = '-----BEGIN CERTIFICATE-----\nMIIBkTCB+wIJA';
9+
710
describe('component: RegistryConfigs', () => {
811
let wrapper: Wrapper<InstanceType<typeof RegistryConfigs> & { [key: string]: any }>;
912

@@ -18,14 +21,14 @@ describe('component: RegistryConfigs', () => {
1821
SelectOrCreateAuthSecret: true,
1922
SecretSelector: true,
2023
},
21-
mocks: { $store: { getters: { 'i18n/t': jest.fn() } } }
24+
mocks: { $store: { getters: { 'i18n/t': jest.fn((key: string) => key) } } }
2225
}
2326
};
2427

2528
describe('key CA Cert Bundle', () => {
2629
it.each([
27-
['source is plain text', 'Zm9vYmFy', 'foobar'],
28-
['source is base64', 'foobar', 'foobar'],
30+
['source is base64', VALID_BASE64_CERT, '-----BEGIN CERTIFICATE-----'],
31+
['source is plain text', 'foobar', 'foobar'],
2932
])('should display key, %p', (_, sourceCaBundle, displayedCaBundle) => {
3033
const value = clone(PROV_CLUSTER);
3134

@@ -43,10 +46,10 @@ describe('component: RegistryConfigs', () => {
4346
expect(registry.props().value).toBe(displayedCaBundle);
4447
});
4548

46-
it('should update key in base64 format', async() => {
49+
it('should base64 encode plain PEM text on save', async() => {
4750
const value = clone(PROV_CLUSTER);
4851

49-
value.spec.rkeConfig.registries.configs = { foo: { caBundle: 'Zm9vYmFy' } };
52+
value.spec.rkeConfig.registries.configs = { foo: { caBundle: VALID_BASE64_CERT } };
5053

5154
mountOptions.propsData.value = value;
5255

@@ -57,10 +60,132 @@ describe('component: RegistryConfigs', () => {
5760

5861
const registry = wrapper.findComponent('[data-testid^="registry-caBundle"]');
5962

60-
registry.vm.$emit('update:value', 'ssh key');
63+
registry.vm.$emit('update:value', VALID_PEM_TEXT);
6164
wrapper.vm.update();
6265

63-
expect(wrapper.emitted('updateConfigs')[0][0]['foo']['caBundle']).toBe('c3NoIGtleQ==');
66+
expect(wrapper.emitted('updateConfigs')[0][0]['foo']['caBundle']).toBe('LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJrVENCK3dJSkE=');
67+
});
68+
69+
it('should keep base64 value as-is on save', async() => {
70+
const value = clone(PROV_CLUSTER);
71+
72+
value.spec.rkeConfig.registries.configs = { foo: { caBundle: VALID_BASE64_CERT } };
73+
74+
mountOptions.propsData.value = value;
75+
76+
wrapper = mount(
77+
RegistryConfigs,
78+
mountOptions
79+
);
80+
81+
const registry = wrapper.findComponent('[data-testid^="registry-caBundle"]');
82+
83+
registry.vm.$emit('update:value', VALID_BASE64_CERT);
84+
wrapper.vm.update();
85+
86+
expect(wrapper.emitted('updateConfigs')[0][0]['foo']['caBundle']).toBe(VALID_BASE64_CERT);
87+
});
88+
});
89+
90+
describe('cA Cert Bundle validation', () => {
91+
it('should pass validation for valid base64 value', () => {
92+
const value = clone(PROV_CLUSTER);
93+
94+
value.spec.rkeConfig.registries.configs = {};
95+
mountOptions.propsData.value = value;
96+
97+
wrapper = mount(RegistryConfigs, mountOptions);
98+
99+
const rule = wrapper.vm.caBundleRules[0];
100+
101+
expect(rule(VALID_BASE64_CERT)).toBeUndefined();
102+
});
103+
104+
it('should pass validation for PEM text', () => {
105+
const value = clone(PROV_CLUSTER);
106+
107+
value.spec.rkeConfig.registries.configs = {};
108+
mountOptions.propsData.value = value;
109+
110+
wrapper = mount(RegistryConfigs, mountOptions);
111+
112+
const rule = wrapper.vm.caBundleRules[0];
113+
114+
expect(rule(VALID_PEM_TEXT)).toBeUndefined();
115+
});
116+
117+
it('should pass validation for empty value', () => {
118+
const value = clone(PROV_CLUSTER);
119+
120+
value.spec.rkeConfig.registries.configs = {};
121+
mountOptions.propsData.value = value;
122+
123+
wrapper = mount(RegistryConfigs, mountOptions);
124+
125+
const rule = wrapper.vm.caBundleRules[0];
126+
127+
expect(rule('')).toBeUndefined();
128+
expect(rule(null)).toBeUndefined();
129+
expect(rule(undefined)).toBeUndefined();
130+
});
131+
132+
it('should fail validation for invalid value', () => {
133+
const value = clone(PROV_CLUSTER);
134+
135+
value.spec.rkeConfig.registries.configs = {};
136+
mountOptions.propsData.value = value;
137+
138+
wrapper = mount(RegistryConfigs, mountOptions);
139+
140+
const rule = wrapper.vm.caBundleRules[0];
141+
142+
expect(rule('not-valid-base64!')).toContain('registryConfig.caBundle.validationError');
143+
});
144+
145+
it('should report allCaBundlesValid as true when all entries have valid caBundles', () => {
146+
const value = clone(PROV_CLUSTER);
147+
148+
value.spec.rkeConfig.registries.configs = {
149+
'reg1.example.com': { caBundle: VALID_BASE64_CERT },
150+
'reg2.example.com': { caBundle: VALID_BASE64_CERT },
151+
};
152+
mountOptions.propsData.value = value;
153+
154+
wrapper = mount(RegistryConfigs, mountOptions);
155+
156+
expect(wrapper.vm.allCaBundlesValid).toBe(true);
157+
});
158+
159+
it('should report allCaBundlesValid as false when any entry has an invalid caBundle', () => {
160+
const value = clone(PROV_CLUSTER);
161+
162+
value.spec.rkeConfig.registries.configs = {
163+
'reg1.example.com': { caBundle: VALID_BASE64_CERT },
164+
'reg2.example.com': { caBundle: 'not-valid!' },
165+
};
166+
mountOptions.propsData.value = value;
167+
168+
wrapper = mount(RegistryConfigs, mountOptions);
169+
170+
expect(wrapper.vm.allCaBundlesValid).toBe(false);
171+
});
172+
173+
it('should update validation when an invalid entry is removed', () => {
174+
const value = clone(PROV_CLUSTER);
175+
176+
value.spec.rkeConfig.registries.configs = {
177+
'reg1.example.com': { caBundle: VALID_BASE64_CERT },
178+
'reg2.example.com': { caBundle: 'not-valid!' },
179+
};
180+
mountOptions.propsData.value = value;
181+
182+
wrapper = mount(RegistryConfigs, mountOptions);
183+
184+
expect(wrapper.vm.allCaBundlesValid).toBe(false);
185+
186+
wrapper.vm.entries.splice(1, 1);
187+
188+
expect(wrapper.vm.allCaBundlesValid).toBe(true);
64189
});
65190
});
66191
});

shell/edit/provisioning.cattle.io.cluster/tabs/registries/index.vue

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import RegistryConfigs from '@shell/edit/provisioning.cattle.io.cluster/tabs/reg
88
import RegistryMirrors from '@shell/edit/provisioning.cattle.io.cluster/tabs/registries/RegistryMirrors';
99
1010
export default {
11-
emits: ['custom-registry-changed', 'registry-host-changed', 'registry-secret-changed', 'input', 'update-configs-changed'],
11+
emits: ['custom-registry-changed', 'registry-host-changed', 'registry-secret-changed', 'input', 'update-configs-changed', 'registry-validation-changed'],
1212
components: {
1313
LabeledInput,
1414
Banner,
@@ -142,6 +142,7 @@ export default {
142142
:cluster-register-before-hook="registerBeforeHook"
143143
@update:value="$emit('input', $event)"
144144
@updateConfigs="$emit('update-configs-changed', $event)"
145+
@validation-changed="$emit('registry-validation-changed', $event)"
145146
/>
146147
</AdvancedSection>
147148
</div>

shell/utils/__tests__/string.test.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { decodeHtml, resourceNames, pathArrayToTree } from '@shell/utils/string';
1+
import { decodeHtml, resourceNames, pathArrayToTree, isBase64EncodedCert } from '@shell/utils/string';
22

33
describe('fx: decodeHtml', () => {
44
it('should decode HTML values from escaped string into valid markup', () => {
@@ -361,3 +361,25 @@ describe('fx: pathArrayToTree', () => {
361361
expect(actual).toStrictEqual(expected);
362362
});
363363
});
364+
365+
describe('fx: isBase64EncodedCert', () => {
366+
it.each([
367+
['valid base64 cert', 'LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0t', true],
368+
['base64 with padding', 'c3NoIGtleQ==', false],
369+
['base64 with newlines', 'LS0tLS1CRUdJTiBDRVJU\nSUZJQ0FURS0tLS0t\nTUlJQmtUQ0I=', true],
370+
['base64 with CRLF', 'LS0tLS1CRUdJTiBDRVJU\r\nSUZJQ0FURS0tLS0t\r\nTUlJQmtUQ0I=', true],
371+
])('should return %p for %p', (_, value, expected) => {
372+
expect(isBase64EncodedCert(value as string)).toBe(expected);
373+
});
374+
375+
it.each([
376+
['empty string', ''],
377+
['null', null],
378+
['undefined', undefined],
379+
['short string like "test"', 'test'],
380+
['short valid base64', 'Zm9v'],
381+
['invalid characters', 'not-valid-base64!'],
382+
])('should return false for %p', (_, value) => {
383+
expect(isBase64EncodedCert(value as string)).toBe(false);
384+
});
385+
});

shell/utils/string.js

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -349,10 +349,33 @@ export function xOfy(x, y) {
349349
return `${ typeof x === 'number' ? x : '?' }/${ typeof y === 'number' ? y : '?' }`;
350350
}
351351

352+
const BASE64_REGEX = /^([0-9a-zA-Z+/]{4})*(([0-9a-zA-Z+/]{2}==)|([0-9a-zA-Z+/]{3}=))?$/;
353+
352354
export function isBase64(value) {
353-
const base64regex = /^([0-9a-zA-Z+/]{4})*(([0-9a-zA-Z+/]{2}==)|([0-9a-zA-Z+/]{3}=))?$/;
355+
return BASE64_REGEX.test(value);
356+
}
357+
358+
/**
359+
* Checks if a value is a valid base64-encoded CA bundle.
360+
* Unlike isBase64, this handles multiline base64 (e.g. openssl wraps at 76 chars)
361+
* and rejects short strings that could be false positives.
362+
* @param {string} value
363+
* @returns {boolean}
364+
*/
365+
export function isBase64EncodedCert(value) {
366+
if (!value || typeof value !== 'string') {
367+
return false;
368+
}
369+
370+
// Strip whitespace to handle line-wrapped base64 output
371+
const stripped = value.replace(/\s/g, '');
372+
373+
// CA certs are long enough that legitimate base64 will always exceed this
374+
if (stripped.length < 16) {
375+
return false;
376+
}
354377

355-
return base64regex.test(value);
378+
return BASE64_REGEX.test(stripped);
356379
}
357380

358381
export function generateRandomAlphaString(length) {

0 commit comments

Comments
 (0)