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,