URL: Mark mixed script usage as suspicious (#5693)

This commit is contained in:
zubiden 2025-03-07 15:16:44 +01:00 committed by Alexander Zinchuk
parent befbc6a4c9
commit d0504015d4
9 changed files with 88 additions and 68 deletions

View File

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

View File

@ -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<OwnProps> = ({ 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<OwnProps> = ({ 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}
/>

View File

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

View File

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

View File

@ -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<OwnProps & StateProps> = ({
acceptAction({
isWriteAllowed: isWriteAccessChecked,
});
} else {
window.open(ensureProtocol(currentAuth?.url), '_blank', 'noopener');
} else if (currentAuth?.url) {
window.open(ensureProtocol(currentAuth.url), '_blank', 'noopener');
}
closeUrlAuthModal();
}, [

View File

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

67
src/util/browser/url.ts Normal file
View File

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

View File

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

View File

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