diff --git a/webapp/src/desktopHistory.test.ts b/webapp/src/desktopHistory.test.ts new file mode 100644 index 00000000..37a14322 --- /dev/null +++ b/webapp/src/desktopHistory.test.ts @@ -0,0 +1,161 @@ +// Copyright (c) 2020-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {History} from 'history' + +import {Utils} from './utils' +import {SuiteWindow} from './types/index' + +import {boardsRouteBase, customHistory, doBrowserHistoryPush, handleBrowserHistoryMessage, handleBrowserHistoryPush} from './desktopHistory' + +const windowAny = (window as SuiteWindow) + +describe('desktopHistory', () => { + const originalFrontendBaseURL = windowAny.frontendBaseURL + + beforeEach(() => { + jest.restoreAllMocks() + jest.spyOn(Utils, 'log').mockImplementation(() => {}) + delete windowAny.desktopAPI + windowAny.frontendBaseURL = '/boards' + }) + + afterAll(() => { + windowAny.frontendBaseURL = originalFrontendBaseURL + }) + + describe('handleBrowserHistoryPush', () => { + const makeHistory = () => ({replace: jest.fn()} as unknown as History) + + test('ignores an empty path', () => { + const history = makeHistory() + handleBrowserHistoryPush('', history) + expect(history.replace).not.toHaveBeenCalled() + }) + + test('ignores a path that still carries the server subpath', () => { + const history = makeHistory() + handleBrowserHistoryPush('/company/boards/team/team-id', history) + expect(history.replace).not.toHaveBeenCalled() + }) + + test('strips the boards route base before navigating', () => { + const history = makeHistory() + handleBrowserHistoryPush(`${boardsRouteBase}/team/team-id`, history) + expect(history.replace).toHaveBeenCalledWith('/team/team-id') + }) + + test('navigates to the root for the bare boards route', () => { + const history = makeHistory() + handleBrowserHistoryPush(boardsRouteBase, history) + expect(history.replace).toHaveBeenCalledWith('/') + }) + + test('ignores a route that only shares the boards prefix', () => { + const history = makeHistory() + handleBrowserHistoryPush('/boards-legacy/team/team-id', history) + expect(history.replace).not.toHaveBeenCalled() + }) + + test('rejects path traversal that escapes the boards route', () => { + const history = makeHistory() + handleBrowserHistoryPush('/boards/../admin', history) + expect(history.replace).not.toHaveBeenCalled() + }) + + test('rejects percent-encoded path traversal', () => { + const history = makeHistory() + handleBrowserHistoryPush('/boards/%2e%2e/admin', history) + expect(history.replace).not.toHaveBeenCalled() + }) + + test('preserves the query string and hash', () => { + const history = makeHistory() + handleBrowserHistoryPush('/boards/team/team-id?view=1#card', history) + expect(history.replace).toHaveBeenCalledWith('/team/team-id?view=1#card') + }) + }) + + describe('handleBrowserHistoryMessage', () => { + const makeHistory = () => ({replace: jest.fn()} as unknown as History) + const sameOrigin = window.location.origin + + test('forwards a valid same-origin boards path', () => { + const history = makeHistory() + const event = {origin: sameOrigin, data: {message: {pathName: '/boards/team/team-id'}}} as MessageEvent + handleBrowserHistoryMessage(event, history) + expect(history.replace).toHaveBeenCalledWith('/team/team-id') + }) + + test('ignores messages from a different origin', () => { + const history = makeHistory() + const event = {origin: 'https://evil.example', data: {message: {pathName: '/boards/team/team-id'}}} as MessageEvent + handleBrowserHistoryMessage(event, history) + expect(history.replace).not.toHaveBeenCalled() + }) + + test('ignores a null payload without throwing', () => { + const history = makeHistory() + const event = {origin: sameOrigin, data: null} as MessageEvent + expect(() => handleBrowserHistoryMessage(event, history)).not.toThrow() + expect(history.replace).not.toHaveBeenCalled() + }) + + test('ignores a non-string pathName', () => { + const history = makeHistory() + const event = {origin: sameOrigin, data: {message: {pathName: 42}}} as unknown as MessageEvent + handleBrowserHistoryMessage(event, history) + expect(history.replace).not.toHaveBeenCalled() + }) + }) + + describe('doBrowserHistoryPush', () => { + test('uses the desktop API when available', () => { + const sendBrowserHistoryPush = jest.fn() + windowAny.desktopAPI = {sendBrowserHistoryPush} + + doBrowserHistoryPush('/boards/team/team-id') + + expect(sendBrowserHistoryPush).toHaveBeenCalledWith('/boards/team/team-id') + }) + + test('falls back to postMessage when the desktop API is missing', () => { + const postMessage = jest.spyOn(window, 'postMessage').mockImplementation(() => {}) + + doBrowserHistoryPush('/boards/team/team-id') + + expect(postMessage).toHaveBeenCalledWith( + {type: 'browser-history-push', message: {path: '/boards/team/team-id'}}, + window.location.origin, + ) + }) + }) + + describe('customHistory push on desktop', () => { + test('sends a subpath-relative path so the subpath is never leaked (MM-67542)', () => { + jest.spyOn(Utils, 'isDesktop').mockReturnValue(true) + const sendBrowserHistoryPush = jest.fn() + windowAny.desktopAPI = {sendBrowserHistoryPush} + + // Simulate a subpath deployment: the frontend base URL includes the subpath. + windowAny.frontendBaseURL = '/company/boards' + + const history = customHistory() + history.push('/team/team-id') + + expect(sendBrowserHistoryPush).toHaveBeenCalledWith('/boards/team/team-id') + expect(sendBrowserHistoryPush).not.toHaveBeenCalledWith('/company/boards/team/team-id') + }) + + test('does not notify the desktop app when not running in desktop', () => { + jest.spyOn(Utils, 'isDesktop').mockReturnValue(false) + const sendBrowserHistoryPush = jest.fn() + windowAny.desktopAPI = {sendBrowserHistoryPush} + + const history = customHistory() + history.push('/team/team-id') + + expect(sendBrowserHistoryPush).not.toHaveBeenCalled() + }) + }) +}) diff --git a/webapp/src/desktopHistory.ts b/webapp/src/desktopHistory.ts new file mode 100644 index 00000000..fc9428f4 --- /dev/null +++ b/webapp/src/desktopHistory.ts @@ -0,0 +1,82 @@ +// Copyright (c) 2020-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {createBrowserHistory, History} from 'history' + +import {Utils} from './utils' +import {SuiteWindow} from './types/index' + +const windowAny = (window as SuiteWindow) + +// Boards is always mounted at this in-app route. Paths exchanged with the +// Desktop App must be relative to the server subpath (the history basename), +// exactly like the core web app. Prefixing the full frontendBaseURL here would +// leak the subpath and force the Desktop App to strip it (see MM-67542). +export const boardsRouteBase = '/boards' + +export const doBrowserHistoryPush = (path: string): void => { + if (windowAny.desktopAPI?.sendBrowserHistoryPush) { + windowAny.desktopAPI.sendBrowserHistoryPush(path) + } else { + window.postMessage( + { + type: 'browser-history-push', + message: {path}, + }, + window.location.origin, + ) + } +} + +export const handleBrowserHistoryPush = (pathName: string, history: History): void => { + // Only navigate for the boards root or a path under it, so a route like + // `/boards-legacy/...` is not mistaken for a boards path. + if (!pathName || (pathName !== boardsRouteBase && !pathName.startsWith(`${boardsRouteBase}/`))) { + return + } + + // Resolve dot segments so traversal like `/boards/../admin` can't escape the + // boards route, then re-check the boundary against the canonical path. + const {pathname, search, hash} = new URL(pathName, window.location.origin) + if (pathname !== boardsRouteBase && !pathname.startsWith(`${boardsRouteBase}/`)) { + return + } + + const relativePath = pathname === boardsRouteBase ? '/' : pathname.slice(boardsRouteBase.length) + Utils.log(`Navigating Boards to ${pathName}`) + history.replace(`${relativePath}${search}${hash}`) +} + +export const handleBrowserHistoryMessage = (event: MessageEvent, history: History): void => { + if (event.origin !== windowAny.location.origin) { + return + } + + const pathName = event.data?.message?.pathName + if (typeof pathName === 'string') { + handleBrowserHistoryPush(pathName, history) + } +} + +export function customHistory() { + const history = createBrowserHistory({basename: Utils.getFrontendBaseURL()}) + + if (Utils.isDesktop()) { + if (windowAny.desktopAPI?.onBrowserHistoryPush) { + windowAny.desktopAPI.onBrowserHistoryPush((pathName) => handleBrowserHistoryPush(pathName, history)) + } else { + window.addEventListener('message', (event: MessageEvent) => handleBrowserHistoryMessage(event, history)) + } + } + + return { + ...history, + push: (path: string, state?: unknown) => { + if (Utils.isDesktop()) { + doBrowserHistoryPush(`${boardsRouteBase}${path}`) + } else { + history.push(path, state as Record) + } + }, + } +} diff --git a/webapp/src/index.tsx b/webapp/src/index.tsx index 86c3aade..74828136 100644 --- a/webapp/src/index.tsx +++ b/webapp/src/index.tsx @@ -5,7 +5,7 @@ import React, {useEffect} from 'react' import {createIntl, createIntlCache} from 'react-intl' import {Store, Action} from 'redux' import {Provider as ReduxProvider} from 'react-redux' -import {createBrowserHistory, History} from 'history' +import {History} from 'history' import {GlobalState} from '@mattermost/types/store' import {selectTeam} from 'mattermost-redux/actions/teams' @@ -21,6 +21,7 @@ import WithWebSockets from './components/withWebSockets' import {setChannel} from './store/channels' import {initialLoad} from './store/initialLoad' import {Utils} from './utils' +import {customHistory} from './desktopHistory' import './styles/focalboard-variables.scss' import './styles/main.scss' import './styles/labels.scss' @@ -68,58 +69,6 @@ type Props = { webSocketClient: MMWebSocketClient } -const doBrowserHistoryPush = (path: string) => { - if (windowAny.desktopAPI?.sendBrowserHistoryPush) { - windowAny.desktopAPI.sendBrowserHistoryPush(path) - } else { - window.postMessage( - { - type: 'browser-history-push', - message: { path }, - }, - window.location.origin, - ) - } -} - -const handleBrowserHistoryPush = (pathName: string, history: ReturnType) => { - if (!pathName || !pathName.startsWith('/boards')) { - return - } - - Utils.log(`Navigating Boards to ${pathName}`) - history.replace(pathName.replace('/boards', '')) -} - -function customHistory() { - const history = createBrowserHistory({ basename: Utils.getFrontendBaseURL() }) - - if (Utils.isDesktop()) { - if (windowAny.desktopAPI?.onBrowserHistoryPush) { - windowAny.desktopAPI.onBrowserHistoryPush((pathName) => handleBrowserHistoryPush(pathName, history)) - } else { - window.addEventListener('message', (event: MessageEvent) => { - if (event.origin !== windowAny.location.origin) { - return - } - - handleBrowserHistoryPush(event.data.message?.pathName, history) - }) - } - } - - return { - ...history, - push: (path: string, state?: unknown) => { - if (Utils.isDesktop()) { - doBrowserHistoryPush(`${windowAny.frontendBaseURL}${path}`) - } else { - history.push(path, state as Record) - } - }, - } -} - let browserHistory: History const MainApp = (props: Props) => {