From e09397a9292b7dbb87e56a97c4377b78fa2cb07e Mon Sep 17 00:00:00 2001 From: sowmya-sl Date: Thu, 30 Jul 2026 15:46:43 +0530 Subject: [PATCH] HELM-520: Warn users about insecure HTTP Helm repository URLs and auto-upgrade to HTTPS Show an inline warning when users enter an HTTP repository URL, alerting them that credentials may be exposed and content may be tampered with in transit. On submit, attempt to upgrade the URL to HTTPS by probing the server; fall back to HTTP if HTTPS is unavailable. Co-authored-by: Cursor --- .../helm-plugin/locales/en/helm-plugin.json | 1 + .../CreateHelmChartRepository.tsx | 7 ++ .../CreateHelmChartRepositoryFormEditor.tsx | 14 +++- ...ository-create-utils-https-upgrade.spec.ts | 67 +++++++++++++++++++ .../helmchartrepository-create-utils.ts | 19 ++++++ 5 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 frontend/packages/helm-plugin/src/components/forms/HelmChartRepository/__tests__/helmchartrepository-create-utils-https-upgrade.spec.ts diff --git a/frontend/packages/helm-plugin/locales/en/helm-plugin.json b/frontend/packages/helm-plugin/locales/en/helm-plugin.json index 7b35ec9c304..1c98ceea709 100644 --- a/frontend/packages/helm-plugin/locales/en/helm-plugin.json +++ b/frontend/packages/helm-plugin/locales/en/helm-plugin.json @@ -79,6 +79,7 @@ "HelmChartRepository": "HelmChartRepository", "Hide advanced options": "Hide advanced options", "Home page": "Home page", + "HTTP is unencrypted. Credentials may be exposed, and downloaded content may have been tampered with in transit. Use HTTPS whenever possible.": "HTTP is unencrypted. Credentials may be exposed, and downloaded content may have been tampered with in transit. Use HTTPS whenever possible.", "Install": "Install", "Install Helm chart from Helm registry.": "Install Helm chart from Helm registry.", "Install Helm chart from URL": "Install Helm chart from URL", diff --git a/frontend/packages/helm-plugin/src/components/forms/HelmChartRepository/CreateHelmChartRepository.tsx b/frontend/packages/helm-plugin/src/components/forms/HelmChartRepository/CreateHelmChartRepository.tsx index b95f667c7fa..e659a3c468c 100644 --- a/frontend/packages/helm-plugin/src/components/forms/HelmChartRepository/CreateHelmChartRepository.tsx +++ b/frontend/packages/helm-plugin/src/components/forms/HelmChartRepository/CreateHelmChartRepository.tsx @@ -26,6 +26,7 @@ import { getDefaultResource, convertToForm, convertToHelmChartRepository, + tryHttpsUpgrade, } from './helmchartrepository-create-utils'; import { validationSchema } from './helmchartrepository-validation-utils'; @@ -110,6 +111,12 @@ const CreateHelmChartRepository: FC = ({ HelmChartRepositoryRes = convertToHelmChartRepository(values.formData, namespace); } + const currentUrl = HelmChartRepositoryRes.spec?.connectionConfig?.url; + const upgradedUrl = await tryHttpsUpgrade(currentUrl); + if (upgradedUrl) { + HelmChartRepositoryRes.spec.connectionConfig.url = upgradedUrl; + } + const resourceCall = isEditForm ? k8sUpdateResource({ model: modelFor(referenceFor(HelmChartRepositoryRes)), diff --git a/frontend/packages/helm-plugin/src/components/forms/HelmChartRepository/CreateHelmChartRepositoryFormEditor.tsx b/frontend/packages/helm-plugin/src/components/forms/HelmChartRepository/CreateHelmChartRepositoryFormEditor.tsx index a3b339f8218..f86d4d401b9 100644 --- a/frontend/packages/helm-plugin/src/components/forms/HelmChartRepository/CreateHelmChartRepositoryFormEditor.tsx +++ b/frontend/packages/helm-plugin/src/components/forms/HelmChartRepository/CreateHelmChartRepositoryFormEditor.tsx @@ -1,6 +1,6 @@ import type { FC } from 'react'; import { useMemo } from 'react'; -import { TextInputTypes } from '@patternfly/react-core'; +import { TextInputTypes, Alert } from '@patternfly/react-core'; import type { FormikValues } from 'formik'; import { useFormikContext } from 'formik'; import * as fuzzy from 'fuzzysearch'; @@ -142,6 +142,18 @@ const CreateHelmChartRepositoryFormEditor: FC + {formData.repoUrl?.startsWith('http://') && ( + <> + + + )} { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + jest.useRealTimers(); + }); + + it('should return https URL when server responds with 200', async () => { + global.fetch = jest.fn().mockResolvedValue(new Response(null, { status: 200 })); + const result = await tryHttpsUpgrade('http://example.com/repo'); + expect(result).toBe('https://example.com/repo'); + expect(global.fetch).toHaveBeenCalledWith( + 'https://example.com/repo', + expect.objectContaining({ method: 'HEAD' }), + ); + }); + + it('should return null when server responds with a non-OK status', async () => { + global.fetch = jest.fn().mockResolvedValue(new Response(null, { status: 404 })); + const result = await tryHttpsUpgrade('http://example.com/repo'); + expect(result).toBeNull(); + }); + + it('should return null when server responds with 500', async () => { + global.fetch = jest.fn().mockResolvedValue(new Response(null, { status: 500 })); + const result = await tryHttpsUpgrade('http://example.com/repo'); + expect(result).toBeNull(); + }); + + it('should return null when server does not support HTTPS', async () => { + global.fetch = jest.fn().mockRejectedValue(new TypeError('Failed to fetch')); + const result = await tryHttpsUpgrade('http://example.com/repo'); + expect(result).toBeNull(); + }); + + it('should return null for HTTPS URLs', async () => { + const result = await tryHttpsUpgrade('https://example.com'); + expect(result).toBeNull(); + }); + + it('should return null for OCI URLs', async () => { + const result = await tryHttpsUpgrade('oci://registry.io/chart'); + expect(result).toBeNull(); + }); + + it('should return null for null or undefined input', async () => { + expect(await tryHttpsUpgrade(null)).toBeNull(); + expect(await tryHttpsUpgrade(undefined)).toBeNull(); + }); + + it('should return null when fetch times out', async () => { + global.fetch = jest.fn().mockImplementation( + (_url, options) => + new Promise((_resolve, reject) => { + options.signal.addEventListener('abort', () => reject(new DOMException('Aborted'))); + }), + ); + jest.useFakeTimers(); + const promise = tryHttpsUpgrade('http://slow-server.com/repo'); + jest.advanceTimersByTime(3000); + const result = await promise; + expect(result).toBeNull(); + }); +}); diff --git a/frontend/packages/helm-plugin/src/components/forms/HelmChartRepository/helmchartrepository-create-utils.ts b/frontend/packages/helm-plugin/src/components/forms/HelmChartRepository/helmchartrepository-create-utils.ts index 9413575ff60..1426be2b8b7 100644 --- a/frontend/packages/helm-plugin/src/components/forms/HelmChartRepository/helmchartrepository-create-utils.ts +++ b/frontend/packages/helm-plugin/src/components/forms/HelmChartRepository/helmchartrepository-create-utils.ts @@ -72,6 +72,25 @@ export const convertToHelmChartRepository = ( return newResource; }; +const HTTPS_PROBE_TIMEOUT_MS = 3000; + +export const tryHttpsUpgrade = async (httpUrl: string): Promise => { + if (!httpUrl?.startsWith('http://')) { + return null; + } + const httpsUrl = httpUrl.replace(/^http:\/\//, 'https://'); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), HTTPS_PROBE_TIMEOUT_MS); + try { + const response = await fetch(httpsUrl, { method: 'HEAD', signal: controller.signal }); + return response.ok ? httpsUrl : null; + } catch { + return null; + } finally { + clearTimeout(timeoutId); + } +}; + export const getDefaultResource = ( namespace: string, kindRef?: K8sResourceKindReference,