diff --git a/src/components/PDFView/index.native.tsx b/src/components/PDFView/index.native.tsx index d700c066f1cc..00f8ed400016 100644 --- a/src/components/PDFView/index.native.tsx +++ b/src/components/PDFView/index.native.tsx @@ -25,6 +25,7 @@ import PDF from 'react-native-pdf'; import type {PDFViewNativeProps} from './types'; +import isAllowedPDFLink from './isAllowedPDFLink'; import PDFPasswordForm from './PDFPasswordForm'; /** @@ -139,6 +140,10 @@ function PDFView({onToggleKeyboard, onLoadComplete, fileName, onPress, isFocused */ const handlePressLink = useCallback( (url: string) => { + if (!isAllowedPDFLink(url)) { + return; + } + if (isTravelLink(url) && activePolicyID) { const postLoginPath = getRelativeUrl(url); openTravelDotLink(activePolicyID, postLoginPath); diff --git a/src/components/PDFView/isAllowedPDFLink.ts b/src/components/PDFView/isAllowedPDFLink.ts new file mode 100644 index 000000000000..70072742e88a --- /dev/null +++ b/src/components/PDFView/isAllowedPDFLink.ts @@ -0,0 +1,24 @@ +import prefixes from '@libs/Navigation/linkingConfig/prefixes'; + +import 'react-native-url-polyfill/auto'; + +const appLinkHostnames = new Set(prefixes.filter((prefix) => prefix.startsWith('https://')).map((prefix) => new URL(prefix).hostname.toLowerCase())); + +/** + * PDF annotations are untrusted and must not launch custom schemes or navigate back into the app. + */ +function isAllowedPDFLink(url: string): boolean { + try { + const {protocol, hostname} = new URL(url); + const normalizedProtocol = protocol.toLowerCase(); + if (normalizedProtocol !== 'https:' && normalizedProtocol !== 'http:') { + return false; + } + + return !appLinkHostnames.has(hostname.toLowerCase().replace(/\.$/, '')); + } catch { + return false; + } +} + +export default isAllowedPDFLink; diff --git a/tests/unit/IsAllowedPDFLinkTest.ts b/tests/unit/IsAllowedPDFLinkTest.ts new file mode 100644 index 000000000000..d10ba2f27f17 --- /dev/null +++ b/tests/unit/IsAllowedPDFLinkTest.ts @@ -0,0 +1,84 @@ +import isAllowedPDFLink from '@components/PDFView/isAllowedPDFLink'; + +import prefixes from '@libs/Navigation/linkingConfig/prefixes'; + +describe('isAllowedPDFLink', () => { + it.each([ + 'new-expensify://transition', + 'new-expensify://transition?email=stranger@example.com', + 'new-expensify://transition?email=victim@example.com', + 'new-expensify://settings/security/closeAccount', + 'NEW-EXPENSIFY://transition', + 'app://-/transition', + 'expensify://transition', + // eslint-disable-next-line no-script-url -- Verify that untrusted script annotations are rejected. + 'javascript:alert(1)', + 'file:///receipt.pdf', + 'data:text/html,test', + 'mailto:security@example.com', + 'tel:+15555555555', + ])('blocks non-web annotation URL %s', (url) => { + // Given a PDF annotation that could launch an app or execute an action + // When its URL is checked + const result = isAllowedPDFLink(url); + + // Then it is not forwarded to the operating system + expect(result).toBe(false); + }); + + it.each(prefixes.filter((prefix) => prefix.startsWith('https://')))('blocks app links on %s', (prefix) => { + // Given a PDF annotation targeting an app host instead of its custom scheme + const url = new URL('/transition?email=stranger@example.com', prefix).toString(); + + // When its URL is checked + const result = isAllowedPDFLink(url); + + // Then the HTTPS equivalent cannot bypass the annotation filter + expect(result).toBe(false); + }); + + it.each([ + 'https://NEW.EXPENSIFY.COM/transition', + 'HTTPS://NEW.EXPENSIFY.COM/transition', + 'https://%6eew.expensify.com/transition', + 'https://new.expensify.com:443/transition', + 'https://new.expensify.com./transition', + 'https://example.com@new.expensify.com/transition', + 'http://new.expensify.com/transition', + 'https://dev.new.expensify.com:8082/transition', + 'https://new.expensify.com/settings/about', + ])('blocks app-host variant %s', (url) => { + // Given an annotation that disguises or changes an app URL + // When its URL is checked + const result = isAllowedPDFLink(url); + + // Then host normalization and protocol changes do not bypass the filter + expect(result).toBe(false); + }); + + it.each(['', 'not a URL', '/transition', '//new.expensify.com/transition', 'https://'])('blocks malformed or relative annotation URL %s', (url) => { + // Given an annotation without a valid absolute URL + // When its URL is checked + const result = isAllowedPDFLink(url); + + // Then parsing fails closed + expect(result).toBe(false); + }); + + it.each([ + 'https://example.com/receipt', + 'HTTPS://EXAMPLE.COM/receipt', + 'http://example.com/receipt', + 'https://help.expensify.com/articles/receipt', + 'https://travel.expensify.com/trips/123', + 'https://example.com/?url=new-expensify%3A%2F%2Ftransition', + 'https://new.expensify.com.example.com/receipt', + ])('allows external web URL %s', (url) => { + // Given a legitimate web link in a PDF annotation + // When its URL is checked + const result = isAllowedPDFLink(url); + + // Then ordinary external links remain usable + expect(result).toBe(true); + }); +}); diff --git a/tests/unit/NativePDFViewTest.tsx b/tests/unit/NativePDFViewTest.tsx new file mode 100644 index 000000000000..0e5dd0665517 --- /dev/null +++ b/tests/unit/NativePDFViewTest.tsx @@ -0,0 +1,100 @@ +import {fireEvent, render, screen} from '@testing-library/react-native'; + +import PDFView from '@components/PDFView/index.native'; + +import {openTravelDotLink} from '@libs/openTravelDotLink'; + +import type {PdfProps} from 'react-native-pdf'; + +import React from 'react'; +import {Linking} from 'react-native'; + +jest.mock('react-native-pdf', () => { + const ReactModule = jest.requireActual('react'); + return function MockPDF({onPressLink}: PdfProps) { + return ReactModule.createElement('PDF', {testID: 'pdf', onPressLink}); + }; +}); + +jest.mock('@components/KeyboardAvoidingView', () => () => null); +jest.mock('@components/LoadingIndicator', () => () => null); +jest.mock('@components/Pressable/PressableWithoutFeedback', () => () => null); +jest.mock('@components/PDFView/PDFPasswordForm', () => () => null); +jest.mock('@hooks/useKeyboardState', () => ({__esModule: true, default: () => ({isKeyboardShown: false})})); +jest.mock('@hooks/useLocalize', () => ({__esModule: true, default: () => ({translate: (key: string) => key})})); +jest.mock('@hooks/useOnyx', () => ({__esModule: true, default: () => ['123']})); +jest.mock('@hooks/useResponsiveLayout', () => ({__esModule: true, default: () => ({shouldUseNarrowLayout: true})})); +jest.mock('@hooks/useSafeAreaPaddings', () => ({__esModule: true, default: () => ({insets: {top: 0, bottom: 0, left: 0, right: 0}})})); +jest.mock('@hooks/useStyleUtils', () => ({__esModule: true, default: () => ({getPDFViewStyle: () => ({})})})); +jest.mock('@hooks/useThemeStyles', () => ({__esModule: true, default: () => ({})})); +jest.mock('@hooks/useWindowDimensions', () => ({__esModule: true, default: () => ({windowWidth: 400, windowHeight: 800})})); +jest.mock('@libs/openTravelDotLink', () => ({openTravelDotLink: jest.fn()})); + +describe('native PDF annotation links', () => { + let openURLSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + openURLSpy = jest.spyOn(Linking, 'openURL').mockResolvedValue(undefined); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it.each([ + 'new-expensify://transition', + 'new-expensify://transition?email=stranger@example.com', + 'https://new.expensify.com/transition', + // eslint-disable-next-line no-script-url -- Verify that untrusted script annotations are rejected. + 'javascript:alert(1)', + ])('does not open annotation URL %s', (url) => { + // Given a PDF rendered in the native attachment viewer + render( + , + ); + + // When the reader presses an unsafe annotation + fireEvent(screen.getByTestId('pdf'), 'pressLink', url); + + // Then neither operating-system nor travel navigation is triggered + expect(openURLSpy).not.toHaveBeenCalled(); + expect(openTravelDotLink).not.toHaveBeenCalled(); + }); + + it('opens an external web annotation', () => { + // Given a PDF rendered in the native attachment viewer + render( + , + ); + + // When the reader presses an external web link + fireEvent(screen.getByTestId('pdf'), 'pressLink', 'https://example.com/receipt'); + + // Then the existing URL-opening behavior is preserved + expect(openURLSpy).toHaveBeenCalledWith('https://example.com/receipt'); + }); + + it('preserves authenticated travel links', () => { + // Given a PDF rendered with an active workspace + render( + , + ); + + // When the reader presses an external travel link + fireEvent(screen.getByTestId('pdf'), 'pressLink', 'https://travel.expensify.com/trips/123'); + + // Then the existing travel authentication flow is used + expect(openTravelDotLink).toHaveBeenCalledWith('123', '/trips/123'); + expect(openURLSpy).not.toHaveBeenCalled(); + }); +});