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
This commit is contained in:
Gauthier
2025-05-09 13:15:18 +02:00
committed by GitHub
parent 45f25408c6
commit 437bf0f4ee
9 changed files with 53 additions and 32 deletions

View File

@@ -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',

View File

@@ -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', {

View File

@@ -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', {

View File

@@ -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',

View File

@@ -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<SettingsJellyfinProps> = ({
),
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<SettingsJellyfinProps> = ({
),
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),

View File

@@ -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',

View File

@@ -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',

View File

@@ -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',

View File

@@ -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;
}
}