Compare commits

...

3 Commits

Author SHA1 Message Date
Elizabeth Thompson
57b93055b6 fix(dashboard): let CSV exports use query cache instead of always force-querying
Two bugs caused dashboard CSV exports to always re-execute the query instead
of using the cache (while the Explore page correctly used the cache):

1. Chart.tsx passed `force: true` to `exportChart()`, unconditionally
   bypassing the cache on every dashboard export.

2. buildQuery.ts coerced a missing `row_limit` to `0` via `|| 0` for
   download queries, while display queries left it as `undefined`. This
   produced a different cache key so exports would miss the cache even
   without the `force` flag.

Both fixes are needed together: removing `force: true` lets the backend
consult the cache, and preserving `undefined` for a missing row_limit
ensures the export query produces the same cache key as the display query
that populated the cache.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 00:20:12 +00:00
madhushreeag
dc64716c61 fix(echarts): bring annotations in front and prevent tooltip from covering annotation labels (#41174)
Co-authored-by: madhushree agarwal <madhushree_agarwal@apple.com>
2026-06-26 16:21:41 -07:00
Evan Rusackas
6f12dbf0e1 feat(api): log rejected related/distinct field access as security events (#41306)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-26 15:45:36 -07:00
7 changed files with 87 additions and 14 deletions

View File

@@ -472,6 +472,7 @@ export function transformFormulaAnnotation(
return {
name,
id: name,
z: 10,
itemStyle: {
color: color || colorScale(name, sliceId),
},
@@ -565,6 +566,7 @@ export function transformIntervalAnnotation(
id: `Interval - ${name}`,
type: 'line',
animation: false,
z: 10,
markArea: {
silent: false,
itemStyle: {
@@ -660,6 +662,7 @@ export function transformEventAnnotation(
id: `Event - ${name}`,
type: 'line',
animation: false,
z: 10,
markLine: {
silent: false,
symbol: 'none',
@@ -705,6 +708,7 @@ export function transformTimeseriesAnnotation(
type: 'line',
id: name,
name,
z: 10,
data,
symbolSize: showMarkers ? markerSize : 0,
itemStyle: computedStyle,

View File

@@ -122,4 +122,8 @@ export const TOOLTIP_POINTER_MARGIN = 10;
// from the edge of the window should the tooltip be kept
export const TOOLTIP_OVERFLOW_MARGIN = 5;
// Minimum distance from the top of the chart container to keep the tooltip,
// reserving space for annotation labels rendered at insideEndTop of markLines/markAreas
export const TOOLTIP_TOP_CLEARANCE = 40;
export const DEFAULT_LOCALE = 'en';

View File

@@ -24,7 +24,11 @@ import {
getColumnLabel,
getMetricLabel,
} from '@superset-ui/core';
import { TOOLTIP_OVERFLOW_MARGIN, TOOLTIP_POINTER_MARGIN } from '../constants';
import {
TOOLTIP_OVERFLOW_MARGIN,
TOOLTIP_POINTER_MARGIN,
TOOLTIP_TOP_CLEARANCE,
} from '../constants';
import { Refs } from '../types';
export function getDefaultTooltip(refs: Refs) {
@@ -107,18 +111,44 @@ export function getDefaultTooltip(refs: Refs) {
}
}
// Position tooltip above cursor, or below if no space
yPos = mouseY - TOOLTIP_POINTER_MARGIN - effectiveTooltipHeight;
// Mirror horizontal logic: position tooltip below cursor when in top half of chart,
// above cursor when in bottom half. This prevents the tooltip from covering annotation
// labels that appear at the top of the chart (markArea/markLine labels).
const chartHeight = divRect?.height || viewportHeight;
const cursorYInChart = canvasMousePos[1];
const isInTopHalfOfChart = cursorYInChart < chartHeight / 2;
// The tooltip is overflowing past the top edge of the window
if (yPos <= 0) {
// Attempt to place the tooltip to the bottom of the mouse position
if (isInTopHalfOfChart) {
yPos = mouseY + TOOLTIP_POINTER_MARGIN;
// The tooltip is overflowing past the bottom edge of the window
if (yPos + effectiveTooltipHeight >= viewportHeight)
// Place the tooltip a fixed distance from the top edge of the window
yPos = TOOLTIP_OVERFLOW_MARGIN;
if (yPos + effectiveTooltipHeight >= viewportHeight) {
yPos = mouseY - TOOLTIP_POINTER_MARGIN - effectiveTooltipHeight;
if (yPos <= 0) {
yPos = TOOLTIP_OVERFLOW_MARGIN;
}
}
} else {
yPos = mouseY - TOOLTIP_POINTER_MARGIN - effectiveTooltipHeight;
if (yPos <= 0) {
yPos = mouseY + TOOLTIP_POINTER_MARGIN;
if (yPos + effectiveTooltipHeight >= viewportHeight) {
yPos = TOOLTIP_OVERFLOW_MARGIN;
}
}
}
// Clamp tooltip away from the top of the chart to avoid covering annotation labels
// (markLine/markArea labels rendered at insideEndTop are within the first ~40px)
if (divRect) {
yPos = Math.max(yPos, divRect.y + TOOLTIP_TOP_CLEARANCE);
// Re-apply bottom overflow check in case top clearance pushed the tooltip off-screen
if (yPos + effectiveTooltipHeight >= viewportHeight) {
yPos =
viewportHeight - effectiveTooltipHeight - TOOLTIP_OVERFLOW_MARGIN;
}
}
// Return the position (converted back to a relative position on the canvas)

View File

@@ -227,7 +227,10 @@ export const buildQuery: BuildQuery<TableChartFormData> = (
formData?.result_type === 'results');
if (isDownloadQuery) {
moreProps.row_limit = Number(formDataCopy.row_limit) || 0;
moreProps.row_limit =
formDataCopy.row_limit != null
? Number(formDataCopy.row_limit)
: undefined;
moreProps.row_offset = 0;
}

View File

@@ -562,7 +562,6 @@ const Chart = (props: ChartProps) => {
exportFormData as unknown as import('@superset-ui/core').QueryFormData,
resultType,
resultFormat: format,
force: true,
ownState: exportOwnState,
onStartStreamingExport: shouldUseStreaming
? (exportParams: JsonObject) => {

View File

@@ -223,6 +223,29 @@ class BaseSupersetApiMixin:
f"{self.__class__.__name__}.{func_name}.{action}"
)
def log_rejected_field_access(self, func_name: str, column_name: str) -> None:
"""Emit a security log event when a related/distinct field is rejected.
The allowlist check itself blocks the request; this records the attempt
in the structured log (alongside the existing statsd counter) so that
rejected field access is visible to security monitoring and forensics,
with the caller's identity, the endpoint, and the attempted value.
"""
# Sanitize the user-supplied column name to a single, bounded token so
# it cannot inject newlines or forge extra key=value tokens in the log
# line. Restrict to a safe character set (column names are alphanumeric
# plus ``_-.``) and replace anything else with ``?``.
sanitized_column = "".join(
ch if (ch.isalnum() or ch in "_-.") else "?" for ch in str(column_name)
)[:200]
logger.warning(
"Rejected disallowed field access: user_id=%s endpoint=%s.%s column=%s",
get_user_id(),
self.__class__.__name__,
func_name,
sanitized_column,
)
def timing_stats(self, action: str, func_name: str, value: float) -> None:
"""
Proxy function for statsd.incr to impose a key structure for REST API's
@@ -619,6 +642,7 @@ class BaseSupersetModelRestApi(BaseSupersetApiMixin, ModelRestApi):
return response
if column_name not in self.allowed_rel_fields:
self.incr_stats("error", self.related.__name__)
self.log_rejected_field_access(self.related.__name__, column_name)
return self.response_404()
args = kwargs.get("rison", {})
@@ -697,7 +721,8 @@ class BaseSupersetModelRestApi(BaseSupersetApiMixin, ModelRestApi):
$ref: '#/components/responses/500'
"""
if column_name not in self.allowed_distinct_fields:
self.incr_stats("error", self.related.__name__)
self.incr_stats("error", self.distinct.__name__)
self.log_rejected_field_access(self.distinct.__name__, column_name)
return self.response_404()
args = kwargs.get("rison", {})
# handle pagination

View File

@@ -423,5 +423,13 @@ class ApiOwnersTestCaseMixin:
self.login(ADMIN_USERNAME)
uri = f"api/v1/{self.resource_name}/related/owner"
rv = self.client.get(uri)
with patch("superset.views.base_api.logger") as mock_logger:
rv = self.client.get(uri)
assert rv.status_code == 404
# A disallowed related field is recorded as a security log event,
# including the rejected column name, in addition to the 404.
mock_logger.warning.assert_called_once()
assert "column=owner" in (
mock_logger.warning.call_args.args[0]
% mock_logger.warning.call_args.args[1:]
)