Skip to content

fix(dashboard): decouple Excel export link lifetime from S3 credential expiry, and support guest/embedded sessions - #43336

Open
eschutho wants to merge 1 commit into
masterfrom
fix/excel-export-guest-sessions-and-s3-link-expiry
Open

fix(dashboard): decouple Excel export link lifetime from S3 credential expiry, and support guest/embedded sessions#43336
eschutho wants to merge 1 commit into
masterfrom
fix/excel-export-guest-sessions-and-s3-link-expiry

Conversation

@eschutho

Copy link
Copy Markdown
Member

SUMMARY

The dashboard Excel export success email (#41133) links to a pre-signed S3 URL that is only valid for as long as both its own ExpiresIn window and the credentials that signed it remain valid. Deployments whose S3 client authenticates via short-lived, auto-refreshed credentials (e.g. an EKS IRSA role assumed through AssumeRoleWithWebIdentity, which AWS caps at 12 hours) can silently invalidate the pre-signed URL long before EXCEL_EXPORT_LINK_TTL_SECONDS elapses, since the credentials' session — not just the URL's own ExpiresIn — bounds how long it actually works.

To keep that promise regardless of credential lifetime, the email now links to a small Superset redirect endpoint (GET .../export_xlsx/download/<job_id>/) instead of a raw S3 URL. The link's own lifetime is enforced independently via the key_value store's expires_on, and the actual pre-signed URL is generated fresh — with then-current credentials — at click time, valid only long enough to complete a single download. The redirect intentionally requires no login, matching a raw pre-signed URL's own access model: the unguessable job id is the credential, and the dashboard access check already ran once when the export was requested.

Reusing this same job_id-keyed store also removes a separate limitation: export_xlsx previously hard-required the requester to have an email address on file, since email was the only delivery channel — which excluded guest/embedded dashboard sessions (GuestUser) entirely. A new GET .../export_xlsx/status/<job_id>/ polling endpoint lets the frontend discover completion without an email: the DownloadMenuItems component now polls after enqueueing and auto-downloads once ready, so an embedded/guest session (no email) gets a working export too. A regular logged-in session gets both the browser auto-download and, still, the email — the polling arrives before the email does in practice.

export_xlsx needs a CSRF exemption for this to work end-to-end: it's a POST route now reachable from embedded/guest sessions whose fetch calls carry no CSRF token, the same reason chart/data is already in WTF_CSRF_EXEMPT_LIST.

BEFORE/AFTER

Before: the export email links directly to a pre-signed S3 URL that can silently stop working hours before its promised expiry on IRSA-style deployments, and POST export_xlsx 400s for any session without an email address (all embedded/guest dashboards).

After: the export email (and, for guest sessions, browser polling) links to a Superset redirect that re-signs the S3 URL at click time; guest/embedded sessions can request and receive an export.

TESTING INSTRUCTIONS

  • pytest tests/integration_tests/dashboards/api_tests.py -k "download_xlsx or export_xlsx" — 17 new/updated tests
  • pytest tests/unit_tests/tasks/test_export_dashboard_excel.py — 47 tests
  • pytest tests/integration_tests/security_tests.py -k test_views_are_secured
  • npx jest src/dashboard/components/menu/DownloadMenuItems/DownloadMenuItems.test.tsx — 18 tests (3 new, covering ready/pending/error polling states)
  • ruff check / ruff format --check / oxlint / prettier --check all clean on touched files
  • mypy clean on touched files (pre-existing, unrelated repo-wide errors aside)

ADDITIONAL INFORMATION

  • Has associated issue
  • Required feature flags
  • Changes UI
  • Includes DB migration - N/A, reuses the existing key_value table via a new KeyValueResource.EXCEL_EXPORT_DOWNLOAD enum member, no schema change
  • Confirm DB migration upgrade and downgrade tested
  • Introduces new feature or API
  • Removes existing feature or API

…l expiry, and support guest/embedded sessions

The Excel export success email links to a pre-signed S3 URL that is only
valid for as long as both its own ExpiresIn and the credentials that signed
it remain valid. Deployments authenticating via short-lived, auto-refreshed
credentials (e.g. an EKS IRSA role assumed through
AssumeRoleWithWebIdentity, which AWS caps at 12 hours) can silently
invalidate the link long before EXCEL_EXPORT_LINK_TTL_SECONDS elapses.

The email now links to a Superset redirect (export_xlsx/download/<job_id>/)
instead of a raw S3 URL. The link's own lifetime is enforced independently
via the key_value store, and a fresh pre-signed URL is generated -- with
then-current credentials -- at click time.

Reusing the same job_id-keyed store also removes a separate limitation:
export_xlsx previously hard-required the requester to have an email address
on file, since email was the only way to deliver the link, which excluded
guest/embedded dashboard sessions entirely. The frontend can now poll a new
export_xlsx_status/<job_id>/ endpoint and auto-download once ready, so an
embedded session (no email) gets a working export too; a logged-in session
gets both the auto-download and, still, the email.

export_xlsx itself needs a CSRF exemption for this to work end-to-end: it's
a POST route reachable from embedded/guest sessions whose requests carry no
CSRF token, the same reason chart/data is already exempted.
@dosubot dosubot Bot added change:backend Requires changing the backend change:frontend Requires changing the frontend dashboard:export Related to exporting dashboards labels Aug 19, 2026
@bito-code-review

bito-code-review Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Code Review Agent Run #94aa37

Actionable Suggestions - 0
Additional Suggestions - 3
  • superset/dashboards/api.py - 2
    • Missing event logging on new endpoint · Line 1850-1895
      `export_xlsx_status` is missing the `@event_logger.log_this_with_context` decorator that `export_xlsx` has. Without it, audit events for this endpoint will not be recorded, breaking the audit trail for guest/embedded export polling. Add it above `@safe` with action={`${self.__class__.__name__}.export_xlsx_status`}.
    • Missing event logging on download endpoint · Line 1897-1935
      `download_xlsx` is missing the `@event_logger.log_this_with_context` decorator that `export_xlsx` has. Despite intentionally omitting `@protect()` (matching the raw pre-signed S3 access model documented in the method's docstring), audit logging should still fire via @event_logger. Without it, downloads of completed exports are not recorded in the audit trail.
  • superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadMenuItems.test.tsx - 1
    • Missing clearAllTimers in afterEach · Line 109-113
      The `afterEach` block calls `jest.useRealTimers()` but does not call `jest.clearAllTimers()`. While `useRealTimers()` restores the timer functions, any scheduled callbacks remain in the queue and could fire unexpectedly in subsequent tests that don't use fake timers. The recommended pattern is `jest.useRealTimers()` followed by `jest.clearAllTimers()`.
Filtered by Review Rules

Bito filtered these suggestions based on rules created automatically for your feedback. Manage rules.

  • superset-frontend/src/dashboard/components/menu/DownloadMenuItems/index.tsx - 3
Review Details
  • Files reviewed - 10 · Commit Range: 26fabcf..26fabcf
    • superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadMenuItems.test.tsx
    • superset-frontend/src/dashboard/components/menu/DownloadMenuItems/index.tsx
    • superset/config.py
    • superset/dashboards/api.py
    • superset/dashboards/excel_export/download_link.py
    • superset/key_value/types.py
    • superset/tasks/export_dashboard_excel.py
    • tests/integration_tests/dashboards/api_tests.py
    • tests/integration_tests/security_tests.py
    • tests/unit_tests/tasks/test_export_dashboard_excel.py
  • Files skipped - 0
  • Tools
    • MyPy (Static Code Analysis) - ✔︎ Successful
    • Astral Ruff (Static Code Analysis) - ✔︎ Successful
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers a full AI review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

AI Code Review powered by Bito Logo

@github-actions github-actions Bot added the api Related to the REST API label Aug 19, 2026
@netlify

netlify Bot commented Aug 19, 2026

Copy link
Copy Markdown

Deploy Preview for superset-docs-preview ready!

Name Link
🔨 Latest commit 26fabcf
🔍 Latest deploy log https://app.netlify.com/projects/superset-docs-preview/deploys/6a85cc908a8fd000083cb0ec
😎 Deploy Preview https://deploy-preview-43336--superset-docs-preview.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

Comment thread superset/config.py
WTF_CSRF_EXEMPT_LIST = [
"superset.charts.data.api.data",
"superset.dashboards.api.cache_dashboard_screenshot",
"superset.dashboards.api.export_xlsx",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Exempting the authenticated POST export endpoint from CSRF protection allows a cross-site request to enqueue an export using a victim's Superset session whenever cookies are sent cross-site, such as deployments configured with SESSION_COOKIE_SAMESITE="None". Keep CSRF validation for cookie-authenticated requests and use a narrowly scoped guest/embedded authentication mechanism instead of globally exempting this endpoint. [security]

Severity Level: Major ⚠️
- ❌ Cross-site origins can trigger authenticated dashboard exports.
- ⚠️ Victim email and worker resources may be consumed.
- ⚠️ CSRF protection is bypassed for a state-changing endpoint.

Use CodeAnt Skill

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/config.py
**Line:** 371:371
**Comment:**
	*Security: Exempting the authenticated `POST` export endpoint from CSRF protection allows a cross-site request to enqueue an export using a victim's Superset session whenever cookies are sent cross-site, such as deployments configured with `SESSION_COOKIE_SAMESITE="None"`. Keep CSRF validation for cookie-authenticated requests and use a narrowly scoped guest/embedded authentication mechanism instead of globally exempting this endpoint.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Comment on lines +212 to +215
setTimeout(
() => pollExportStatus(jobId, startedAt),
EXPORT_STATUS_POLL_INTERVAL_MS,
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The polling timers are not retained or cancelled when the hook unmounts or the user navigates away. A pending export can therefore continue issuing status requests for up to five minutes, and a late successful response can display a toast or navigate the browser to the download URL after the originating dashboard has been destroyed. Track the timer and cancel it during cleanup, and ignore responses after unmount. [missing cleanup]

Severity Level: Major ⚠️
- ⚠️ Dashboard navigation leaves polling requests running.
- ⚠️ Late completion can navigate the current page unexpectedly.
- ⚠️ Stale callbacks can display export toasts after teardown.

Use CodeAnt Skill

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset-frontend/src/dashboard/components/menu/DownloadMenuItems/index.tsx
**Line:** 212:215
**Comment:**
	*Missing Cleanup: The polling timers are not retained or cancelled when the hook unmounts or the user navigates away. A pending export can therefore continue issuing status requests for up to five minutes, and a late successful response can display a toast or navigate the browser to the download URL after the originating dashboard has been destroyed. Track the timer and cancel it during cleanup, and ignore responses after unmount.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Comment on lines 242 to 246
addSuccessToast(
t(
"Your export is being prepared. You'll receive an email when it's ready.",
),
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The pending message explicitly promises that the user will receive an email, but the newly supported guest and embedded sessions have no email address and rely exclusively on polling and automatic download. This gives those users an incorrect delivery expectation; use wording that reflects the automatic download or select the message based on the session's notification capability. [logic error]

Severity Level: Minor 🧹
- ⚠️ Guest users receive an impossible email notification promise.
- ⚠️ Embedded users may wait for an email that never arrives.
- ✅ Polling still enables automatic download completion.

Use CodeAnt Skill

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset-frontend/src/dashboard/components/menu/DownloadMenuItems/index.tsx
**Line:** 242:246
**Comment:**
	*Logic Error: The pending message explicitly promises that the user will receive an email, but the newly supported guest and embedded sessions have no email address and rely exclusively on polling and automatic download. This gives those users an incorrect delivery expectation; use wording that reflects the automatic download or select the message based on the session's notification capability.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

@bito-code-review

Copy link
Copy Markdown
Contributor

The security concern regarding CSRF protection for the export endpoint is valid. Exempting state-changing endpoints from CSRF protection can indeed expose users to cross-site request forgery attacks, especially in configurations where cookies are sent cross-site. To resolve this, you should remove the global exemption for the endpoint and implement a more secure authentication mechanism, such as a guest token or a signed request parameter, for embedded or guest scenarios. Since the file superset/config.py is not present in the provided PR diff, I cannot implement the fix directly. Please locate the CSRF exemption configuration in superset/config.py and replace the global exemption with a more granular approach.

@gabotorresruiz

gabotorresruiz commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Heads up: removing the guest guard here exposes an AttributeError on g.user.id in export_xlsx (GuestUser has no id attribute), so embedded guests get a 500 Fatal error instead of an export.

I opened #43340 against this branch with the fix (guest token passed to the task, guest user reconstructed via get_guest_user_from_token so RLS claims hold in the worker, shared lock slot 0 for guests) plus a unit test, so it can be merged into this PR before this lands on master.

@@ -1787,12 +1803,9 @@ def export_xlsx(self, pk: int) -> WerkzeugResponse:
except SupersetSecurityException:
return self.response_403()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Embedded guest requests now reach this path, but GuestUser has no persisted numeric id; building the lock key (and later enqueueing the task) raises before a job can be returned. Could this keep guest context separate from the user-id task contract, or continue rejecting guests until the worker can safely reconstruct that context?

# upsert (not create) so a retried/duplicate write for the same job_id
# overwrites cleanly instead of colliding on the primary key.
KeyValueDAO.delete_expired_entries(RESOURCE)
KeyValueDAO.upsert_entry(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ready/error status is only staged in the SQLAlchemy session here. With a Redis distributed lock, releasing the lock does not commit that session, so Celery teardown rolls the entry back: polling remains pending and the emailed redirect returns 410. Could this write be committed transactionally before reporting the export ready?

def build_download_url(job_id: UUID) -> str:
"""The browser-facing URL that redirects to a freshly pre-signed S3 URL
for ``job_id``, once its export is ready."""
return headless_url(DOWNLOAD_PATH.format(job_id=job_id), user_friendly=True)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

headless_url joins this absolute path to the host and bypasses APPLICATION_ROOT. Deployments mounted below a prefix therefore email a root-level download URL that does not route to Superset. Could this use the prefix-aware URL helper (or otherwise include the application root)?

# KeyValueEntry.expires_on comparisons use naive datetime.now(), so
# the stored expiry must be naive UTC too, not tz-aware.
download_url = create_download_link(
uuid.UUID(job_id), bucket, key, expires_at.replace(tzinfo=None)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This stores a naive UTC timestamp, while KeyValueEntry.is_expired() compares it with naive local datetime.now(). On non-UTC servers the configured link lifetime is shifted by the timezone offset, so links can expire early or stay valid too long. Could the stored value and expiry comparison use the same timezone convention?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

api Related to the REST API change:backend Requires changing the backend change:frontend Requires changing the frontend dashboard:export Related to exporting dashboards size/XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants