From d0504015d4314aa3779355c6a6eafe601447e080 Mon Sep 17 00:00:00 2001 From: zubiden <19638254+zubiden@users.noreply.github.com> Date: Fri, 7 Mar 2025 15:16:44 +0100 Subject: [PATCH] URL: Mark mixed script usage as suspicious (#5693) --- src/components/common/SafeLink.tsx | 39 ++--------- src/components/main/SafeLinkModal.tsx | 8 ++- .../middle/composer/TextFormatter.tsx | 2 +- .../middle/message/ActionMessageText.tsx | 2 +- .../modals/urlAuth/UrlAuthModal.tsx | 6 +- src/global/actions/api/messages.ts | 10 +-- src/util/browser/url.ts | 67 +++++++++++++++++++ src/util/deepLinkParser.ts | 2 +- src/util/ensureProtocol.ts | 20 ------ 9 files changed, 88 insertions(+), 68 deletions(-) create mode 100644 src/util/browser/url.ts delete mode 100644 src/util/ensureProtocol.ts diff --git a/src/components/common/SafeLink.tsx b/src/components/common/SafeLink.tsx index 9797b58cd..e00c4b7b3 100644 --- a/src/components/common/SafeLink.tsx +++ b/src/components/common/SafeLink.tsx @@ -4,12 +4,8 @@ import { getActions } from '../../global'; import { ApiMessageEntityTypes } from '../../api/types'; -import { - DEBUG, -} from '../../config'; -import convertPunycode from '../../lib/punycode'; +import { ensureProtocol, getUnicodeUrl, isMixedScriptUrl } from '../../util/browser/url'; import buildClassName from '../../util/buildClassName'; -import { ensureProtocol } from '../../util/ensureProtocol'; import useLastCallback from '../../hooks/useLastCallback'; @@ -39,7 +35,9 @@ const SafeLink = ({ if (!url) return true; e.preventDefault(); - openUrl({ url, shouldSkipModal: shouldSkipModal || isRegularLink }); + + const isTrustedLink = isRegularLink && !isMixedScriptUrl(url); + openUrl({ url, shouldSkipModal: shouldSkipModal || isTrustedLink }); return false; }); @@ -69,33 +67,4 @@ const SafeLink = ({ ); }; -function getUnicodeUrl(url?: string) { - if (!url) { - return undefined; - } - - const href = ensureProtocol(url); - if (!href) { - return undefined; - } - - try { - const parsedUrl = new URL(href); - const unicodeDomain = convertPunycode(parsedUrl.hostname); - - try { - return decodeURI(parsedUrl.toString()).replace(parsedUrl.hostname, unicodeDomain); - } catch (err) { // URL contains invalid sequences, keep it as it is - return parsedUrl.toString().replace(parsedUrl.hostname, unicodeDomain); - } - } catch (error) { - if (DEBUG) { - // eslint-disable-next-line no-console - console.warn('SafeLink.getDecodedUrl error ', url, error); - } - } - - return undefined; -} - export default SafeLink; diff --git a/src/components/main/SafeLinkModal.tsx b/src/components/main/SafeLinkModal.tsx index fca3e96ad..58d3b202d 100644 --- a/src/components/main/SafeLinkModal.tsx +++ b/src/components/main/SafeLinkModal.tsx @@ -2,7 +2,7 @@ import type { FC } from '../../lib/teact/teact'; import React, { memo, useCallback } from '../../lib/teact/teact'; import { getActions } from '../../global'; -import { ensureProtocol } from '../../util/ensureProtocol'; +import { ensureProtocol } from '../../util/browser/url'; import renderText from '../common/helpers/renderText'; import useCurrentOrPrev from '../../hooks/useCurrentOrPrev'; @@ -20,6 +20,10 @@ const SafeLinkModal: FC = ({ url }) => { const lang = useOldLang(); const handleOpen = useCallback(() => { + if (!url) { + return; + } + window.open(ensureProtocol(url), '_blank', 'noopener'); toggleSafeLinkModal({ url: undefined }); }, [toggleSafeLinkModal, url]); @@ -35,7 +39,7 @@ const SafeLinkModal: FC = ({ url }) => { isOpen={Boolean(url)} onClose={handleDismiss} title={lang('OpenUrlTitle')} - textParts={renderText(lang('OpenUrlAlert2', renderingUrl), ['links'])} + textParts={renderText(lang('OpenUrlAlert2', renderingUrl))} confirmLabel={lang('OpenUrlTitle')} confirmHandler={handleOpen} /> diff --git a/src/components/middle/composer/TextFormatter.tsx b/src/components/middle/composer/TextFormatter.tsx index 85646e864..9785b7ad3 100644 --- a/src/components/middle/composer/TextFormatter.tsx +++ b/src/components/middle/composer/TextFormatter.tsx @@ -7,9 +7,9 @@ import type { IAnchorPosition } from '../../../types'; import { ApiMessageEntityTypes } from '../../../api/types'; import { EDITABLE_INPUT_ID } from '../../../config'; +import { ensureProtocol } from '../../../util/browser/url'; import buildClassName from '../../../util/buildClassName'; import captureEscKeyListener from '../../../util/captureEscKeyListener'; -import { ensureProtocol } from '../../../util/ensureProtocol'; import getKeyFromEvent from '../../../util/getKeyFromEvent'; import stopEvent from '../../../util/stopEvent'; import { INPUT_CUSTOM_EMOJI_SELECTOR } from './helpers/customEmoji'; diff --git a/src/components/middle/message/ActionMessageText.tsx b/src/components/middle/message/ActionMessageText.tsx index 82cc59961..4fc89796d 100644 --- a/src/components/middle/message/ActionMessageText.tsx +++ b/src/components/middle/message/ActionMessageText.tsx @@ -16,8 +16,8 @@ import { selectThreadIdFromMessage, selectTopic, } from '../../../global/selectors'; +import { ensureProtocol } from '../../../util/browser/url'; import { formatDateTimeToString, formatShortDuration } from '../../../util/dates/dateFormat'; -import { ensureProtocol } from '../../../util/ensureProtocol'; import { formatCurrency } from '../../../util/formatCurrency'; import { formatStarsAsText } from '../../../util/localization/format'; import { conjuctionWithNodes } from '../../../util/localization/utils'; diff --git a/src/components/modals/urlAuth/UrlAuthModal.tsx b/src/components/modals/urlAuth/UrlAuthModal.tsx index a277edda9..6ff6cf96f 100644 --- a/src/components/modals/urlAuth/UrlAuthModal.tsx +++ b/src/components/modals/urlAuth/UrlAuthModal.tsx @@ -9,7 +9,7 @@ import type { TabState } from '../../../global/types'; import { getUserFullName } from '../../../global/helpers'; import { selectUser } from '../../../global/selectors'; -import { ensureProtocol } from '../../../util/ensureProtocol'; +import { ensureProtocol } from '../../../util/browser/url'; import renderText from '../../common/helpers/renderText'; import useCurrentOrPrev from '../../../hooks/useCurrentOrPrev'; @@ -44,8 +44,8 @@ const UrlAuthModal: FC = ({ acceptAction({ isWriteAllowed: isWriteAccessChecked, }); - } else { - window.open(ensureProtocol(currentAuth?.url), '_blank', 'noopener'); + } else if (currentAuth?.url) { + window.open(ensureProtocol(currentAuth.url), '_blank', 'noopener'); } closeUrlAuthModal(); }, [ diff --git a/src/global/actions/api/messages.ts b/src/global/actions/api/messages.ts index f5dc2886d..9096adbc4 100644 --- a/src/global/actions/api/messages.ts +++ b/src/global/actions/api/messages.ts @@ -37,9 +37,9 @@ import { SUPPORTED_PHOTO_CONTENT_TYPES, SUPPORTED_VIDEO_CONTENT_TYPES, } from '../../../config'; +import { ensureProtocol, isMixedScriptUrl } from '../../../util/browser/url'; import { copyTextToClipboardFromPromise } from '../../../util/clipboard'; import { isDeepLink } from '../../../util/deepLinkParser'; -import { ensureProtocol } from '../../../util/ensureProtocol'; import { getCurrentTabId } from '../../../util/establishMultitabRole'; import { areSortedArraysIntersecting, @@ -1866,6 +1866,8 @@ addActionHandler('openUrl', (global, actions, payload): ActionReturnType => { url, shouldSkipModal, ignoreDeepLinks, tabId = getCurrentTabId(), } = payload; const urlWithProtocol = ensureProtocol(url)!; + const parsedUrl = new URL(urlWithProtocol); + const isMixedScript = isMixedScriptUrl(urlWithProtocol); if (!ignoreDeepLinks && isDeepLink(urlWithProtocol)) { actions.closeStoryViewer({ tabId }); @@ -1877,8 +1879,6 @@ addActionHandler('openUrl', (global, actions, payload): ActionReturnType => { const { appConfig, config } = global; if (appConfig) { - const parsedUrl = new URL(urlWithProtocol); - if (config?.autologinToken && appConfig.autologinDomains.includes(parsedUrl.hostname)) { parsedUrl.searchParams.set(AUTOLOGIN_TOKEN_KEY, config.autologinToken); window.open(parsedUrl.href, '_blank', 'noopener'); @@ -1896,9 +1896,9 @@ addActionHandler('openUrl', (global, actions, payload): ActionReturnType => { const shouldDisplayModal = !urlWithProtocol.match(RE_TELEGRAM_LINK) && !shouldSkipModal; if (shouldDisplayModal) { - actions.toggleSafeLinkModal({ url: urlWithProtocol, tabId }); + actions.toggleSafeLinkModal({ url: isMixedScript ? parsedUrl.toString() : urlWithProtocol, tabId }); } else { - window.open(urlWithProtocol, '_blank', 'noopener'); + window.open(parsedUrl, '_blank', 'noopener'); } }); diff --git a/src/util/browser/url.ts b/src/util/browser/url.ts new file mode 100644 index 000000000..14120897c --- /dev/null +++ b/src/util/browser/url.ts @@ -0,0 +1,67 @@ +import { DEBUG } from '../../config'; +import convertPunycode from '../../lib/punycode'; + +const PROTOCOL_WHITELIST = new Set(['http:', 'https:', 'tg:', 'ton:', 'mailto:', 'tel:']); +const FALLBACK_PREFIX = 'https://'; + +export function ensureProtocol(url: string) { + try { + const parsedUrl = new URL(url); + // eslint-disable-next-line no-script-url + if (!PROTOCOL_WHITELIST.has(parsedUrl.protocol)) { + return `${FALLBACK_PREFIX}${url}`; + } + + return url; + } catch (err) { + return `${FALLBACK_PREFIX}${url}`; + } +} + +export function getUnicodeUrl(url: string) { + const href = ensureProtocol(url); + + try { + const parsedUrl = new URL(href); + const unicodeDomain = convertPunycode(parsedUrl.hostname); + + try { + return decodeURI(parsedUrl.toString()).replace(parsedUrl.hostname, unicodeDomain); + } catch (err) { // URL contains invalid sequences, keep it as it is + return parsedUrl.toString().replace(parsedUrl.hostname, unicodeDomain); + } + } catch (error) { + if (DEBUG) { + // eslint-disable-next-line no-console + console.warn('SafeLink.getDecodedUrl error ', url, error); + } + } + + return undefined; +} + +export function isMixedScriptUrl(url: string): boolean { + let domain; + try { + domain = convertPunycode(new URL(ensureProtocol(url)!).hostname); + } catch (e) { + return true; // If URL is invalid, treat it as mixed script + } + + let hasLatin = false; + let hasNonLatin = false; + + for (const char of Array.from(domain)) { + if (!/\p{L}/u.test(char)) continue; // Ignore non-letter characters + + if (/\p{Script=Latin}/u.test(char)) { + hasLatin = true; + } else { + hasNonLatin = true; + } + + if (hasLatin && hasNonLatin) return true; + } + + return false; +} diff --git a/src/util/deepLinkParser.ts b/src/util/deepLinkParser.ts index 407ca5568..10cb0007b 100644 --- a/src/util/deepLinkParser.ts +++ b/src/util/deepLinkParser.ts @@ -2,8 +2,8 @@ import type { ThreadId } from '../types'; import { RE_TG_LINK, RE_TME_LINK } from '../config'; import { toChannelId } from '../global/helpers'; +import { ensureProtocol } from './browser/url'; import { parseTimestampDuration } from './dates/timestamp'; -import { ensureProtocol } from './ensureProtocol'; import { isUsernameValid } from './username'; import { IS_BAD_URL_PARSER } from './windowEnvironment'; diff --git a/src/util/ensureProtocol.ts b/src/util/ensureProtocol.ts deleted file mode 100644 index f46945967..000000000 --- a/src/util/ensureProtocol.ts +++ /dev/null @@ -1,20 +0,0 @@ -const PROTOCOL_WHITELIST = new Set(['http:', 'https:', 'tg:', 'ton:', 'mailto:', 'tel:']); -const FALLBACK_PREFIX = 'https://'; - -export function ensureProtocol(url?: string) { - if (!url) { - return undefined; - } - - try { - const parsedUrl = new URL(url); - // eslint-disable-next-line no-script-url - if (!PROTOCOL_WHITELIST.has(parsedUrl.protocol)) { - return `${FALLBACK_PREFIX}${url}`; - } - - return url; - } catch (err) { - return `${FALLBACK_PREFIX}${url}`; - } -}