diff --git a/frontend/package.json b/frontend/package.json index ab4e391c1ee..7764028724e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -170,6 +170,7 @@ "use-resize-observer": "^9.1.0", "vega-lite": "^5.23.0", "vega-loader": "^4.5.3", + "vega-tooltip": "^1.1.0", "vscode-jsonrpc": "^8.2.1", "vscode-languageserver-protocol": "^3.17.5", "web-vitals": "^4.2.4", diff --git a/frontend/src/components/charts/tooltip.ts b/frontend/src/components/charts/tooltip.ts new file mode 100644 index 00000000000..ca1ea6621a1 --- /dev/null +++ b/frontend/src/components/charts/tooltip.ts @@ -0,0 +1,4 @@ +import { Handler } from "vega-tooltip"; + +// Create a tooltip handler that supports HTML content (including images) +export const tooltipHandler = new Handler(); diff --git a/frontend/src/components/data-table/charts/lazy-chart.tsx b/frontend/src/components/data-table/charts/lazy-chart.tsx index 305f3d6f9d2..c1c8235e15e 100644 --- a/frontend/src/components/data-table/charts/lazy-chart.tsx +++ b/frontend/src/components/data-table/charts/lazy-chart.tsx @@ -2,6 +2,7 @@ import React from "react"; import type { TopLevelSpec } from "vega-lite"; +import { tooltipHandler } from "@/components/charts/tooltip"; import { useTheme } from "@/theme/useTheme"; import type { ErrorMessage } from "./chart-spec/spec"; import { augmentSpecWithData } from "./chart-spec/spec"; @@ -33,6 +34,7 @@ export const LazyChart: React.FC<{ spec={spec} theme={theme === "dark" ? "dark" : undefined} height={height} + tooltip={tooltipHandler.call} actions={{ export: true, source: false, diff --git a/frontend/src/components/editor/Output.tsx b/frontend/src/components/editor/Output.tsx index 30464fc7eb3..dd6639e8ab7 100644 --- a/frontend/src/components/editor/Output.tsx +++ b/frontend/src/components/editor/Output.tsx @@ -25,6 +25,7 @@ import { ChevronsUpDownIcon, ExpandIcon, } from "lucide-react"; +import { tooltipHandler } from "@/components/charts/tooltip"; import { useExpandedOutput } from "@/core/cells/outputs"; import { useIframeCapabilities } from "@/hooks/useIframeCapabilities"; import { renderHTML } from "@/plugins/core/RenderHTML"; @@ -179,6 +180,7 @@ export const OutputRenderer: React.FC<{ }> diff --git a/frontend/src/plugins/impl/data-explorer/ConnectedDataExplorerComponent.tsx b/frontend/src/plugins/impl/data-explorer/ConnectedDataExplorerComponent.tsx index 613d1fd3951..279d6c0e1bd 100644 --- a/frontend/src/plugins/impl/data-explorer/ConnectedDataExplorerComponent.tsx +++ b/frontend/src/plugins/impl/data-explorer/ConnectedDataExplorerComponent.tsx @@ -6,6 +6,7 @@ import { ListFilterIcon } from "lucide-react"; import React, { type JSX, useMemo } from "react"; import { VegaLite } from "react-vega"; import type { VegaLiteProps } from "react-vega/lib/VegaLite"; +import { tooltipHandler } from "@/components/charts/tooltip"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Tooltip } from "@/components/ui/tooltip"; @@ -135,6 +136,7 @@ export const DataExplorerComponent = ({ padding={PADDING} actions={ACTIONS} spec={makeResponsive(mainPlot.spec)} + tooltip={tooltipHandler.call} theme={theme === "dark" ? "dark" : undefined} /> @@ -210,6 +212,7 @@ export const DataExplorerComponent = ({ key={idx} actions={false} spec={plot.spec} + tooltip={tooltipHandler.call} theme={theme === "dark" ? "dark" : undefined} /> diff --git a/frontend/src/plugins/impl/vega/vega-component.tsx b/frontend/src/plugins/impl/vega/vega-component.tsx index 1612946802b..61aca665a57 100644 --- a/frontend/src/plugins/impl/vega/vega-component.tsx +++ b/frontend/src/plugins/impl/vega/vega-component.tsx @@ -8,6 +8,7 @@ import useEvent from "react-use-event-hook"; import { type SignalListeners, VegaLite, type View } from "react-vega"; // @ts-expect-error vega-typings does not include formats import { formats } from "vega-loader"; +import { tooltipHandler } from "@/components/charts/tooltip"; import { Alert, AlertTitle } from "@/components/ui/alert"; import { Tooltip } from "@/components/ui/tooltip"; import { useAsyncData } from "@/hooks/useAsyncData"; @@ -26,7 +27,6 @@ import type { VegaLiteSpec } from "./types"; // register arrow reader under type 'arrow' formats("arrow", arrow); - export interface Data { spec: VegaLiteSpec; chartSelection: boolean | "point" | "interval"; @@ -234,6 +234,7 @@ const LoadedVegaComponent = ({ signalListeners={signalListeners} onError={handleError} onNewView={handleNewView} + tooltip={tooltipHandler.call} /> {renderHelpContent()} diff --git a/marimo/_smoke_tests/altair_examples/tooltip_images.py b/marimo/_smoke_tests/altair_examples/tooltip_images.py new file mode 100644 index 00000000000..f4b06cd3624 --- /dev/null +++ b/marimo/_smoke_tests/altair_examples/tooltip_images.py @@ -0,0 +1,206 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = [ +# "pandas", +# "altair", +# "marimo", +# ] +# /// +# Copyright 2024 Marimo. All rights reserved. + +import marimo + +__generated_with = "0.17.2" +app = marimo.App(width="medium") + + +@app.cell +def _(): + import marimo as mo + return (mo,) + + +@app.cell(hide_code=True) +def _(mo): + mo.md( + """ + # Altair Tooltip Images + + This smoke test verifies that images in Altair tooltips render correctly + and that the tooltip handler properly sanitizes potentially malicious content. + + **Tests:** + 1. External image URLs in tooltips + 2. Base64-encoded images in tooltips + 3. XSS vulnerability prevention + """ + ) + return + + +@app.cell +def _(): + import altair as alt + import pandas as pd + + # Create sample data with image URLs + source = pd.DataFrame( + { + "a": [1, 2], + "b": [1, 2], + "image": [ + "https://marimo.io/logo.png", + "https://marimo.io/favicon.ico", + ], + } + ) + + # Create chart with image tooltip + chart = ( + alt.Chart(source) + .mark_circle(size=200) + .encode( + x=alt.X("a", scale=alt.Scale(domain=[0, 3])), + y=alt.Y("b", scale=alt.Scale(domain=[0, 3])), + tooltip=["image"], + ) + .properties( + title="Scatter Plot with Image Tooltips - Hover to see images", + width=400, + height=400, + ) + ) + + chart + return alt, pd + + +@app.cell(hide_code=True) +def _(mo): + mo.md( + r""" + ## Instructions + + Hover over the circles to see the image tooltips. + The images should render in the tooltip, not just show URLs as text. + """ + ) + return + + +@app.cell +def _(alt, pd): + # Example with base64 encoded image + base64_image = "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTAiIGhlaWdodD0iNTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGNpcmNsZSBjeD0iMjUiIGN5PSIyNSIgcj0iMjAiIGZpbGw9InJlZCIvPjwvc3ZnPg==" + + source2 = pd.DataFrame( + { + "x": [1, 2, 3], + "y": [1, 2, 1], + "name": ["Red Circle", "Blue Square", "Green Triangle"], + "image": [ + base64_image, + "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTAiIGhlaWdodD0iNTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHJlY3QgeD0iMTAiIHk9IjEwIiB3aWR0aD0iMzAiIGhlaWdodD0iMzAiIGZpbGw9ImJsdWUiLz48L3N2Zz4=", + "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTAiIGhlaWdodD0iNTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBvbHlnb24gcG9pbnRzPSIyNSw1IDQwLDQwIDEwLDQwIiBmaWxsPSJncmVlbiIvPjwvc3ZnPg==", + ], + } + ) + + chart2 = ( + alt.Chart(source2) + .mark_point(size=300, filled=True) + .encode( + x=alt.X("x", scale=alt.Scale(domain=[0, 4])), + y=alt.Y("y", scale=alt.Scale(domain=[0, 3])), + tooltip=["name", "image"], + ) + .properties( + title="Chart with Base64 Encoded Image Tooltips", + width=400, + height=300, + ) + ) + + chart2 + return + + +@app.cell(hide_code=True) +def _(mo): + mo.md( + r""" + ## XSS Security Tests + + These tests verify that the tooltip handler properly sanitizes + potentially malicious content and prevents XSS attacks. + + **Expected behavior:** All XSS attempts should be neutralized. + No alert boxes or script execution should occur when hovering. + """ + ) + return + + +@app.cell +def _(alt, pd): + # Test various XSS attack vectors + xss_test_data = pd.DataFrame( + { + "x": [1, 2, 3, 4, 1, 2, 3, 4], + "y": [1, 1, 1, 1, 2, 2, 2, 2], + "label": [ + "Script Tag", + "Event Handler", + "JS URL", + "IMG onerror", + "SVG Script", + "HTML Injection", + "OnMouseOver", + "Data URL JS", + ], + "image": [ + # Script tag injection + '', + # Event handler + "", + # JavaScript URL + "click", + # IMG with onerror + '', + # SVG with embedded script + '', + # HTML injection + "
Click me
", + # OnMouseOver + "hover", + # Data URL with JavaScript + "alert('XSS')\">", + ], + } + ) + + xss_chart = ( + alt.Chart(xss_test_data) + .mark_circle(size=150) + .encode( + x=alt.X("x:O", title="Test Vector"), + y=alt.Y("y:O", title="Category"), + color=alt.Color( + "label:N", + legend=alt.Legend(title="Attack Type"), + ), + tooltip=["label", "image"], + ) + .properties( + title="XSS Security Test - Hover to verify sanitization", + width=600, + height=300, + ) + ) + + xss_chart + return + + +if __name__ == "__main__": + app.run() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 086b7309739..a4ae5beaf2e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -496,6 +496,9 @@ importers: vega-loader: specifier: ^4.5.3 version: 4.5.3 + vega-tooltip: + specifier: ^1.1.0 + version: 1.1.0 vscode-jsonrpc: specifier: ^8.2.1 version: 8.2.1 @@ -9813,6 +9816,9 @@ packages: vega-tooltip@0.22.1: resolution: {integrity: sha512-mPmzxwvi6+2ZgbZ/+mNC7XbSu5I6Ckon8zdgUfH9neb+vV7CKlV/FYypMdVN/9iDMFUqGzybYdqNOiSPPIxFEQ==} + vega-tooltip@1.1.0: + resolution: {integrity: sha512-PP4CxC8gX//SBUtlcJkwffmvdZBvzAsqS0EANBKvImJ9PxV/KtJkcs7RCqp+A7nh2cjWdVzyOBWAvqKhXJStTQ==} + vega-transforms@4.12.1: resolution: {integrity: sha512-Qxo+xeEEftY1jYyKgzOGc9NuW4/MqGm1YPZ5WrL9eXg2G0410Ne+xL/MFIjHF4hRX+3mgFF4Io2hPpfy/thjLg==} @@ -9828,6 +9834,9 @@ packages: vega-util@1.17.3: resolution: {integrity: sha512-nSNpZLUrRvFo46M5OK4O6x6f08WD1yOcEzHNlqivF+sDLSsVpstaF6fdJYwrbf/debFi2L9Tkp4gZQtssup9iQ==} + vega-util@2.1.0: + resolution: {integrity: sha512-PGfp0m0QCufDmcxKJCWQy4Ov23FoF8DSXmoJwSezi3itQaa2hbxK0+xwsTMP2vy4PR16Pu25HMzgMwXVW1+33w==} + vega-view-transforms@4.6.1: resolution: {integrity: sha512-RYlyMJu5kZV4XXjmyTQKADJWDB25SMHsiF+B1rbE1p+pmdQPlp5tGdPl9r5dUJOp3p8mSt/NGI8GPGucmPMxtw==} @@ -21483,6 +21492,10 @@ snapshots: dependencies: vega-util: 1.17.3 + vega-tooltip@1.1.0: + dependencies: + vega-util: 2.1.0 + vega-transforms@4.12.1: dependencies: d3-array: 3.2.4 @@ -21508,6 +21521,8 @@ snapshots: vega-util@1.17.3: {} + vega-util@2.1.0: {} + vega-view-transforms@4.6.1: dependencies: vega-dataflow: 5.7.7