Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
4 changes: 4 additions & 0 deletions src/components/AnchorForCommentsOnly/index.native.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
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';

function AnchorForCommentsOnly({onPress, href = '', ...props}: AnchorForCommentsOnlyProps) {
const onLinkPress = () => {
if (onPress) {
onPress();
} else if (!isExternalLinkSchemeAllowed(href)) {
Log.warn('[AnchorForCommentsOnly] blocked link with disallowed scheme');
} else {
Linking.openURL(href);
}
Expand Down
6 changes: 6 additions & 0 deletions src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -82,6 +84,10 @@ function Header({
() => (
<TextLink
onPress={() => {
if (!isExternalLinkSchemeAllowed(subTitleLink)) {
Log.warn('[Header] blocked subtitle link with disallowed scheme');
return;
}
Linking.openURL(subTitleLink);
}}
numberOfLines={1}
Expand Down
6 changes: 6 additions & 0 deletions src/components/PDFView/index.native.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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],
Expand Down
28 changes: 27 additions & 1 deletion src/libs/Url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -151,4 +166,15 @@ function getUrlWithParams<TBase extends string, TParams extends UrlParams>(baseU
return (queryString ? `${path}?${queryString}` : path) as UrlWithParams<TBase>;
}

export {getSearchParamFromUrl, getSearchParamFromPath, hasSameExpensifyOrigin, getPathFromURL, appendParam, hasURL, addLeadingForwardSlash, extractUrlDomain, getUrlWithParams};
export {
getSearchParamFromUrl,
getSearchParamFromPath,
hasSameExpensifyOrigin,
getPathFromURL,
appendParam,
hasURL,
addLeadingForwardSlash,
extractUrlDomain,
getUrlWithParams,
isExternalLinkSchemeAllowed,
};
8 changes: 7 additions & 1 deletion src/libs/asyncOpenURL/index.ts
Original file line number Diff line number Diff line change
@@ -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) => {
Expand All @@ -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]';
Expand Down
18 changes: 15 additions & 3 deletions src/libs/asyncOpenURL/index.web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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});
Expand All @@ -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();
Expand Down
19 changes: 19 additions & 0 deletions tests/unit/UrlTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,<script>alert(1)</script>', 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],
Expand Down
Loading