diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 6af591bed322..679e58dbbc6b 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1345,6 +1345,9 @@ const CONST = { STAGING_NEW_EXPENSIFY_URL: 'https://staging.new.expensify.com', PR_TESTING_NEW_EXPENSIFY_URL: `https://${CI.PULL_REQUEST_NUMBER}.pr-testing.expensify.com`, NEWHELP_URL: 'https://help.expensify.com', + // Schemes we allow to be opened externally. Anything else (javascript:, data:, custom app schemes, etc.) is blocked + // before reaching Linking.openURL to prevent scheme injection from server/user-controlled URLs. + ALLOWED_EXTERNAL_LINK_SCHEMES: ['http:', 'https:', 'mailto:', 'tel:'], CHASE_ACCOUNT_NUMBER_HELP_URL: 'https://help.expensify.com/articles/new-expensify/wallet-and-payments/Connect-a-Personal-Bank-Account', WHATS_NEW_URL: `${USE_EXPENSIFY_URL}/blog?category=Product%20Updates`, INTERNAL_DEV_EXPENSIFY_URL: 'https://www.expensify.com.dev', diff --git a/src/components/AnchorForCommentsOnly/index.native.tsx b/src/components/AnchorForCommentsOnly/index.native.tsx index c7b9ea270cd9..77bae4198943 100644 --- a/src/components/AnchorForCommentsOnly/index.native.tsx +++ b/src/components/AnchorForCommentsOnly/index.native.tsx @@ -1,5 +1,7 @@ import React from 'react'; import {Linking} from 'react-native'; +import Log from '@libs/Log'; +import {isExternalLinkSchemeAllowed} from '@libs/Url'; import BaseAnchorForCommentsOnly from './BaseAnchorForCommentsOnly'; import type {AnchorForCommentsOnlyProps} from './types'; @@ -7,6 +9,8 @@ function AnchorForCommentsOnly({onPress, href = '', ...props}: AnchorForComments const onLinkPress = () => { if (onPress) { onPress(); + } else if (!isExternalLinkSchemeAllowed(href)) { + Log.warn('[AnchorForCommentsOnly] blocked link with disallowed scheme'); } else { Linking.openURL(href); } diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 92a3e87be6a2..c43172b86189 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -5,6 +5,8 @@ import {Linking, View} from 'react-native'; import useDialogContainerFocus from '@hooks/useDialogContainerFocus'; import useDialogLabelRegistration from '@hooks/useDialogLabelRegistration'; import useThemeStyles from '@hooks/useThemeStyles'; +import Log from '@libs/Log'; +import {isExternalLinkSchemeAllowed} from '@libs/Url'; import CONST from '@src/CONST'; import EnvironmentBadge from './EnvironmentBadge'; import Text from './Text'; @@ -82,6 +84,10 @@ function Header({ () => ( { + if (!isExternalLinkSchemeAllowed(subTitleLink)) { + Log.warn('[Header] blocked subtitle link with disallowed scheme'); + return; + } Linking.openURL(subTitleLink); }} numberOfLines={1} diff --git a/src/components/PDFView/index.native.tsx b/src/components/PDFView/index.native.tsx index 3ef133919a3d..cb365fe3e3ec 100644 --- a/src/components/PDFView/index.native.tsx +++ b/src/components/PDFView/index.native.tsx @@ -13,9 +13,11 @@ import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; +import Log from '@libs/Log'; import {openTravelDotLink} from '@libs/openTravelDotLink'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import {getRelativeUrl, isTravelLink} from '@libs/TravelUtils'; +import {isExternalLinkSchemeAllowed} from '@libs/Url'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import PDFPasswordForm from './PDFPasswordForm'; @@ -148,6 +150,10 @@ function PDFView({onToggleKeyboard, onLoadComplete, fileName, onPress, isFocused openTravelDotLink(activePolicyID, postLoginPath); return; } + if (!isExternalLinkSchemeAllowed(url)) { + Log.warn('[PDFView] blocked PDF link with disallowed scheme'); + return; + } Linking.openURL(url); }, [activePolicyID], diff --git a/src/libs/Url.ts b/src/libs/Url.ts index 842c769b83fe..18207813679f 100644 --- a/src/libs/Url.ts +++ b/src/libs/Url.ts @@ -56,6 +56,21 @@ function appendParam(url: string, paramName: string, paramValue: string) { return `${url}${separator}${paramName}=${paramValue}` as Route; } +/** + * Guards against scheme injection (javascript:, data:, custom app schemes) before opening an external URL. + * Schemeless values (relative/anchor paths) are allowed through since they are handled by navigation, not external opens. + */ +function isExternalLinkSchemeAllowed(url: string): boolean { + const scheme = url + .trim() + .match(/^([a-z][a-z0-9+.-]*):/i)?.[1] + ?.toLowerCase(); + if (!scheme) { + return true; + } + return (CONST.ALLOWED_EXTERNAL_LINK_SCHEMES as readonly string[]).includes(`${scheme}:`); +} + function hasURL(text: string) { const urlPattern = /((https|http)?:\/\/[^\s]+)/g; @@ -151,4 +166,15 @@ function getUrlWithParams(baseU return (queryString ? `${path}?${queryString}` : path) as UrlWithParams; } -export {getSearchParamFromUrl, getSearchParamFromPath, hasSameExpensifyOrigin, getPathFromURL, appendParam, hasURL, addLeadingForwardSlash, extractUrlDomain, getUrlWithParams}; +export { + getSearchParamFromUrl, + getSearchParamFromPath, + hasSameExpensifyOrigin, + getPathFromURL, + appendParam, + hasURL, + addLeadingForwardSlash, + extractUrlDomain, + getUrlWithParams, + isExternalLinkSchemeAllowed, +}; diff --git a/src/libs/asyncOpenURL/index.ts b/src/libs/asyncOpenURL/index.ts index c07663597df3..718666b4e9e3 100644 --- a/src/libs/asyncOpenURL/index.ts +++ b/src/libs/asyncOpenURL/index.ts @@ -1,6 +1,7 @@ import {Linking} from 'react-native'; import Log from '@libs/Log'; import {sanitizeUrlForLogging} from '@libs/sanitizeLogParams'; +import {isExternalLinkSchemeAllowed} from '@libs/Url'; import type AsyncOpenURL from './types'; const asyncOpenURL: AsyncOpenURL = (promise, url) => { @@ -10,7 +11,12 @@ const asyncOpenURL: AsyncOpenURL = (promise, url) => { promise .then((params) => { - Linking.openURL(typeof url === 'string' ? url : url(params)); + const resolvedURL = typeof url === 'string' ? url : url(params); + if (!isExternalLinkSchemeAllowed(resolvedURL)) { + Log.warn('[asyncOpenURL] blocked URL with disallowed scheme', {url: sanitizeUrlForLogging(resolvedURL)}); + return; + } + Linking.openURL(resolvedURL); }) .catch(() => { const safeUrl = typeof url === 'string' ? sanitizeUrlForLogging(url) : '[function]'; diff --git a/src/libs/asyncOpenURL/index.web.ts b/src/libs/asyncOpenURL/index.web.ts index 378205ed6ff1..e87a500e8dad 100644 --- a/src/libs/asyncOpenURL/index.web.ts +++ b/src/libs/asyncOpenURL/index.web.ts @@ -2,6 +2,7 @@ import {Linking} from 'react-native'; import type {Linking as LinkingWeb} from 'react-native-web'; import getPlatform from '@libs/getPlatform'; import Log from '@libs/Log'; +import {isExternalLinkSchemeAllowed} from '@libs/Url'; import CONST from '@src/CONST'; import type AsyncOpenURL from './types'; @@ -20,7 +21,12 @@ const asyncOpenURL: AsyncOpenURL = (promise, url, shouldSkipCustomSafariLogic, s if (!isSafari || !!shouldSkipCustomSafariLogic || !!shouldOpenInSameTab) { promise .then((params) => { - (Linking.openURL as LinkingWeb['openURL'])(typeof url === 'string' ? url : url(params), shouldOpenInSameTab && canOpenURLInSameTab ? '_self' : undefined); + const resolvedURL = typeof url === 'string' ? url : url(params); + if (!isExternalLinkSchemeAllowed(resolvedURL)) { + Log.warn('[asyncOpenURL] blocked URL with disallowed scheme', {url: resolvedURL}); + return; + } + (Linking.openURL as LinkingWeb['openURL'])(resolvedURL, shouldOpenInSameTab && canOpenURLInSameTab ? '_self' : undefined); }) .catch(() => { Log.warn('[asyncOpenURL] error occurred while opening URL', {url}); @@ -29,11 +35,17 @@ const asyncOpenURL: AsyncOpenURL = (promise, url, shouldSkipCustomSafariLogic, s const windowRef = window.open(); promise .then((params) => { + const resolvedURL = typeof url === 'string' ? url : url(params); + if (!isExternalLinkSchemeAllowed(resolvedURL)) { + Log.warn('[asyncOpenURL] blocked URL with disallowed scheme', {url: resolvedURL}); + windowRef?.close(); + return; + } if (!windowRef) { - (Linking.openURL as LinkingWeb['openURL'])(typeof url === 'string' ? url : url(params), '_self'); + (Linking.openURL as LinkingWeb['openURL'])(resolvedURL, '_self'); return; } - windowRef.location = typeof url === 'string' ? url : url(params); + windowRef.location = resolvedURL; }) .catch(() => { windowRef?.close(); diff --git a/tests/unit/UrlTest.ts b/tests/unit/UrlTest.ts index d07f2ee0898d..40cf94a52598 100644 --- a/tests/unit/UrlTest.ts +++ b/tests/unit/UrlTest.ts @@ -108,6 +108,25 @@ describe('Url', () => { expect(Url.getUrlWithParams(baseUrl, params)).toBe(expected); }); }); + describe('isExternalLinkSchemeAllowed', () => { + it.each([ + ['allows https', 'https://new.expensify.com/inbox', true], + ['allows http', 'http://example.com', true], + ['allows mailto', 'mailto:concierge@expensify.com', true], + ['allows tel', 'tel:+15551234567', true], + ['is case-insensitive on scheme', 'HTTPS://example.com', true], + ['allows schemeless relative paths', '/r/1234', true], + // eslint-disable-next-line no-script-url + ['blocks javascript', 'javascript:alert(1)', false], + // cspell:disable-next-line + ['blocks data', 'data:text/html,', false], + ['blocks file', 'file:///etc/passwd', false], + ['blocks custom app schemes', 'intent://evil#Intent;scheme=foo;end', false], + ['blocks scheme despite leading whitespace', ' javascript:alert(1)', false], + ])('%s', (_, url, expected) => { + expect(Url.isExternalLinkSchemeAllowed(url)).toBe(expected); + }); + }); describe('getSearchParamFromPath', () => { it.each([ ['returns null when no query string', 'search/hold/search', 'q', null],