URL: Mark mixed script usage as suspicious (#5693)
This commit is contained in:
parent
befbc6a4c9
commit
d0504015d4
@ -4,12 +4,8 @@ import { getActions } from '../../global';
|
|||||||
|
|
||||||
import { ApiMessageEntityTypes } from '../../api/types';
|
import { ApiMessageEntityTypes } from '../../api/types';
|
||||||
|
|
||||||
import {
|
import { ensureProtocol, getUnicodeUrl, isMixedScriptUrl } from '../../util/browser/url';
|
||||||
DEBUG,
|
|
||||||
} from '../../config';
|
|
||||||
import convertPunycode from '../../lib/punycode';
|
|
||||||
import buildClassName from '../../util/buildClassName';
|
import buildClassName from '../../util/buildClassName';
|
||||||
import { ensureProtocol } from '../../util/ensureProtocol';
|
|
||||||
|
|
||||||
import useLastCallback from '../../hooks/useLastCallback';
|
import useLastCallback from '../../hooks/useLastCallback';
|
||||||
|
|
||||||
@ -39,7 +35,9 @@ const SafeLink = ({
|
|||||||
if (!url) return true;
|
if (!url) return true;
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
openUrl({ url, shouldSkipModal: shouldSkipModal || isRegularLink });
|
|
||||||
|
const isTrustedLink = isRegularLink && !isMixedScriptUrl(url);
|
||||||
|
openUrl({ url, shouldSkipModal: shouldSkipModal || isTrustedLink });
|
||||||
|
|
||||||
return false;
|
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;
|
export default SafeLink;
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import type { FC } from '../../lib/teact/teact';
|
|||||||
import React, { memo, useCallback } from '../../lib/teact/teact';
|
import React, { memo, useCallback } from '../../lib/teact/teact';
|
||||||
import { getActions } from '../../global';
|
import { getActions } from '../../global';
|
||||||
|
|
||||||
import { ensureProtocol } from '../../util/ensureProtocol';
|
import { ensureProtocol } from '../../util/browser/url';
|
||||||
import renderText from '../common/helpers/renderText';
|
import renderText from '../common/helpers/renderText';
|
||||||
|
|
||||||
import useCurrentOrPrev from '../../hooks/useCurrentOrPrev';
|
import useCurrentOrPrev from '../../hooks/useCurrentOrPrev';
|
||||||
@ -20,6 +20,10 @@ const SafeLinkModal: FC<OwnProps> = ({ url }) => {
|
|||||||
const lang = useOldLang();
|
const lang = useOldLang();
|
||||||
|
|
||||||
const handleOpen = useCallback(() => {
|
const handleOpen = useCallback(() => {
|
||||||
|
if (!url) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
window.open(ensureProtocol(url), '_blank', 'noopener');
|
window.open(ensureProtocol(url), '_blank', 'noopener');
|
||||||
toggleSafeLinkModal({ url: undefined });
|
toggleSafeLinkModal({ url: undefined });
|
||||||
}, [toggleSafeLinkModal, url]);
|
}, [toggleSafeLinkModal, url]);
|
||||||
@ -35,7 +39,7 @@ const SafeLinkModal: FC<OwnProps> = ({ url }) => {
|
|||||||
isOpen={Boolean(url)}
|
isOpen={Boolean(url)}
|
||||||
onClose={handleDismiss}
|
onClose={handleDismiss}
|
||||||
title={lang('OpenUrlTitle')}
|
title={lang('OpenUrlTitle')}
|
||||||
textParts={renderText(lang('OpenUrlAlert2', renderingUrl), ['links'])}
|
textParts={renderText(lang('OpenUrlAlert2', renderingUrl))}
|
||||||
confirmLabel={lang('OpenUrlTitle')}
|
confirmLabel={lang('OpenUrlTitle')}
|
||||||
confirmHandler={handleOpen}
|
confirmHandler={handleOpen}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -7,9 +7,9 @@ import type { IAnchorPosition } from '../../../types';
|
|||||||
import { ApiMessageEntityTypes } from '../../../api/types';
|
import { ApiMessageEntityTypes } from '../../../api/types';
|
||||||
|
|
||||||
import { EDITABLE_INPUT_ID } from '../../../config';
|
import { EDITABLE_INPUT_ID } from '../../../config';
|
||||||
|
import { ensureProtocol } from '../../../util/browser/url';
|
||||||
import buildClassName from '../../../util/buildClassName';
|
import buildClassName from '../../../util/buildClassName';
|
||||||
import captureEscKeyListener from '../../../util/captureEscKeyListener';
|
import captureEscKeyListener from '../../../util/captureEscKeyListener';
|
||||||
import { ensureProtocol } from '../../../util/ensureProtocol';
|
|
||||||
import getKeyFromEvent from '../../../util/getKeyFromEvent';
|
import getKeyFromEvent from '../../../util/getKeyFromEvent';
|
||||||
import stopEvent from '../../../util/stopEvent';
|
import stopEvent from '../../../util/stopEvent';
|
||||||
import { INPUT_CUSTOM_EMOJI_SELECTOR } from './helpers/customEmoji';
|
import { INPUT_CUSTOM_EMOJI_SELECTOR } from './helpers/customEmoji';
|
||||||
|
|||||||
@ -16,8 +16,8 @@ import {
|
|||||||
selectThreadIdFromMessage,
|
selectThreadIdFromMessage,
|
||||||
selectTopic,
|
selectTopic,
|
||||||
} from '../../../global/selectors';
|
} from '../../../global/selectors';
|
||||||
|
import { ensureProtocol } from '../../../util/browser/url';
|
||||||
import { formatDateTimeToString, formatShortDuration } from '../../../util/dates/dateFormat';
|
import { formatDateTimeToString, formatShortDuration } from '../../../util/dates/dateFormat';
|
||||||
import { ensureProtocol } from '../../../util/ensureProtocol';
|
|
||||||
import { formatCurrency } from '../../../util/formatCurrency';
|
import { formatCurrency } from '../../../util/formatCurrency';
|
||||||
import { formatStarsAsText } from '../../../util/localization/format';
|
import { formatStarsAsText } from '../../../util/localization/format';
|
||||||
import { conjuctionWithNodes } from '../../../util/localization/utils';
|
import { conjuctionWithNodes } from '../../../util/localization/utils';
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import type { TabState } from '../../../global/types';
|
|||||||
|
|
||||||
import { getUserFullName } from '../../../global/helpers';
|
import { getUserFullName } from '../../../global/helpers';
|
||||||
import { selectUser } from '../../../global/selectors';
|
import { selectUser } from '../../../global/selectors';
|
||||||
import { ensureProtocol } from '../../../util/ensureProtocol';
|
import { ensureProtocol } from '../../../util/browser/url';
|
||||||
import renderText from '../../common/helpers/renderText';
|
import renderText from '../../common/helpers/renderText';
|
||||||
|
|
||||||
import useCurrentOrPrev from '../../../hooks/useCurrentOrPrev';
|
import useCurrentOrPrev from '../../../hooks/useCurrentOrPrev';
|
||||||
@ -44,8 +44,8 @@ const UrlAuthModal: FC<OwnProps & StateProps> = ({
|
|||||||
acceptAction({
|
acceptAction({
|
||||||
isWriteAllowed: isWriteAccessChecked,
|
isWriteAllowed: isWriteAccessChecked,
|
||||||
});
|
});
|
||||||
} else {
|
} else if (currentAuth?.url) {
|
||||||
window.open(ensureProtocol(currentAuth?.url), '_blank', 'noopener');
|
window.open(ensureProtocol(currentAuth.url), '_blank', 'noopener');
|
||||||
}
|
}
|
||||||
closeUrlAuthModal();
|
closeUrlAuthModal();
|
||||||
}, [
|
}, [
|
||||||
|
|||||||
@ -37,9 +37,9 @@ import {
|
|||||||
SUPPORTED_PHOTO_CONTENT_TYPES,
|
SUPPORTED_PHOTO_CONTENT_TYPES,
|
||||||
SUPPORTED_VIDEO_CONTENT_TYPES,
|
SUPPORTED_VIDEO_CONTENT_TYPES,
|
||||||
} from '../../../config';
|
} from '../../../config';
|
||||||
|
import { ensureProtocol, isMixedScriptUrl } from '../../../util/browser/url';
|
||||||
import { copyTextToClipboardFromPromise } from '../../../util/clipboard';
|
import { copyTextToClipboardFromPromise } from '../../../util/clipboard';
|
||||||
import { isDeepLink } from '../../../util/deepLinkParser';
|
import { isDeepLink } from '../../../util/deepLinkParser';
|
||||||
import { ensureProtocol } from '../../../util/ensureProtocol';
|
|
||||||
import { getCurrentTabId } from '../../../util/establishMultitabRole';
|
import { getCurrentTabId } from '../../../util/establishMultitabRole';
|
||||||
import {
|
import {
|
||||||
areSortedArraysIntersecting,
|
areSortedArraysIntersecting,
|
||||||
@ -1866,6 +1866,8 @@ addActionHandler('openUrl', (global, actions, payload): ActionReturnType => {
|
|||||||
url, shouldSkipModal, ignoreDeepLinks, tabId = getCurrentTabId(),
|
url, shouldSkipModal, ignoreDeepLinks, tabId = getCurrentTabId(),
|
||||||
} = payload;
|
} = payload;
|
||||||
const urlWithProtocol = ensureProtocol(url)!;
|
const urlWithProtocol = ensureProtocol(url)!;
|
||||||
|
const parsedUrl = new URL(urlWithProtocol);
|
||||||
|
const isMixedScript = isMixedScriptUrl(urlWithProtocol);
|
||||||
|
|
||||||
if (!ignoreDeepLinks && isDeepLink(urlWithProtocol)) {
|
if (!ignoreDeepLinks && isDeepLink(urlWithProtocol)) {
|
||||||
actions.closeStoryViewer({ tabId });
|
actions.closeStoryViewer({ tabId });
|
||||||
@ -1877,8 +1879,6 @@ addActionHandler('openUrl', (global, actions, payload): ActionReturnType => {
|
|||||||
|
|
||||||
const { appConfig, config } = global;
|
const { appConfig, config } = global;
|
||||||
if (appConfig) {
|
if (appConfig) {
|
||||||
const parsedUrl = new URL(urlWithProtocol);
|
|
||||||
|
|
||||||
if (config?.autologinToken && appConfig.autologinDomains.includes(parsedUrl.hostname)) {
|
if (config?.autologinToken && appConfig.autologinDomains.includes(parsedUrl.hostname)) {
|
||||||
parsedUrl.searchParams.set(AUTOLOGIN_TOKEN_KEY, config.autologinToken);
|
parsedUrl.searchParams.set(AUTOLOGIN_TOKEN_KEY, config.autologinToken);
|
||||||
window.open(parsedUrl.href, '_blank', 'noopener');
|
window.open(parsedUrl.href, '_blank', 'noopener');
|
||||||
@ -1896,9 +1896,9 @@ addActionHandler('openUrl', (global, actions, payload): ActionReturnType => {
|
|||||||
const shouldDisplayModal = !urlWithProtocol.match(RE_TELEGRAM_LINK) && !shouldSkipModal;
|
const shouldDisplayModal = !urlWithProtocol.match(RE_TELEGRAM_LINK) && !shouldSkipModal;
|
||||||
|
|
||||||
if (shouldDisplayModal) {
|
if (shouldDisplayModal) {
|
||||||
actions.toggleSafeLinkModal({ url: urlWithProtocol, tabId });
|
actions.toggleSafeLinkModal({ url: isMixedScript ? parsedUrl.toString() : urlWithProtocol, tabId });
|
||||||
} else {
|
} else {
|
||||||
window.open(urlWithProtocol, '_blank', 'noopener');
|
window.open(parsedUrl, '_blank', 'noopener');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
67
src/util/browser/url.ts
Normal file
67
src/util/browser/url.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -2,8 +2,8 @@ import type { ThreadId } from '../types';
|
|||||||
|
|
||||||
import { RE_TG_LINK, RE_TME_LINK } from '../config';
|
import { RE_TG_LINK, RE_TME_LINK } from '../config';
|
||||||
import { toChannelId } from '../global/helpers';
|
import { toChannelId } from '../global/helpers';
|
||||||
|
import { ensureProtocol } from './browser/url';
|
||||||
import { parseTimestampDuration } from './dates/timestamp';
|
import { parseTimestampDuration } from './dates/timestamp';
|
||||||
import { ensureProtocol } from './ensureProtocol';
|
|
||||||
import { isUsernameValid } from './username';
|
import { isUsernameValid } from './username';
|
||||||
import { IS_BAD_URL_PARSER } from './windowEnvironment';
|
import { IS_BAD_URL_PARSER } from './windowEnvironment';
|
||||||
|
|
||||||
|
|||||||
@ -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}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
x
Reference in New Issue
Block a user