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