From c751f4db0fa491ad860e74494132b8d21e1ab918 Mon Sep 17 00:00:00 2001 From: Augustin Husson Date: Tue, 10 Nov 2026 12:21:49 +0100 Subject: [PATCH] [BUGFIX] Canvas: sanitize node URLs before opening them The URL of a Canvas node was interpolated and passed to window.open() without the verification done for the other links of the plugins (sanitizeUrl), so a javascript: or data: URL, written in the panel or provided by a variable, was opened. As the window is opened with noopener, such a URL doesn't run in the Perses origin, but the panel shouldn't rely on it. The URL is now verified with sanitizeUrl once the variables are interpolated, and a node whose URL uses a disallowed scheme doesn't open anything. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Augustin Husson --- .../components/panel/PanelNodeLayer.test.tsx | 82 +++++++++++++++++++ .../src/components/panel/PanelNodeLayer.tsx | 7 +- 2 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 canvas/src/components/panel/PanelNodeLayer.test.tsx diff --git a/canvas/src/components/panel/PanelNodeLayer.test.tsx b/canvas/src/components/panel/PanelNodeLayer.test.tsx new file mode 100644 index 000000000..9399d68ea --- /dev/null +++ b/canvas/src/components/panel/PanelNodeLayer.test.tsx @@ -0,0 +1,82 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { VariableStateMap } from '@perses-dev/components'; +import type * as PluginSystemModule from '@perses-dev/plugin-system'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { vi } from 'vitest'; + +import type { CanvasTheme } from '../../hooks/useCanvasTheme'; +import type { CanvasSpec } from '../../model'; +import { PanelNodeLayer } from './PanelNodeLayer'; + +vi.mock('@perses-dev/plugin-system', async (importOriginal) => ({ + ...(await importOriginal()), + useAllVariableValues: (): VariableStateMap => ({ + host: { value: 'example.com', loading: false }, + target: { value: 'javascript:alert(document.domain)', loading: false }, + }), +})); + +vi.mock('../../hooks/useCanvasTheme', () => ({ + useCanvasTheme: (): Pick => ({ + connection: '#000000', + nodeDefaultFill: '#ffffff', + }), +})); + +function renderNode(url: string): void { + const spec: CanvasSpec = { + nodes: [{ id: 'node', position: { x: 0, y: 0 }, width: 40, height: 40, kind: 'text', label: 'node', url }], + }; + render( + + + , + ); +} + +describe('PanelNodeLayer', () => { + const open = vi.spyOn(window, 'open').mockReturnValue(null); + + beforeEach(() => { + open.mockClear(); + }); + + it('opens the interpolated URL of a node in a new tab', () => { + renderNode('https://$host/status'); + fireEvent.click(screen.getByText('node')); + expect(open).toHaveBeenCalledWith('https://example.com/status', '_blank', 'noopener,noreferrer'); + }); + + it('opens a relative URL', () => { + renderNode('/projects/demo/dashboards/demo'); + fireEvent.click(screen.getByText('node')); + expect(open).toHaveBeenCalledWith('/projects/demo/dashboards/demo', '_blank', 'noopener,noreferrer'); + }); + + it.each(['javascript:alert(document.domain)', ' JaVa\tScRiPt:alert(1)', 'data:text/html,'])( + 'does not open the URL %j', + (url) => { + renderNode(url); + fireEvent.click(screen.getByText('node')); + expect(open).not.toHaveBeenCalled(); + }, + ); + + it('does not open a URL whose scheme is provided by a variable', () => { + renderNode('$target'); + fireEvent.click(screen.getByText('node')); + expect(open).not.toHaveBeenCalled(); + }); +}); diff --git a/canvas/src/components/panel/PanelNodeLayer.tsx b/canvas/src/components/panel/PanelNodeLayer.tsx index 497d7ad8d..5416539f3 100644 --- a/canvas/src/components/panel/PanelNodeLayer.tsx +++ b/canvas/src/components/panel/PanelNodeLayer.tsx @@ -11,6 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +import { sanitizeUrl } from '@perses-dev/components'; import { replaceVariablesInString, useAllVariableValues } from '@perses-dev/plugin-system'; import type { TimeSeries } from '@perses-dev/spec'; import type { ReactElement } from 'react'; @@ -84,7 +85,11 @@ export function PanelNodeLayer({ const handleNodeClick = useCallback( (url: string) => { - window.open(replaceVariablesInString(url, variableValues), '_blank', 'noopener,noreferrer'); + // The URL is verified once interpolated, as a variable can provide its scheme (e.g. javascript:). + const href = sanitizeUrl(replaceVariablesInString(url, variableValues)); + if (href) { + window.open(href, '_blank', 'noopener,noreferrer'); + } }, [variableValues], );