Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/components/charts/tooltip.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { Handler } from "vega-tooltip";

// Create a tooltip handler that supports HTML content (including images)
export const tooltipHandler = new Handler();
2 changes: 2 additions & 0 deletions frontend/src/components/data-table/charts/lazy-chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/components/editor/Output.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -179,6 +180,7 @@ export const OutputRenderer: React.FC<{
<Suspense fallback={<ChartLoadingState />}>
<LazyVegaLite
spec={parsedJsonData as TopLevelFacetedUnitSpec}
tooltip={tooltipHandler.call}
theme={theme === "dark" ? "dark" : undefined}
/>
</Suspense>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -135,6 +136,7 @@ export const DataExplorerComponent = ({
padding={PADDING}
actions={ACTIONS}
spec={makeResponsive(mainPlot.spec)}
tooltip={tooltipHandler.call}
theme={theme === "dark" ? "dark" : undefined}
/>
</div>
Expand Down Expand Up @@ -210,6 +212,7 @@ export const DataExplorerComponent = ({
key={idx}
actions={false}
spec={plot.spec}
tooltip={tooltipHandler.call}
theme={theme === "dark" ? "dark" : undefined}
/>
</HorizontalCarouselItem>
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/plugins/impl/vega/vega-component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -234,6 +234,7 @@ const LoadedVegaComponent = ({
signalListeners={signalListeners}
onError={handleError}
onNewView={handleNewView}
tooltip={tooltipHandler.call}
/>
{renderHelpContent()}
</div>
Expand Down
206 changes: 206 additions & 0 deletions marimo/_smoke_tests/altair_examples/tooltip_images.py
Original file line number Diff line number Diff line change
@@ -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
'<script>alert("XSS")</script>',
# Event handler
"<img src=x onerror=\"alert('XSS')\">",
# JavaScript URL
"<a href=\"javascript:alert('XSS')\">click</a>",
# IMG with onerror
'<img src="invalid" onerror="alert(\'XSS\')">',
# SVG with embedded script
'<svg><script>alert("XSS")</script></svg>',
# HTML injection
"<div onclick=\"alert('XSS')\">Click me</div>",
# OnMouseOver
"<span onmouseover=\"alert('XSS')\">hover</span>",
# Data URL with JavaScript
"<img src=\"data:text/html,<script>alert('XSS')</script>\">",
],
}
)

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()
15 changes: 15 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading