Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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()
21 changes: 18 additions & 3 deletions pnpm-lock.yaml

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

Loading