diff --git a/packages/dashboard/e2e/tests/widgets/text.spec.ts b/packages/dashboard/e2e/tests/widgets/text.spec.ts index 1e936ffd3..0b03d193b 100644 --- a/packages/dashboard/e2e/tests/widgets/text.spec.ts +++ b/packages/dashboard/e2e/tests/widgets/text.spec.ts @@ -280,5 +280,37 @@ test.describe('Test Text Widget', () => { await page.getByText(TEXT_WIDGET_CONTENT).click(); await page.waitForURL(`**${url}`); }); + + test('when link is enabled, it should sanitize href and not link if dangerous', async ({ + dashboardWithTextWidget, + configPanel, + page, + }) => { + const widget = dashboardWithTextWidget.gridArea.locator( + '[data-gesture=widget]' + ); + + const url = "javascript://%0aalert('1.com')"; + await configPanel.collapsedButton.click(); + await widget.dblclick(); + await widget.getByRole('textbox').fill(TEXT_WIDGET_CONTENT); + await configPanel.container + .getByTestId('text-widget-link-header') + .click(); + await configPanel.container + .getByText('Create link', { exact: true }) + .click(); + await configPanel.container + .getByLabel('text widget link input') + .fill(url); + + // go to preview + await page.getByRole('button', { name: 'preview' }).click(); + + // clicking on the link + await page.getByText(TEXT_WIDGET_CONTENT).click(); + + expect(page.getByText(TEXT_WIDGET_CONTENT)).not.toHaveAttribute('href'); + }); }); }); diff --git a/packages/dashboard/src/customization/widgets/text/__snapshots__/component.test.tsx.snap b/packages/dashboard/src/customization/widgets/text/__snapshots__/component.test.tsx.snap deleted file mode 100644 index 2cb029223..000000000 --- a/packages/dashboard/src/customization/widgets/text/__snapshots__/component.test.tsx.snap +++ /dev/null @@ -1,81 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Text Widget should render {"readOnly":false,"isSelected":false,"isUrl":false} correctly 1`] = ` -
-
- {"readOnly":false,"isSelected":false,"id":"some-id","x":1,"y":2,"z":3,"height":100,"width":100,"type":"text-widget","properties":{"isUrl":false,"value":"abc"}} -
-
-`; - -exports[`Text Widget should render {"readOnly":false,"isSelected":false,"isUrl":true} correctly 1`] = ` -
-
- {"isUrl":true,"readOnly":false,"isSelected":false,"id":"some-id","x":1,"y":2,"z":3,"height":100,"width":100,"type":"text-widget","properties":{"isUrl":true,"value":"abc"}} -
-
-`; - -exports[`Text Widget should render {"readOnly":false,"isSelected":true,"isUrl":false} correctly 1`] = ` -
-
- {"readOnly":false,"isSelected":true,"id":"some-id","x":1,"y":2,"z":3,"height":100,"width":100,"type":"text-widget","properties":{"isUrl":false,"value":"abc"}} -
-
-`; - -exports[`Text Widget should render {"readOnly":false,"isSelected":true,"isUrl":true} correctly 1`] = ` -
-
- {"isUrl":true,"readOnly":false,"isSelected":true,"id":"some-id","x":1,"y":2,"z":3,"height":100,"width":100,"type":"text-widget","properties":{"isUrl":true,"value":"abc"}} -
-
-`; - -exports[`Text Widget should render {"readOnly":true,"isSelected":false,"isUrl":false} correctly 1`] = ` -
-
- {"id":"some-id","x":1,"y":2,"z":3,"height":100,"width":100,"type":"text-widget","properties":{"isUrl":false,"value":"abc"}} -
-
-`; - -exports[`Text Widget should render {"readOnly":true,"isSelected":false,"isUrl":true} correctly 1`] = ` -
-
- {"id":"some-id","x":1,"y":2,"z":3,"height":100,"width":100,"type":"text-widget","properties":{"isUrl":true,"value":"abc"}} -
-
-`; - -exports[`Text Widget should render {"readOnly":true,"isSelected":true,"isUrl":false} correctly 1`] = ` -
-
- {"id":"some-id","x":1,"y":2,"z":3,"height":100,"width":100,"type":"text-widget","properties":{"isUrl":false,"value":"abc"}} -
-
-`; - -exports[`Text Widget should render {"readOnly":true,"isSelected":true,"isUrl":true} correctly 1`] = ` -
-
- {"id":"some-id","x":1,"y":2,"z":3,"height":100,"width":100,"type":"text-widget","properties":{"isUrl":true,"value":"abc"}} -
-
-`; diff --git a/packages/dashboard/src/customization/widgets/text/component.test.tsx b/packages/dashboard/src/customization/widgets/text/component.test.tsx index 0cebb062f..5a979fe01 100644 --- a/packages/dashboard/src/customization/widgets/text/component.test.tsx +++ b/packages/dashboard/src/customization/widgets/text/component.test.tsx @@ -1,10 +1,8 @@ import React from 'react'; - -import { render } from '@testing-library/react'; -import { useSelector, useDispatch } from 'react-redux'; +import { render, screen } from '@testing-library/react'; import TextWidgetComponent from './component'; -import { useIsSelected } from '~/customization/hooks/useIsSelected'; -import { onChangeDashboardGridEnabledAction } from '~/store/actions'; +import * as ReactRedux from 'react-redux'; +import type { TextProperties } from '../types'; jest.mock('~/store/actions', () => ({ ...jest.requireActual('~/store/actions'), @@ -13,88 +11,101 @@ jest.mock('~/store/actions', () => ({ jest.mock('react-redux', () => ({ ...jest.requireActual('react-redux'), - useSelector: jest.fn(), - useDispatch: jest.fn(), + useDispatch: () => jest.fn(), })); jest.mock('~/customization/hooks/useIsSelected', () => ({ useIsSelected: jest.fn(), })); -jest.mock('./link', () => (props: unknown) => ( -
{JSON.stringify(props)}
-)); -jest.mock('./styledText/textArea', () => (props: unknown) => ( -
{JSON.stringify(props)}
-)); -jest.mock('./styledText/editableText', () => (props: unknown) => ( -
{JSON.stringify(props)}
-)); -jest.mock('./styledText', () => (props: unknown) => ( -
{JSON.stringify(props)}
-)); +function TextTextWidget(props: TextProperties) { + return ( + + ); +} + +function TestReadonlyTextWidget(props: TextProperties) { + jest.spyOn(ReactRedux, 'useSelector').mockReturnValueOnce(true); + + return ; +} + +function TestEditableTextWidget(props: TextProperties) { + jest.spyOn(ReactRedux, 'useSelector').mockReturnValueOnce(false); + + return ; +} describe('Text Widget', () => { - beforeEach(() => { - jest.resetAllMocks(); + it('should render readonly text', () => { + const text = 'text'; + render(); + + const textWidget = screen.getByText(text); + expect(textWidget).toBeVisible(); + expect(textWidget).toHaveAttribute('aria-readonly'); + expect(screen.queryByRole('link', { name: text })).not.toBeInTheDocument(); }); - [ - { readOnly: true, isSelected: true, isUrl: true }, - { readOnly: true, isSelected: true, isUrl: false }, - { readOnly: true, isSelected: false, isUrl: false }, - { readOnly: false, isSelected: false, isUrl: false }, - { readOnly: false, isSelected: false, isUrl: true }, - { readOnly: false, isSelected: true, isUrl: true }, - { readOnly: false, isSelected: true, isUrl: false }, - { readOnly: true, isSelected: false, isUrl: true }, - ].forEach((configuration) => { - it(`should render ${JSON.stringify(configuration)} correctly`, () => { - const { isSelected, readOnly, isUrl } = configuration; - (useIsSelected as jest.Mock).mockImplementation(() => isSelected); - (useSelector as jest.Mock).mockImplementation(() => readOnly); - (useDispatch as jest.Mock).mockReturnValue(jest.fn()); - - const { container } = render( - - ); - - expect(container).toMatchSnapshot(); - }); + it('should render editable text', () => { + const text = 'text'; + render(); + + const textWidget = screen.getByText(text); + expect(textWidget).toBeVisible(); + expect(textWidget).not.toHaveAttribute('aria-readonly'); + expect(screen.queryByRole('link', { name: text })).not.toBeInTheDocument(); }); - it('should exit edit mode when unmounted', () => { - (useDispatch as jest.Mock).mockImplementation(() => jest.fn((cb) => cb())); // short curcuit dispatch - - (useIsSelected as jest.Mock).mockImplementation(() => false); - (useSelector as jest.Mock).mockImplementation(() => false); - (useDispatch as jest.Mock).mockReturnValue(jest.fn()); - - const { unmount } = render( - - ); - unmount(); - - expect(onChangeDashboardGridEnabledAction).toBeCalledWith({ - enabled: true, - }); + it('should render editable link', () => { + const text = 'text'; + const href = 'https://test.com'; + render(); + + const textWidget = screen.getByText(text); + expect(textWidget).toHaveTextContent(text); + expect(textWidget).not.toHaveAttribute('aria-readonly'); + expect(screen.queryByRole('link', { name: text })).not.toBeInTheDocument(); + }); + + it('should santize and then render unsafe link that injects javascript', () => { + const text = 'text'; + const href = 'javascript://%0Aalert(1)'; + render(); + + const textWidget = screen.getByText(text); + expect(textWidget).toBeVisible(); + expect(textWidget).not.toHaveAttribute('href'); + expect(screen.queryByRole('link', { name: text })).not.toBeInTheDocument(); + }); + + it('should santize and then render unsafe link that injects javascript via hex format', () => { + const text = 'text'; + const href = '\x6A\x61\x76\x61\x73\x63\x72\x69\x70\x74\x3aalert(1)'; + render(); + + const textWidget = screen.getByText(text); + expect(textWidget).toBeVisible(); + expect(textWidget).not.toHaveAttribute('href'); + expect(screen.queryByRole('link', { name: text })).not.toBeInTheDocument(); + }); + + it('should render a safe link', () => { + const text = 'text'; + const href = 'https://test.com'; + render(); + + const textWidget = screen.getByRole('link', { name: text }); + expect(textWidget).toBeVisible(); + expect(textWidget).toHaveAttribute('href', href); }); }); diff --git a/packages/dashboard/src/customization/widgets/text/component.tsx b/packages/dashboard/src/customization/widgets/text/component.tsx index 88ce0ccd2..f95dc3a6f 100644 --- a/packages/dashboard/src/customization/widgets/text/component.tsx +++ b/packages/dashboard/src/customization/widgets/text/component.tsx @@ -19,6 +19,7 @@ const TextWidgetComponent: React.FC = (widget) => { const { isUrl, value } = widget.properties; const dispatch = useDispatch(); + const [isEditing, setIsEditing] = useState(false); const handleSetEdit = useCallback( @@ -50,7 +51,7 @@ const TextWidgetComponent: React.FC = (widget) => { if (isUrl) { return ; } else { - return ; + return ; } } else { if (isUrl) { diff --git a/packages/dashboard/src/customization/widgets/text/styledText/index.tsx b/packages/dashboard/src/customization/widgets/text/styledText/index.tsx index 75e226d42..69885be8a 100644 --- a/packages/dashboard/src/customization/widgets/text/styledText/index.tsx +++ b/packages/dashboard/src/customization/widgets/text/styledText/index.tsx @@ -9,11 +9,13 @@ import { spaceScaledXs } from '@cloudscape-design/design-tokens'; type StyledTextProps = TextWidget & { onPointerDown?: PointerEventHandler; onPointerUp?: PointerEventHandler; + readonly?: boolean; }; const StyledText: React.FC = ({ onPointerDown, onPointerUp, + readonly, ...widget }) => { const { value } = widget.properties; @@ -43,7 +45,12 @@ const StyledText: React.FC = ({ }; return ( -

+

{textContent}

);