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