You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ri...@apache.org on 2026/06/15 16:49:33 UTC
(superset) branch master updated: fix(embedded): skip CSRF token fetch for guest streaming chart exports (#41004)
This is an automated email from the ASF dual-hosted git repository.
richardfogaca pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new a1bc3c67eda fix(embedded): skip CSRF token fetch for guest streaming chart exports (#41004)
a1bc3c67eda is described below
commit a1bc3c67edae3f2e21a5cc668941806dd599867d
Author: Richard Fogaca Nienkotter <63...@users.noreply.github.com>
AuthorDate: Mon Jun 15 13:49:25 2026 -0300
fix(embedded): skip CSRF token fetch for guest streaming chart exports (#41004)
---
.../src/SqlLab/components/ResultSet/index.tsx | 1 +
.../useStreamingExport.test.ts | 171 ++++++++++++++++++++-
.../StreamingExportModal/useStreamingExport.ts | 28 +++-
.../src/explore/exploreUtils/index.ts | 2 +
4 files changed, 193 insertions(+), 9 deletions(-)
diff --git a/superset-frontend/src/SqlLab/components/ResultSet/index.tsx b/superset-frontend/src/SqlLab/components/ResultSet/index.tsx
index 310b8af58e7..a227566f60f 100644
--- a/superset-frontend/src/SqlLab/components/ResultSet/index.tsx
+++ b/superset-frontend/src/SqlLab/components/ResultSet/index.tsx
@@ -425,6 +425,7 @@ const ResultSet = ({
url: makeUrl('/api/v1/sqllab/export_streaming/'),
payload: { client_id: query.id },
exportType: 'csv',
+ exportSource: 'sqllab',
expectedRows: rows,
});
} else {
diff --git a/superset-frontend/src/components/StreamingExportModal/useStreamingExport.test.ts b/superset-frontend/src/components/StreamingExportModal/useStreamingExport.test.ts
index 06883c6b8f3..094d41ad10e 100644
--- a/superset-frontend/src/components/StreamingExportModal/useStreamingExport.test.ts
+++ b/superset-frontend/src/components/StreamingExportModal/useStreamingExport.test.ts
@@ -48,11 +48,11 @@ global.URL.revokeObjectURL = jest.fn();
global.fetch = jest.fn();
-const { SupersetClient } = jest.requireMock('@superset-ui/core');
-
beforeEach(() => {
jest.clearAllMocks();
global.fetch = jest.fn();
+ const { SupersetClient } = jest.requireMock('@superset-ui/core');
+ SupersetClient.getCSRFToken.mockResolvedValue('mock-csrf-token');
SupersetClient.getGuestToken.mockReturnValue(undefined);
});
@@ -228,6 +228,7 @@ test('sets ERROR status and calls onError when fetch rejects', async () => {
// URL prefix guard tests - prevent regression of missing app root prefix
const { applicationRoot } = jest.requireMock('src/utils/getBootstrapData');
const { makeUrl } = jest.requireMock('src/utils/pathUtils');
+const { SupersetClient } = jest.requireMock('@superset-ui/core');
const createPrefixTestMockFetch = () =>
jest.fn().mockResolvedValue({
@@ -242,6 +243,107 @@ const createPrefixTestMockFetch = () =>
},
});
+test('guest-token chart exports skip CSRF fetch and include guest_token form field', async () => {
+ applicationRoot.mockReturnValue('');
+ SupersetClient.getGuestToken.mockReturnValue('guest-token');
+ SupersetClient.getCSRFToken.mockRejectedValue(new Error('CSRF forbidden'));
+
+ const csvData = new TextEncoder().encode('id,name\n1,Alice\n');
+ let readCount = 0;
+ const mockFetch = jest.fn().mockResolvedValue({
+ ok: true,
+ headers: new Headers({
+ 'Content-Disposition': 'attachment; filename="embedded.csv"',
+ }),
+ body: {
+ getReader: () => ({
+ read: jest.fn().mockImplementation(() => {
+ readCount += 1;
+ if (readCount === 1) {
+ return Promise.resolve({ done: false, value: csvData });
+ }
+ return Promise.resolve({ done: true, value: undefined });
+ }),
+ }),
+ },
+ });
+ global.fetch = mockFetch;
+
+ const { result } = renderHook(() => useStreamingExport());
+
+ act(() => {
+ result.current.startExport({
+ url: '/api/v1/chart/data',
+ payload: { datasource: '1__table', viz_type: 'table' },
+ exportType: 'csv',
+ exportSource: 'chart',
+ expectedRows: 100000,
+ });
+ });
+
+ await waitFor(() => {
+ expect(result.current.progress.status).toBe(ExportStatus.COMPLETED);
+ });
+
+ expect(SupersetClient.getCSRFToken).not.toHaveBeenCalled();
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+ const [, requestInit] = mockFetch.mock.calls[0];
+ const body = requestInit.body as URLSearchParams;
+
+ expect(body.get('guest_token')).toBe('guest-token');
+ expect(body.get('expected_rows')).toBe('100000');
+ expect(body.get('form_data')).toBe(
+ JSON.stringify({ datasource: '1__table', viz_type: 'table' }),
+ );
+});
+
+test('non-guest chart exports fetch CSRF and include X-CSRFToken header', async () => {
+ applicationRoot.mockReturnValue('');
+
+ const csvData = new TextEncoder().encode('id,name\n1,Alice\n');
+ let readCount = 0;
+ const mockFetch = jest.fn().mockResolvedValue({
+ ok: true,
+ headers: new Headers({
+ 'Content-Disposition': 'attachment; filename="chart.csv"',
+ }),
+ body: {
+ getReader: () => ({
+ read: jest.fn().mockImplementation(() => {
+ readCount += 1;
+ if (readCount === 1) {
+ return Promise.resolve({ done: false, value: csvData });
+ }
+ return Promise.resolve({ done: true, value: undefined });
+ }),
+ }),
+ },
+ });
+ global.fetch = mockFetch;
+
+ const { result } = renderHook(() => useStreamingExport());
+
+ act(() => {
+ result.current.startExport({
+ url: '/api/v1/chart/data',
+ payload: { datasource: '1__table', viz_type: 'table' },
+ exportType: 'csv',
+ exportSource: 'chart',
+ });
+ });
+
+ await waitFor(() => {
+ expect(result.current.progress.status).toBe(ExportStatus.COMPLETED);
+ });
+
+ expect(SupersetClient.getCSRFToken).toHaveBeenCalledTimes(1);
+ const [, requestInit] = mockFetch.mock.calls[0];
+ expect(requestInit.headers).toMatchObject({
+ 'X-CSRFToken': 'mock-csrf-token',
+ });
+ expect((requestInit.body as URLSearchParams).has('guest_token')).toBe(false);
+});
+
test('chart streaming export includes guest token in form body when configured', async () => {
SupersetClient.getGuestToken.mockReturnValue('guest-token');
const mockFetch = createPrefixTestMockFetch();
@@ -254,6 +356,7 @@ test('chart streaming export includes guest token in form body when configured',
url: '/api/v1/chart/data',
payload: { datasource: '1__table', viz_type: 'table' },
exportType: 'csv',
+ exportSource: 'chart',
});
});
@@ -268,6 +371,70 @@ test('chart streaming export includes guest token in form body when configured',
);
});
+test('SQL Lab exports fetch CSRF and omit guest_token even when guest token exists', async () => {
+ applicationRoot.mockReturnValue('');
+ SupersetClient.getGuestToken.mockReturnValue('guest-token');
+
+ const mockFetch = createPrefixTestMockFetch();
+ global.fetch = mockFetch;
+
+ const { result } = renderHook(() => useStreamingExport());
+
+ act(() => {
+ result.current.startExport({
+ url: '/api/v1/sqllab/export_streaming/',
+ payload: { client_id: 'test-id' },
+ exportType: 'csv',
+ exportSource: 'sqllab',
+ });
+ });
+
+ await waitFor(() => {
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+ });
+
+ expect(SupersetClient.getCSRFToken).toHaveBeenCalledTimes(1);
+ const [, requestInit] = mockFetch.mock.calls[0];
+ const body = requestInit.body as URLSearchParams;
+
+ expect(requestInit.headers).toMatchObject({
+ 'X-CSRFToken': 'mock-csrf-token',
+ });
+ expect(body.get('client_id')).toBe('test-id');
+ expect(body.has('guest_token')).toBe(false);
+});
+
+test('guest tokens do not bypass CSRF for unclassified non-client exports', async () => {
+ applicationRoot.mockReturnValue('');
+ SupersetClient.getGuestToken.mockReturnValue('guest-token');
+
+ const mockFetch = createPrefixTestMockFetch();
+ global.fetch = mockFetch;
+
+ const { result } = renderHook(() => useStreamingExport());
+
+ act(() => {
+ result.current.startExport({
+ url: '/api/v1/other/export_streaming/',
+ payload: { export_id: 'test-id' },
+ exportType: 'csv',
+ });
+ });
+
+ await waitFor(() => {
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+ });
+
+ expect(SupersetClient.getCSRFToken).toHaveBeenCalledTimes(1);
+ const [, requestInit] = mockFetch.mock.calls[0];
+ const body = requestInit.body as URLSearchParams;
+
+ expect(requestInit.headers).toMatchObject({
+ 'X-CSRFToken': 'mock-csrf-token',
+ });
+ expect(body.has('guest_token')).toBe(false);
+});
+
test('URL prefix guard applies prefix to unprefixed relative URL when app root is configured', async () => {
const appRoot = '/superset';
applicationRoot.mockReturnValue(appRoot);
diff --git a/superset-frontend/src/components/StreamingExportModal/useStreamingExport.ts b/superset-frontend/src/components/StreamingExportModal/useStreamingExport.ts
index 75a17caa152..4a8aa0fe0c3 100644
--- a/superset-frontend/src/components/StreamingExportModal/useStreamingExport.ts
+++ b/superset-frontend/src/components/StreamingExportModal/useStreamingExport.ts
@@ -31,6 +31,8 @@ interface StreamingExportPayload {
[key: string]: any;
}
+type StreamingExportSource = 'chart' | 'sqllab';
+
interface StreamingExportParams {
/**
* The API endpoint URL for the export request.
@@ -46,6 +48,7 @@ interface StreamingExportParams {
payload: StreamingExportPayload;
filename?: string;
exportType: 'csv' | 'xlsx';
+ exportSource?: StreamingExportSource;
expectedRows?: number;
}
@@ -95,6 +98,7 @@ const createFetchRequest = async (
payload: StreamingExportPayload,
filename: string | undefined,
_exportType: string,
+ exportSource: StreamingExportSource | undefined,
expectedRows: number | undefined,
signal: AbortSignal,
): Promise<RequestInit> => {
@@ -102,10 +106,19 @@ const createFetchRequest = async (
'Content-Type': 'application/x-www-form-urlencoded',
};
- // Get CSRF token using SupersetClient
- const csrfToken = await SupersetClient.getCSRFToken();
- if (csrfToken) {
- headers['X-CSRFToken'] = csrfToken;
+ const guestToken = SupersetClient.getGuestToken();
+ const isGuestTokenChartExport =
+ Boolean(guestToken) &&
+ exportSource === 'chart' &&
+ !('client_id' in payload);
+
+ // Embedded guest sessions cannot fetch CSRF tokens. Guest chart exports are
+ // safe because chart data is CSRF-exempt and auth is carried by guest_token.
+ if (!isGuestTokenChartExport) {
+ const csrfToken = await SupersetClient.getCSRFToken();
+ if (csrfToken) {
+ headers['X-CSRFToken'] = csrfToken;
+ }
}
const formParams: Record<string, string> = {};
@@ -118,8 +131,7 @@ const createFetchRequest = async (
formParams.expected_rows = expectedRows.toString();
}
- const guestToken = SupersetClient.getGuestToken();
- if (guestToken) {
+ if (guestToken && isGuestTokenChartExport) {
formParams.guest_token = guestToken;
}
@@ -185,7 +197,8 @@ export const useStreamingExport = (options: UseStreamingExportOptions = {}) => {
const executeExport = useCallback(
async (params: StreamingExportParams) => {
- const { url, payload, filename, exportType, expectedRows } = params;
+ const { url, payload, filename, exportType, exportSource, expectedRows } =
+ params;
if (isExportingRef.current) {
return;
}
@@ -210,6 +223,7 @@ export const useStreamingExport = (options: UseStreamingExportOptions = {}) => {
payload,
filename,
exportType,
+ exportSource,
expectedRows,
abortControllerRef.current.signal,
);
diff --git a/superset-frontend/src/explore/exploreUtils/index.ts b/superset-frontend/src/explore/exploreUtils/index.ts
index 23771a9ba97..4c090a9d379 100644
--- a/superset-frontend/src/explore/exploreUtils/index.ts
+++ b/superset-frontend/src/explore/exploreUtils/index.ts
@@ -99,6 +99,7 @@ interface ExportChartParams {
url: string | null;
payload: QueryFormData | ReturnType<typeof buildQueryContext>;
exportType: string;
+ exportSource: 'chart';
}) => void)
| null;
}
@@ -394,6 +395,7 @@ export const exportChart = async ({
url: url ? ensureAppRoot(url) : url,
payload,
exportType: resultFormat,
+ exportSource: 'chart',
});
} else {
// SupersetClient.postForm calls getUrl({ endpoint }) internally, which prepends