Skip to content

Commit 437bf0f

Browse files
authored
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
1 parent 45f2540 commit 437bf0f

File tree

9 files changed

+53
-32
lines changed

9 files changed

+53
-32
lines changed

src/components/Settings/Notifications/NotificationsGotify/index.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import LoadingSpinner from '@app/components/Common/LoadingSpinner';
33
import NotificationTypeSelector from '@app/components/NotificationTypeSelector';
44
import globalMessages from '@app/i18n/globalMessages';
55
import defineMessages from '@app/utils/defineMessages';
6+
import { isValidURL } from '@app/utils/urlValidationHelper';
67
import { ArrowDownOnSquareIcon, BeakerIcon } from '@heroicons/react/24/solid';
78
import axios from 'axios';
89
import { Field, Form, Formik } from 'formik';
@@ -51,10 +52,10 @@ const NotificationsGotify = () => {
5152
.required(intl.formatMessage(messages.validationUrlRequired)),
5253
otherwise: Yup.string().nullable(),
5354
})
54-
.matches(
55-
// eslint-disable-next-line no-useless-escape
56-
/^(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,
57-
intl.formatMessage(messages.validationUrlRequired)
55+
.test(
56+
'valid-url',
57+
intl.formatMessage(messages.validationUrlRequired),
58+
isValidURL
5859
)
5960
.test(
6061
'no-trailing-slash',

src/components/Settings/Notifications/NotificationsNtfy/index.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import SensitiveInput from '@app/components/Common/SensitiveInput';
44
import NotificationTypeSelector from '@app/components/NotificationTypeSelector';
55
import globalMessages from '@app/i18n/globalMessages';
66
import defineMessages from '@app/utils/defineMessages';
7+
import { isValidURL } from '@app/utils/urlValidationHelper';
78
import { ArrowDownOnSquareIcon, BeakerIcon } from '@heroicons/react/24/outline';
89
import axios from 'axios';
910
import { Field, Form, Formik } from 'formik';
@@ -54,10 +55,10 @@ const NotificationsNtfy = () => {
5455
.required(intl.formatMessage(messages.validationNtfyUrl)),
5556
otherwise: Yup.string().nullable(),
5657
})
57-
.matches(
58-
// eslint-disable-next-line no-useless-escape
59-
/^(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,
60-
intl.formatMessage(messages.validationNtfyUrl)
58+
.test(
59+
'valid-url',
60+
intl.formatMessage(messages.validationNtfyUrl),
61+
isValidURL
6162
),
6263
topic: Yup.string()
6364
.when('enabled', {

src/components/Settings/Notifications/NotificationsWebhook/index.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import LoadingSpinner from '@app/components/Common/LoadingSpinner';
33
import NotificationTypeSelector from '@app/components/NotificationTypeSelector';
44
import globalMessages from '@app/i18n/globalMessages';
55
import defineMessages from '@app/utils/defineMessages';
6+
import { isValidURL } from '@app/utils/urlValidationHelper';
67
import { ArrowDownOnSquareIcon, BeakerIcon } from '@heroicons/react/24/outline';
78
import {
89
ArrowPathIcon,
@@ -107,10 +108,10 @@ const NotificationsWebhook = () => {
107108
.required(intl.formatMessage(messages.validationWebhookUrl)),
108109
otherwise: Yup.string().nullable(),
109110
})
110-
.matches(
111-
// eslint-disable-next-line no-useless-escape
112-
/^(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,
113-
intl.formatMessage(messages.validationWebhookUrl)
111+
.test(
112+
'valid-url',
113+
intl.formatMessage(messages.validationWebhookUrl),
114+
isValidURL
114115
),
115116
jsonPayload: Yup.string()
116117
.when('enabled', {

src/components/Settings/RadarrModal/index.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import SensitiveInput from '@app/components/Common/SensitiveInput';
33
import type { RadarrTestResponse } from '@app/components/Settings/SettingsServices';
44
import globalMessages from '@app/i18n/globalMessages';
55
import defineMessages from '@app/utils/defineMessages';
6+
import { isValidURL } from '@app/utils/urlValidationHelper';
67
import { Transition } from '@headlessui/react';
78
import type { RadarrSettings } from '@server/lib/settings';
89
import axios from 'axios';
@@ -117,9 +118,10 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
117118
intl.formatMessage(messages.validationMinimumAvailabilityRequired)
118119
),
119120
externalUrl: Yup.string()
120-
.matches(
121-
/^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}(\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*))?$/i,
122-
intl.formatMessage(messages.validationApplicationUrl)
121+
.test(
122+
'valid-url',
123+
intl.formatMessage(messages.validationApplicationUrl),
124+
isValidURL
123125
)
124126
.test(
125127
'no-trailing-slash',

src/components/Settings/SettingsJellyfin.tsx

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import LibraryItem from '@app/components/Settings/LibraryItem';
66
import useSettings from '@app/hooks/useSettings';
77
import globalMessages from '@app/i18n/globalMessages';
88
import defineMessages from '@app/utils/defineMessages';
9+
import { isValidURL } from '@app/utils/urlValidationHelper';
910
import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline';
1011
import { ApiErrorCode } from '@server/constants/error';
1112
import { MediaServerType } from '@server/constants/server';
@@ -140,21 +141,15 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
140141
),
141142
jellyfinExternalUrl: Yup.string()
142143
.nullable()
143-
.matches(
144-
/^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}(\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*))?$/i,
145-
intl.formatMessage(messages.validationUrl)
146-
)
144+
.test('valid-url', intl.formatMessage(messages.validationUrl), isValidURL)
147145
.test(
148146
'no-trailing-slash',
149147
intl.formatMessage(messages.validationUrlTrailingSlash),
150148
(value) => !value || !value.endsWith('/')
151149
),
152150
jellyfinForgotPasswordUrl: Yup.string()
153151
.nullable()
154-
.matches(
155-
/^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}(\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*))?$/i,
156-
intl.formatMessage(messages.validationUrl)
157-
)
152+
.test('valid-url', intl.formatMessage(messages.validationUrl), isValidURL)
158153
.test(
159154
'no-trailing-slash',
160155
intl.formatMessage(messages.validationUrlTrailingSlash),

src/components/Settings/SettingsMain/index.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import useLocale from '@app/hooks/useLocale';
1313
import { Permission, useUser } from '@app/hooks/useUser';
1414
import globalMessages from '@app/i18n/globalMessages';
1515
import defineMessages from '@app/utils/defineMessages';
16+
import { isValidURL } from '@app/utils/urlValidationHelper';
1617
import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline';
1718
import { ArrowPathIcon } from '@heroicons/react/24/solid';
1819
import type { UserSettingsGeneralResponse } from '@server/interfaces/api/userSettingsInterfaces';
@@ -88,9 +89,10 @@ const SettingsMain = () => {
8889
intl.formatMessage(messages.validationApplicationTitle)
8990
),
9091
applicationUrl: Yup.string()
91-
.matches(
92-
/^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}(\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*))?$/i,
93-
intl.formatMessage(messages.validationApplicationUrl)
92+
.test(
93+
'valid-url',
94+
intl.formatMessage(messages.validationApplicationUrl),
95+
isValidURL
9496
)
9597
.test(
9698
'no-trailing-slash',

src/components/Settings/SettingsPlex.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import LibraryItem from '@app/components/Settings/LibraryItem';
88
import SettingsBadge from '@app/components/Settings/SettingsBadge';
99
import globalMessages from '@app/i18n/globalMessages';
1010
import defineMessages from '@app/utils/defineMessages';
11+
import { isValidURL } from '@app/utils/urlValidationHelper';
1112
import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline';
1213
import {
1314
ArrowPathIcon,
@@ -191,9 +192,10 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => {
191192
otherwise: Yup.string().nullable(),
192193
}),
193194
tautulliExternalUrl: Yup.string()
194-
.matches(
195-
/^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}(\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*))?$/i,
196-
intl.formatMessage(messages.validationUrl)
195+
.test(
196+
'valid-url',
197+
intl.formatMessage(messages.validationUrl),
198+
isValidURL
197199
)
198200
.test(
199201
'no-trailing-slash',

src/components/Settings/SonarrModal/index.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import SensitiveInput from '@app/components/Common/SensitiveInput';
33
import type { SonarrTestResponse } from '@app/components/Settings/SettingsServices';
44
import globalMessages from '@app/i18n/globalMessages';
55
import defineMessages from '@app/utils/defineMessages';
6+
import { isValidURL } from '@app/utils/urlValidationHelper';
67
import { Transition } from '@headlessui/react';
78
import type { SonarrSettings } from '@server/lib/settings';
89
import axios from 'axios';
@@ -126,9 +127,10 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
126127
)
127128
: Yup.number(),
128129
externalUrl: Yup.string()
129-
.matches(
130-
/^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}(\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*))?$/i,
131-
intl.formatMessage(messages.validationApplicationUrl)
130+
.test(
131+
'valid-url',
132+
intl.formatMessage(messages.validationApplicationUrl),
133+
isValidURL
132134
)
133135
.test(
134136
'no-trailing-slash',

src/utils/urlValidationHelper.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
export function isValidURL(value: unknown) {
2+
try {
3+
let url: URL;
4+
if (typeof value === 'string') {
5+
url = new URL(value);
6+
} else if (value instanceof URL) {
7+
url = value;
8+
} else {
9+
return false;
10+
}
11+
return url.protocol === 'http:' || url.protocol === 'https:';
12+
} catch {
13+
return false;
14+
}
15+
}

0 commit comments

Comments
 (0)