From 437bf0f4ee9e1e57a829683ce4c8756efaed3415 Mon Sep 17 00:00:00 2001 From: Gauthier Date: Fri, 9 May 2025 13:15:18 +0200 Subject: [PATCH] refactor(url validation): replace regex-based URL validation by the JavaScript URL constructor (#1650) Replaced regex-based URL validation with the native JavaScript URL constructor to improve reliability. This approach should be more robust and should help prevent bugs like the one we previously encountered with malformed regex. fix #1539 --- .../Notifications/NotificationsGotify/index.tsx | 9 +++++---- .../Notifications/NotificationsNtfy/index.tsx | 9 +++++---- .../Notifications/NotificationsWebhook/index.tsx | 9 +++++---- src/components/Settings/RadarrModal/index.tsx | 8 +++++--- src/components/Settings/SettingsJellyfin.tsx | 11 +++-------- src/components/Settings/SettingsMain/index.tsx | 8 +++++--- src/components/Settings/SettingsPlex.tsx | 8 +++++--- src/components/Settings/SonarrModal/index.tsx | 8 +++++--- src/utils/urlValidationHelper.ts | 15 +++++++++++++++ 9 files changed, 53 insertions(+), 32 deletions(-) create mode 100644 src/utils/urlValidationHelper.ts diff --git a/src/components/Settings/Notifications/NotificationsGotify/index.tsx b/src/components/Settings/Notifications/NotificationsGotify/index.tsx index 6c33f6dbc..461103511 100644 --- a/src/components/Settings/Notifications/NotificationsGotify/index.tsx +++ b/src/components/Settings/Notifications/NotificationsGotify/index.tsx @@ -3,6 +3,7 @@ import LoadingSpinner from '@app/components/Common/LoadingSpinner'; import NotificationTypeSelector from '@app/components/NotificationTypeSelector'; import globalMessages from '@app/i18n/globalMessages'; import defineMessages from '@app/utils/defineMessages'; +import { isValidURL } from '@app/utils/urlValidationHelper'; import { ArrowDownOnSquareIcon, BeakerIcon } from '@heroicons/react/24/solid'; import axios from 'axios'; import { Field, Form, Formik } from 'formik'; @@ -51,10 +52,10 @@ const NotificationsGotify = () => { .required(intl.formatMessage(messages.validationUrlRequired)), otherwise: Yup.string().nullable(), }) - .matches( - // eslint-disable-next-line no-useless-escape - /^(https?:)?\/\/(((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:)*@)?(((\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5]))|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]))(:\d*)?)(\/((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)+(\/(([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)*)*)?)?(\?((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|[\uE000-\uF8FF]|\/|\?)*)?(\#((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|\/|\?)*)?$/i, - intl.formatMessage(messages.validationUrlRequired) + .test( + 'valid-url', + intl.formatMessage(messages.validationUrlRequired), + isValidURL ) .test( 'no-trailing-slash', diff --git a/src/components/Settings/Notifications/NotificationsNtfy/index.tsx b/src/components/Settings/Notifications/NotificationsNtfy/index.tsx index 30906a83e..3ffdd55c0 100644 --- a/src/components/Settings/Notifications/NotificationsNtfy/index.tsx +++ b/src/components/Settings/Notifications/NotificationsNtfy/index.tsx @@ -4,6 +4,7 @@ import SensitiveInput from '@app/components/Common/SensitiveInput'; import NotificationTypeSelector from '@app/components/NotificationTypeSelector'; import globalMessages from '@app/i18n/globalMessages'; import defineMessages from '@app/utils/defineMessages'; +import { isValidURL } from '@app/utils/urlValidationHelper'; import { ArrowDownOnSquareIcon, BeakerIcon } from '@heroicons/react/24/outline'; import axios from 'axios'; import { Field, Form, Formik } from 'formik'; @@ -54,10 +55,10 @@ const NotificationsNtfy = () => { .required(intl.formatMessage(messages.validationNtfyUrl)), otherwise: Yup.string().nullable(), }) - .matches( - // eslint-disable-next-line no-useless-escape - /^(https?:)?\/\/(((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:)*@)?(((\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5]))|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]))(:\d*)?)(\/((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)+(\/(([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)*)*)?)?(\?((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|[\uE000-\uF8FF]|\/|\?)*)?(\#((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|\/|\?)*)?$/i, - intl.formatMessage(messages.validationNtfyUrl) + .test( + 'valid-url', + intl.formatMessage(messages.validationNtfyUrl), + isValidURL ), topic: Yup.string() .when('enabled', { diff --git a/src/components/Settings/Notifications/NotificationsWebhook/index.tsx b/src/components/Settings/Notifications/NotificationsWebhook/index.tsx index 44f9dfa0e..0595090f3 100644 --- a/src/components/Settings/Notifications/NotificationsWebhook/index.tsx +++ b/src/components/Settings/Notifications/NotificationsWebhook/index.tsx @@ -3,6 +3,7 @@ import LoadingSpinner from '@app/components/Common/LoadingSpinner'; import NotificationTypeSelector from '@app/components/NotificationTypeSelector'; import globalMessages from '@app/i18n/globalMessages'; import defineMessages from '@app/utils/defineMessages'; +import { isValidURL } from '@app/utils/urlValidationHelper'; import { ArrowDownOnSquareIcon, BeakerIcon } from '@heroicons/react/24/outline'; import { ArrowPathIcon, @@ -107,10 +108,10 @@ const NotificationsWebhook = () => { .required(intl.formatMessage(messages.validationWebhookUrl)), otherwise: Yup.string().nullable(), }) - .matches( - // eslint-disable-next-line no-useless-escape - /^(https?:)?\/\/(((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:)*@)?(((\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5]))|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]))(:\d*)?)(\/((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)+(\/(([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)*)*)?)?(\?((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|[\uE000-\uF8FF]|\/|\?)*)?(\#((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|\/|\?)*)?$/i, - intl.formatMessage(messages.validationWebhookUrl) + .test( + 'valid-url', + intl.formatMessage(messages.validationWebhookUrl), + isValidURL ), jsonPayload: Yup.string() .when('enabled', { diff --git a/src/components/Settings/RadarrModal/index.tsx b/src/components/Settings/RadarrModal/index.tsx index 38228bf03..bf22e94d0 100644 --- a/src/components/Settings/RadarrModal/index.tsx +++ b/src/components/Settings/RadarrModal/index.tsx @@ -3,6 +3,7 @@ import SensitiveInput from '@app/components/Common/SensitiveInput'; import type { RadarrTestResponse } from '@app/components/Settings/SettingsServices'; import globalMessages from '@app/i18n/globalMessages'; import defineMessages from '@app/utils/defineMessages'; +import { isValidURL } from '@app/utils/urlValidationHelper'; import { Transition } from '@headlessui/react'; import type { RadarrSettings } from '@server/lib/settings'; import axios from 'axios'; @@ -117,9 +118,10 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => { intl.formatMessage(messages.validationMinimumAvailabilityRequired) ), externalUrl: Yup.string() - .matches( - /^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}(\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*))?$/i, - intl.formatMessage(messages.validationApplicationUrl) + .test( + 'valid-url', + intl.formatMessage(messages.validationApplicationUrl), + isValidURL ) .test( 'no-trailing-slash', diff --git a/src/components/Settings/SettingsJellyfin.tsx b/src/components/Settings/SettingsJellyfin.tsx index e66519f08..b4d36cfe5 100644 --- a/src/components/Settings/SettingsJellyfin.tsx +++ b/src/components/Settings/SettingsJellyfin.tsx @@ -6,6 +6,7 @@ import LibraryItem from '@app/components/Settings/LibraryItem'; import useSettings from '@app/hooks/useSettings'; import globalMessages from '@app/i18n/globalMessages'; import defineMessages from '@app/utils/defineMessages'; +import { isValidURL } from '@app/utils/urlValidationHelper'; import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline'; import { ApiErrorCode } from '@server/constants/error'; import { MediaServerType } from '@server/constants/server'; @@ -140,10 +141,7 @@ const SettingsJellyfin: React.FC = ({ ), jellyfinExternalUrl: Yup.string() .nullable() - .matches( - /^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}(\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*))?$/i, - intl.formatMessage(messages.validationUrl) - ) + .test('valid-url', intl.formatMessage(messages.validationUrl), isValidURL) .test( 'no-trailing-slash', intl.formatMessage(messages.validationUrlTrailingSlash), @@ -151,10 +149,7 @@ const SettingsJellyfin: React.FC = ({ ), jellyfinForgotPasswordUrl: Yup.string() .nullable() - .matches( - /^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}(\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*))?$/i, - intl.formatMessage(messages.validationUrl) - ) + .test('valid-url', intl.formatMessage(messages.validationUrl), isValidURL) .test( 'no-trailing-slash', intl.formatMessage(messages.validationUrlTrailingSlash), diff --git a/src/components/Settings/SettingsMain/index.tsx b/src/components/Settings/SettingsMain/index.tsx index d1e809a28..d4f52d1b4 100644 --- a/src/components/Settings/SettingsMain/index.tsx +++ b/src/components/Settings/SettingsMain/index.tsx @@ -13,6 +13,7 @@ import useLocale from '@app/hooks/useLocale'; import { Permission, useUser } from '@app/hooks/useUser'; import globalMessages from '@app/i18n/globalMessages'; import defineMessages from '@app/utils/defineMessages'; +import { isValidURL } from '@app/utils/urlValidationHelper'; import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline'; import { ArrowPathIcon } from '@heroicons/react/24/solid'; import type { UserSettingsGeneralResponse } from '@server/interfaces/api/userSettingsInterfaces'; @@ -88,9 +89,10 @@ const SettingsMain = () => { intl.formatMessage(messages.validationApplicationTitle) ), applicationUrl: Yup.string() - .matches( - /^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}(\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*))?$/i, - intl.formatMessage(messages.validationApplicationUrl) + .test( + 'valid-url', + intl.formatMessage(messages.validationApplicationUrl), + isValidURL ) .test( 'no-trailing-slash', diff --git a/src/components/Settings/SettingsPlex.tsx b/src/components/Settings/SettingsPlex.tsx index 973c5ede4..2c765597d 100644 --- a/src/components/Settings/SettingsPlex.tsx +++ b/src/components/Settings/SettingsPlex.tsx @@ -8,6 +8,7 @@ import LibraryItem from '@app/components/Settings/LibraryItem'; import SettingsBadge from '@app/components/Settings/SettingsBadge'; import globalMessages from '@app/i18n/globalMessages'; import defineMessages from '@app/utils/defineMessages'; +import { isValidURL } from '@app/utils/urlValidationHelper'; import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline'; import { ArrowPathIcon, @@ -191,9 +192,10 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => { otherwise: Yup.string().nullable(), }), tautulliExternalUrl: Yup.string() - .matches( - /^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}(\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*))?$/i, - intl.formatMessage(messages.validationUrl) + .test( + 'valid-url', + intl.formatMessage(messages.validationUrl), + isValidURL ) .test( 'no-trailing-slash', diff --git a/src/components/Settings/SonarrModal/index.tsx b/src/components/Settings/SonarrModal/index.tsx index 9fb28d9d1..90f4953cc 100644 --- a/src/components/Settings/SonarrModal/index.tsx +++ b/src/components/Settings/SonarrModal/index.tsx @@ -3,6 +3,7 @@ import SensitiveInput from '@app/components/Common/SensitiveInput'; import type { SonarrTestResponse } from '@app/components/Settings/SettingsServices'; import globalMessages from '@app/i18n/globalMessages'; import defineMessages from '@app/utils/defineMessages'; +import { isValidURL } from '@app/utils/urlValidationHelper'; import { Transition } from '@headlessui/react'; import type { SonarrSettings } from '@server/lib/settings'; import axios from 'axios'; @@ -126,9 +127,10 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => { ) : Yup.number(), externalUrl: Yup.string() - .matches( - /^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}(\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*))?$/i, - intl.formatMessage(messages.validationApplicationUrl) + .test( + 'valid-url', + intl.formatMessage(messages.validationApplicationUrl), + isValidURL ) .test( 'no-trailing-slash', diff --git a/src/utils/urlValidationHelper.ts b/src/utils/urlValidationHelper.ts new file mode 100644 index 000000000..ffd81f50d --- /dev/null +++ b/src/utils/urlValidationHelper.ts @@ -0,0 +1,15 @@ +export function isValidURL(value: unknown) { + try { + let url: URL; + if (typeof value === 'string') { + url = new URL(value); + } else if (value instanceof URL) { + url = value; + } else { + return false; + } + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } +}