Skip to content
Merged
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
5 changes: 5 additions & 0 deletions src/components/PDFView/index.native.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import PDF from 'react-native-pdf';

import type {PDFViewNativeProps} from './types';

import isAllowedPDFLink from './isAllowedPDFLink';
import PDFPasswordForm from './PDFPasswordForm';

/**
Expand Down Expand Up @@ -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);
Expand Down
24 changes: 24 additions & 0 deletions src/components/PDFView/isAllowedPDFLink.ts
Original file line number Diff line number Diff line change
@@ -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;
84 changes: 84 additions & 0 deletions tests/unit/IsAllowedPDFLinkTest.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
100 changes: 100 additions & 0 deletions tests/unit/NativePDFViewTest.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof React>('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(
<PDFView
sourceURL="https://example.com/receipt.pdf"
onLoadComplete={jest.fn()}
/>,
);

// 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(
<PDFView
sourceURL="https://example.com/receipt.pdf"
onLoadComplete={jest.fn()}
/>,
);

// 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(
<PDFView
sourceURL="https://example.com/receipt.pdf"
onLoadComplete={jest.fn()}
/>,
);

// 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();
});
});
Loading