Skip to content

fix(screenshots): validate cached screenshot image bytes on read and write#42120

Open
eschutho wants to merge 1 commit into
masterfrom
fix-screenshot-cache-validation
Open

fix(screenshots): validate cached screenshot image bytes on read and write#42120
eschutho wants to merge 1 commit into
masterfrom
fix-screenshot-cache-validation

Conversation

@eschutho

Copy link
Copy Markdown
Member

Decisions made that were not in the instructions

  • Validation depth: chose a cheap PNG/JPEG magic-byte header check (\x89PNG\r\n\x1a\n / \xff\xd8\xff), not a full image decode, per the instruction to keep this cheap. A full decode would add CPU cost to every cache read/write for a class of corruption (truncated/blank-but-headered files) that a header check won't catch — deemed out of scope for closing the specific "blank PDF from stale cache" report.
  • Chart-screenshot endpoint: no changes were needed to superset/charts/api.py. Both the dashboard and chart screenshot()/thumbnail()/cache_* handlers already funnel through the shared BaseScreenshot.get_from_cache_key() classmethod, so fixing validation there closes the read-side hole for both resource types mechanically, with no separate chart-specific patch.
  • Where the read-side check lives: added it to get_from_cache_key() rather than to ScreenshotCachePayload.get_image() or __init__. This scopes the change to the cache-retrieval choke point (what the endpoints actually call to decide "hit vs. miss") without changing the behavior of get_image()/ScreenshotCachePayload construction elsewhere (e.g. the Celery report/alert pipeline, which calls get_screenshot() directly and never touches this cache layer).

SUMMARY

Dashboard/chart screenshot and thumbnail caching (ScreenshotCachePayload in superset/utils/screenshots.py) had two gaps:

  1. No read-side validation. BaseScreenshot.get_from_cache_key() returned whatever was in the cache as long as a status of UPDATED was recorded, even if the stored image was None/0-byte or otherwise not a real image. Since the cache key is digest-based, an unchanged dashboard/chart kept serving the same stale/blank entry indefinitely (e.g. a blank PDF download).
  2. Write-side validation was falsy-only. A prior fix (fix(screenshots): catch empty-bytes tiled result and set ERROR on falsy image #41097) set ERROR status when the screenshot task produced a falsy (None/b"") result, but a non-empty, non-image payload (e.g. truncated/corrupt bytes) still passed the if image: check and got cached with UPDATED status.

This PR adds a shared, cheap validator (validate_screenshot_image() — checks non-empty + PNG/JPEG magic-byte header, no full decode) used on both paths:

  • Read side: get_from_cache_key() now rejects a cached payload that claims a successful screenshot (status == UPDATED) but fails validation, returning None — the same value callers already treat as a cache miss — and logs a WARNING with the cache key and the reason (empty vs undecodable). Because both the dashboard and chart screenshot/thumbnail/cache_* endpoints already call this same shared classmethod, this closes the hole for both resource types without touching charts/api.py or dashboards/api.py.
  • Write side: BaseScreenshot.compute_and_cache() now runs the same check on the freshly generated image before caching it as a success. If it fails, the payload is marked ERROR (consistent with fix(screenshots): catch empty-bytes tiled result and set ERROR on falsy image #41097's approach) instead of UPDATED, and a WARNING with the cache key and reason is logged.

Explicitly not changed:

TESTING INSTRUCTIONS

Added unit tests in tests/unit_tests/utils/test_screenshot_cache_fix.py and updated fixtures in tests/unit_tests/utils/screenshot_test.py (existing tests used non-image placeholder bytes like b"image_data" as stand-ins for a "successful" screenshot; these now use a minimal valid PNG header so they still exercise the success path under the new validation):

  • Read side: a cached payload with a 0-byte image is served as a cache miss (None) with a WARNING logged; a payload with non-image garbage bytes is likewise treated as a cache miss; a valid PNG-header payload is served normally; a non-UPDATED (e.g. PENDING) payload is returned as-is.
  • Write side: compute_and_cache() with an empty or garbage-bytes screenshot result caches ERROR status (never UPDATED) and logs a WARNING including the cache key and reason.

Run:

pytest tests/unit_tests/utils/test_screenshot_cache_fix.py tests/unit_tests/utils/screenshot_test.py -q

48 passed. Also ran the broader tests/unit_tests/utils/ -k screenshot suite (76 passed); 3 unrelated pre-existing failures in webdriver_test.py/test_screenshot_utils.py reproduce identically on master (playwright-version mismatch in the test environment, unrelated to this change and in files this PR does not touch).

ruff check passes on the changed files; mypy reports no errors in superset/utils/screenshots.py.

ADDITIONAL INFORMATION

  • Has associated issue:
  • Required feature flags:
  • Changes UI
  • Includes DB Migration (follow approval process in SIP-59)
    • Migration is atomic, supports rollback & is backwards-compatible
    • Confirm DB migration upgrade and downgrade tested
    • Runtime estimates and downtime expectations provided
  • Introduces new feature or API
  • Removes existing feature or API

🤖 Generated with Claude Code

…write

Reject stale/empty/corrupt cached screenshot payloads instead of serving
them: get_from_cache_key now treats a payload that claims a successful
screenshot but has empty or non-image bytes as a cache miss, and
compute_and_cache applies the same cheap header check before marking a
result as cached-success, falling back to ERROR status otherwise.

Co-Authored-By: Claude <noreply@anthropic.com>
@dosubot dosubot Bot added the infra:caching Infra setup and configuration related to caching label Jul 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

infra:caching Infra setup and configuration related to caching

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant