diff --git a/superset-frontend/src/features/home/Menu.test.tsx b/superset-frontend/src/features/home/Menu.test.tsx index 4b7e744188e..2c07963c7b7 100644 --- a/superset-frontend/src/features/home/Menu.test.tsx +++ b/superset-frontend/src/features/home/Menu.test.tsx @@ -21,9 +21,15 @@ import fetchMock from 'fetch-mock'; import { render, screen, userEvent } from 'spec/helpers/testing-library'; import setupCodeOverrides from 'src/setup/setupCodeOverrides'; import { getExtensionsRegistry } from '@superset-ui/core'; +import * as CoreUI from '@apache-superset/core/ui'; import { Menu } from './Menu'; import * as getBootstrapData from 'src/utils/getBootstrapData'; +jest.mock('@apache-superset/core/ui', () => ({ + ...jest.requireActual('@apache-superset/core/ui'), + useTheme: jest.fn(), +})); + const dropdownItems = [ { label: 'Data', @@ -243,6 +249,8 @@ const staticAssetsPrefixMock = jest.spyOn( getBootstrapData, 'staticAssetsPrefix', ); +const applicationRootMock = jest.spyOn(getBootstrapData, 'applicationRoot'); +const useThemeMock = CoreUI.useTheme as jest.Mock; fetchMock.get( 'glob:*api/v1/database/?q=(filters:!((col:allow_file_upload,opr:upload_is_enabled,value:!t)))', @@ -252,8 +260,11 @@ fetchMock.get( beforeEach(() => { // setup a DOM element as a render target useSelectorMock.mockClear(); - // By default use empty static assets prefix + // By default use empty static assets prefix and default app root staticAssetsPrefixMock.mockReturnValue(''); + applicationRootMock.mockReturnValue(''); + // By default useTheme returns the real default theme (brandLogoUrl is falsy) + useThemeMock.mockReturnValue(CoreUI.supersetTheme); }); test('should render', async () => { @@ -675,3 +686,105 @@ test('should not render the brand text if not available', async () => { const brandText = screen.queryByText(text); expect(brandText).not.toBeInTheDocument(); }); + +test('brand logo href should not be prefixed with app root when brandLogoHref is an absolute URL', async () => { + applicationRootMock.mockReturnValue('/superset'); + useThemeMock.mockReturnValue({ + ...CoreUI.supersetTheme, + brandLogoUrl: '/static/assets/images/custom-logo.png', + brandLogoHref: 'https://external.example.com', + }); + useSelectorMock.mockReturnValue({ roles: user.roles }); + + render(
, { + useRedux: true, + useQueryParams: true, + useRouter: true, + useTheme: true, + }); + + const brandLink = await screen.findByRole('link', { + name: /apache superset/i, + }); + expect(brandLink).toHaveAttribute('href', 'https://external.example.com'); +}); + +test('brand logo href should not be prefixed with app root when brandLogoHref is protocol-relative', async () => { + applicationRootMock.mockReturnValue('/superset'); + useThemeMock.mockReturnValue({ + ...CoreUI.supersetTheme, + brandLogoUrl: '/static/assets/images/custom-logo.png', + brandLogoHref: '//external.example.com', + }); + useSelectorMock.mockReturnValue({ roles: user.roles }); + + render(, { + useRedux: true, + useQueryParams: true, + useRouter: true, + useTheme: true, + }); + + const brandLink = await screen.findByRole('link', { + name: /apache superset/i, + }); + expect(brandLink).toHaveAttribute('href', '//external.example.com'); +}); + +test('brand path should be prefixed with app root in subdirectory deployment', async () => { + applicationRootMock.mockReturnValue('/superset'); + useSelectorMock.mockReturnValue({ roles: user.roles }); + + const propsWithSimplePath = { + ...mockedProps, + data: { + ...mockedProps.data, + brand: { + ...mockedProps.data.brand, + path: '/welcome/', + }, + }, + }; + + render(, { + useRedux: true, + useQueryParams: true, + useRouter: true, + useTheme: true, + }); + + const brandLink = await screen.findByRole('link', { + name: new RegExp(propsWithSimplePath.data.brand.alt, 'i'), + }); + expect(brandLink).toHaveAttribute('href', '/superset/welcome/'); +}); + +test('brand link falls back to brand.path when theme brandLogoUrl is absent', async () => { + // useThemeMock default returns supersetTheme with brandLogoUrl undefined (falsy) + applicationRootMock.mockReturnValue('/superset'); + useSelectorMock.mockReturnValue({ roles: user.roles }); + + const propsWithFallbackPath = { + ...mockedProps, + data: { + ...mockedProps.data, + brand: { + ...mockedProps.data.brand, + path: '/welcome/', + }, + }, + }; + + render(, { + useRedux: true, + useQueryParams: true, + useRouter: true, + useTheme: true, + }); + + const brandLink = await screen.findByRole('link', { + name: new RegExp(propsWithFallbackPath.data.brand.alt, 'i'), + }); + // ensureAppRoot must have been applied: /welcome/ → /superset/welcome/ + expect(brandLink).toHaveAttribute('href', '/superset/welcome/'); +}); diff --git a/superset-frontend/src/utils/pathUtils.test.ts b/superset-frontend/src/utils/pathUtils.test.ts index bf24053e671..fd546ff01d5 100644 --- a/superset-frontend/src/utils/pathUtils.test.ts +++ b/superset-frontend/src/utils/pathUtils.test.ts @@ -158,3 +158,74 @@ test('makeUrl should handle URLs with anchors', async () => { '/superset/dashboard/123#anchor', ); }); + +// Representative URLs used across the absolute-URL passthrough tests below. +const HTTPS_URL = 'https://external.example.com'; +const HTTP_URL = 'http://external.example.com'; +const PROTOCOL_RELATIVE_URL = '//external.example.com'; +const FTP_URL = 'ftp://files.example.com/data'; +const MAILTO_URL = 'mailto:user@example.com'; +const TEL_URL = 'tel:+1234567890'; + +// Sets up bootstrap data and returns a fresh pathUtils module instance. +// Passing appRoot='' (default) simulates no subdirectory deployment. +async function loadPathUtils(appRoot = '') { + const bootstrapData = { common: { application_root: appRoot } }; + document.body.innerHTML = ``; + jest.resetModules(); + await import('./getBootstrapData'); + return import('./pathUtils'); +} + +test('ensureAppRoot should preserve absolute and protocol-relative URLs unchanged with default root', async () => { + const { ensureAppRoot } = await loadPathUtils(); + + expect(ensureAppRoot(HTTPS_URL)).toBe(HTTPS_URL); + expect(ensureAppRoot(HTTP_URL)).toBe(HTTP_URL); + expect(ensureAppRoot(PROTOCOL_RELATIVE_URL)).toBe(PROTOCOL_RELATIVE_URL); +}); + +test('ensureAppRoot should preserve absolute URLs unchanged with custom subdirectory', async () => { + const { ensureAppRoot } = await loadPathUtils('/superset/'); + + expect(ensureAppRoot(HTTPS_URL)).toBe(HTTPS_URL); + expect(ensureAppRoot(HTTP_URL)).toBe(HTTP_URL); + // Non-http absolute schemes: all safe schemes must pass through + expect(ensureAppRoot(FTP_URL)).toBe(FTP_URL); + expect(ensureAppRoot(MAILTO_URL)).toBe(MAILTO_URL); + expect(ensureAppRoot(TEL_URL)).toBe(TEL_URL); +}); + +test('ensureAppRoot should preserve protocol-relative URLs unchanged', async () => { + const { ensureAppRoot } = await loadPathUtils('/superset/'); + + expect(ensureAppRoot(PROTOCOL_RELATIVE_URL)).toBe(PROTOCOL_RELATIVE_URL); +}); + +test('makeUrl should preserve absolute and protocol-relative URLs unchanged', async () => { + const { makeUrl } = await loadPathUtils('/superset/'); + + expect(makeUrl(HTTPS_URL)).toBe(HTTPS_URL); + expect(makeUrl(PROTOCOL_RELATIVE_URL)).toBe(PROTOCOL_RELATIVE_URL); + // Non-http absolute scheme parity with ensureAppRoot + expect(makeUrl(FTP_URL)).toBe(FTP_URL); +}); + +test('ensureAppRoot should block javascript: and data: schemes (XSS prevention)', async () => { + const { ensureAppRoot } = await loadPathUtils('/superset/'); + + // Dangerous schemes must NOT pass through — they get prefixed to neutralise them. + // Build the literals via concatenation so the linter's no-script-url rule + // does not flag this intentional test input. + const jsUrl = `${'javascript'}:alert(1)`; + const dataUrl = `${'data'}:text/html,