From 638374dc30b0e334cf7d57864cdba68f4de0fb7d Mon Sep 17 00:00:00 2001 From: Louis Laugesen Date: Tue, 19 Nov 2024 11:08:36 +1100 Subject: [PATCH] Ensure checkout cancel_to param is a relative url (#96514) * Use a url origin check to confirm path is a relative url * Add tests around cancel_to --- .../checkout/src/lib/leave-checkout.ts | 10 +- .../checkout/src/test/leave-checkout.ts | 126 ++++++++++++++++++ 2 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 client/my-sites/checkout/src/test/leave-checkout.ts diff --git a/client/my-sites/checkout/src/lib/leave-checkout.ts b/client/my-sites/checkout/src/lib/leave-checkout.ts index d7f9893fcc93b..f054b8d5e6b39 100644 --- a/client/my-sites/checkout/src/lib/leave-checkout.ts +++ b/client/my-sites/checkout/src/lib/leave-checkout.ts @@ -102,7 +102,7 @@ export const leaveCheckout = ( { if ( searchParams.has( 'cancel_to' ) ) { const cancelPath = searchParams.get( 'cancel_to' ) ?? ''; // Only allow redirecting to relative paths. - if ( cancelPath.match( /^\/(?!\/)/ ) ) { + if ( isRelativeUrl( cancelPath ) ) { navigate( cancelPath ); return; } @@ -115,3 +115,11 @@ export const leaveCheckout = ( { navigate( closeUrl ); }; + +export function isRelativeUrl( url: string ) { + try { + return new URL( url, window.location.href ).origin === window.location.origin; + } catch { + return false; + } +} diff --git a/client/my-sites/checkout/src/test/leave-checkout.ts b/client/my-sites/checkout/src/test/leave-checkout.ts new file mode 100644 index 0000000000000..1b3797878caf4 --- /dev/null +++ b/client/my-sites/checkout/src/test/leave-checkout.ts @@ -0,0 +1,126 @@ +/** + * @jest-environment jsdom + */ + +import { navigate } from 'calypso/lib/navigate'; +import { leaveCheckout, isRelativeUrl } from '../lib/leave-checkout'; + +// Mock dependencies +jest.mock( 'calypso/lib/analytics/tracks', () => ( { + recordTracksEvent: jest.fn(), +} ) ); + +jest.mock( 'calypso/lib/navigate', () => ( { + navigate: jest.fn(), +} ) ); + +describe( 'leaveCheckout', () => { + beforeEach( () => { + // Reset all mocks before each test + jest.clearAllMocks(); + // Setup window.location + Object.defineProperty( window, 'location', { + value: { + href: 'https://wordpress.com/checkout', + origin: 'https://wordpress.com', + search: '', + }, + writable: true, + } ); + } ); + + describe( 'cancel_to parameter handling', () => { + it( 'should navigate to cancel_to path when it is a relative URL', () => { + window.location.search = '?cancel_to=/home'; + + leaveCheckout( { tracksEvent: 'checkout_cancel' } ); + + expect( navigate ).toHaveBeenCalledWith( '/home' ); + } ); + + it( 'should not navigate to cancel_to path when it is an absolute URL', () => { + window.location.search = '?cancel_to=https://example.com'; + + leaveCheckout( { tracksEvent: 'checkout_cancel' } ); + + expect( navigate ).not.toHaveBeenCalledWith( 'https://example.com' ); + } ); + + it( 'should not navigate to cancel_to path when it is a protocol-relative URL', () => { + window.location.search = '?cancel_to=//example.com'; + + leaveCheckout( { tracksEvent: 'checkout_cancel' } ); + + expect( navigate ).not.toHaveBeenCalledWith( '//example.com' ); + } ); + + it( 'should not navigate to cancel_to path when it is trying to bypass relative path check', () => { + window.location.search = '?cancel_to=/\\example.com'; + + leaveCheckout( { tracksEvent: 'checkout_cancel' } ); + + expect( navigate ).not.toHaveBeenCalledWith( '/\\example.com' ); + } ); + } ); +} ); + +describe( 'isRelativeUrl', () => { + beforeEach( () => { + Object.defineProperty( window, 'location', { + value: { + href: 'https://wordpress.com/checkout', + origin: 'https://wordpress.com', + }, + writable: true, + } ); + } ); + + describe( 'relative paths', () => { + const testCases = [ + '/home', + '/path/to/page', + 'path', + './path', + '../path', + 'https://wordpress.com/path', + 'wordpress.com/path', + '//wordpress.com/path', + '/\\wordpress.com/path', + ]; + + testCases.forEach( ( path ) => { + it( `should return true for relative path: ${ path }`, () => { + expect( isRelativeUrl( path ) ).toBe( true ); + } ); + } ); + } ); + + describe( 'absolute URLs', () => { + const testCases = [ + 'http://example.com', + '//example.com', + 'https://example.com/path', + '/\\example.com', + ]; + + testCases.forEach( ( url ) => { + it( `should return false for absolute URL: ${ url }`, () => { + expect( isRelativeUrl( url ) ).toBe( false ); + } ); + } ); + } ); + + describe( 'invalid URLs', () => { + const testCases = [ + 'javascript:alert(1)', + 'data:text/html,', + 'mailto:test@example.com', + ]; + + testCases.forEach( ( url ) => { + it( `should return false for invalid URL: ${ url }`, () => { + expect( isRelativeUrl( url ) ).toBe( false ); + } ); + } ); + } ); +} );