Skip to content

fix(sqla): guard unprotected Jinja template rendering in ExploreMixin - #44172

Merged
eschutho merged 2 commits into
masterfrom
fix-unprotected-jinja-template-helpers
Sep 17, 2026
Merged

eschutho merged 2 commits into
masterfrom
fix-unprotected-jinja-template-helpers

Conversation

@eschutho

@eschutho eschutho commented Sep 11, 2026 •

Copy link
Copy Markdown
Member

SUMMARY

Two process_template call sites in ExploreMixin — get_timestamp_expression() and convert_tbl_column_to_sqla_col() — lack exception handling for Jinja template errors. When a user writes a Jinja expression (e.g. {{ filter_values('col')[0] }} or {{ undefined_var.attr }}) in a time column or calculated column, and the template raises UndefinedError or TemplateError, the raw jinja2 exception escapes unhandled instead of being surfaced as a QueryObjectValidationError.

This is the same bug class fixed by #42366 and proposed in #42401 for get_rendered_sql(), which already has the correct two-clause exception handler. These two sibling methods in the same class were missed.

Root cause: BaseTemplateProcessor.process_template wraps parse-time TemplateSyntaxError as SupersetSyntaxErrorException, but during the render phase it only catches RecursionError and a narrow pattern-matched UndefinedError; any other UndefinedError and any other TemplateError subtype raised during rendering escape raw. Both methods are called from get_sqla_query() (the main chart-rendering / SQL Lab query path) with no upstream catch.

Fix: Wrap both process_template calls with the same two-clause handler (except UndefinedError → QueryObjectValidationError, then except (TemplateError, SupersetSyntaxErrorException) → QueryObjectValidationError), mirroring the existing pattern in get_rendered_sql(). All needed imports were already present in the file.

This is additive exception handling only — no behavior change for the success path.

BEFORE/AFTER SCREENSHOTS OR ANIMATED GIF

N/A — backend-only change affecting error handling, no UI changes.

TESTING INSTRUCTIONS

  1. Create a dataset with a calculated column expression containing {{ undefined_var.attr }}
  2. Try to query/chart using that column
  3. Before: raw jinja2.exceptions.UndefinedError escapes, producing an opaque 500 error
  4. After: QueryObjectValidationError with message "Calculated column template error: 'undefined_var' is undefined" — a clean, user-facing error

Automated regression tests are included covering both UndefinedError and TemplateSyntaxError for both fixed methods.

ADDITIONAL INFORMATION

  • Has associated issue: sc-120357
  • Required feature flags:
  • Changes UI
  • Includes DB Migration
  • Introduces new feature or API
  • Removes existing feature or API

Wrap `process_template` calls in `get_timestamp_expression` and
`convert_tbl_column_to_sqla_col` with the same two-clause exception
handler that `get_rendered_sql` already uses, so that `UndefinedError`
and `TemplateError` surface as `QueryObjectValidationError` instead
of escaping as raw jinja2 exceptions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@bito-code-review

bito-code-review Bot commented Sep 11, 2026 •

Copy link
Copy Markdown
Contributor

Code Review Agent Run #09dae3

Actionable Suggestions - 0
Review Details
  • Files reviewed - 2 · Commit Range: 7d4f277..7d4f277
    • superset/models/helpers.py
    • tests/unit_tests/connectors/sqla/models_test.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 an incremental AI Review.

  • /review full - 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

@netlify

netlify Bot commented Sep 11, 2026

Copy link
Copy Markdown

✅ Deploy Preview for superset-docs-preview ready!

Name Link
🔨 Latest commit 7d4f277
🔍 Latest deploy log https://app.netlify.com/projects/superset-docs-preview/deploys/6aa42f6cb3116100086285df
😎 Deploy Preview https://deploy-preview-44172--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.

@codecov

codecov Bot commented Sep 11, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 52.50000% with 19 lines in your changes missing coverage. Please review.
✅ Project coverage is 80.27%. Comparing base (774a511) to head (c922659).
⚠️ Report is 106 commits behind head on master.

Files with missing lines Patch % Lines
superset/connectors/sqla/models.py 42.30% 14 Missing and 1 partial ⚠️
superset/models/helpers.py 71.42% 2 Missing and 2 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master   #44172      +/-   ##
==========================================
+ Coverage   80.16%   80.27%   +0.10%     
==========================================
  Files        2925     2928       +3     
  Lines      172563   174081    +1518     
  Branches    40058    40378     +320     
==========================================
+ Hits       138337   139742    +1405     
- Misses      31629    31680      +51     
- Partials     2597     2659      +62     
Flag Coverage Δ
hive 37.14% <5.00%> (-0.20%) ⬇️
mysql 56.56% <12.50%> (-0.30%) ⬇️
postgres 56.59% <12.50%> (-0.31%) ⬇️
presto 39.22% <5.00%> (-0.01%) ⬇️
python 84.68% <52.50%> (+0.13%) ⬆️
sqlite 56.29% <12.50%> (-0.31%) ⬇️
unit 76.28% <50.00%> (+0.41%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@rebenitez1802 rebenitez1802 left a comment

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.

Request changes: the calculated-column half is a correct, faithful fix, but the time-column half guards a method with no production caller — the real temporal path still leaks raw UndefinedError, and two new tests assert a guarantee that doesn't hold in production.

🔴 High — Time-column guard is dead code; the live path still leaks
get_timestamp_expression is patched on ExploreMixin (helpers.py:4386), but that method has no production caller. TableColumn doesn't subclass ExploreMixin, and all four call sites (helpers.py:4089/4676/4779/5016) invoke get_timestamp_expression on TableColumn objects with kwargs (apply_dataset_offset=, sql_shifted_temporal_labels=) that only exist on TableColumn.get_timestamp_expression (models.py:1260). That method still wraps process_template with except SupersetSyntaxErrorException only (models.py:1296), so a Jinja error in a time-grain'd temporal calculated column (e.g. {{ nonexistent_var.attr }}) still escapes as raw UndefinedError → 500 — exactly the bug this PR claims to fix. The two get_timestamp_expression tests pass only because they bind the mixin method manually via __get__, giving false green coverage. Fix: move the two-clause handler into TableColumn.get_timestamp_expression at models.py:1295 (where the live catch is), and either delete the now-unused ExploreMixin.get_timestamp_expression or retarget the tests at the real dispatch path. (The convert_tbl_column_to_sqla_col half is genuinely correct — it's ExploreMixin-only and called as self.….)

🟡 Medium — SupersetTemplateException / undefined-function templates bypass the wrapping
The new handlers (and the reference they copy) catch UndefinedError and (TemplateError, SupersetSyntaxErrorException), but process_template also raises UndefinedTemplateFunctionException for {{ missing_macro() }} and SupersetTemplateException for recursion/internal errors (jinja_context.py:1053/1060/1069) — neither is caught. So on the working calculated-column path, {{ some_undefined_func() }} still skips the intended "Calculated column template error" wrapping. Impact is softened because SupersetTemplateException is a SupersetException (rendered as a structured error, not a raw 500), so this is a coverage/consistency gap rather than a crash. Fix: add SupersetTemplateException to the caught tuple (except (TemplateError, SupersetSyntaxErrorException, SupersetTemplateException)) with a str(ex) fallback for the message, ideally in get_rendered_sql too.

🟢 Low — Sibling render paths in the same query still unguarded (asymmetry)
Reachable from the same get_sqla_query, these still catch only SupersetSyntaxErrorException (or nothing): SqlMetric.get_sqla_col (models.py:1433), TableColumn.get_sqla_col (models.py:1220, the adhoc-column reference path), and the fully-unguarded adhoc-SQL renders _process_metric_sql_expression (helpers.py:1937) and _process_validated_sql_expression (helpers.py:1971). Net asymmetry: a calculated column selected directly in groupby is now guarded, but the same column referenced as an adhoc column, or a saved metric like SUM({{ foo.bar }}), still leaks. Out of this PR's stated scope, but worth a follow-up (or expanding here) since it's the same bug class.

🟢 Low — template_error tests inject an exception process_template never emits
test_*_wraps_jinja_template_error mock process_template.side_effect = TemplateSyntaxError(...), but process_template always re-wraps parse-time TemplateSyntaxError as SupersetSyntaxErrorException (jinja_context.py:1034) — a raw TemplateSyntaxError never reaches the handler in production. Because TemplateSyntaxError is a TemplateError, the tests only exercise the ex.message branch; the realistic else: str(ex.errors[0].message …) branch is never covered. Fix: set the side effect to a SupersetSyntaxErrorException (built from a SupersetError) to cover the real branch and the .errors[0] indexing.

🟢 Low — Test assertion/setup nits
The four tests match= only the static prefix ("Time column template error", etc.), not the interpolated original message — so a refactor that blanks msg= to a constant would still pass; consider match=r"…template error.*nonexistent_var". Separately, the two convert_* tests copy database/catalog/schema/engine/_validate_stored_expression setup from …rejects_stored_subquery, but process_template raises before any of it runs — dead setup that misleads readers; drop it (or factor the shared scaffolding into a parametrized helper).

🟢 Low — Pattern nits
The new copies drop the reference's else: # SupersetSyntaxErrorException disambiguator (helpers.py:3578); worth restoring since the else silently corresponds to the second tuple member with a different error shape. The two-clause handler now appears three times in one file — if you dedupe, extract only the _template_error_message(ex) dispatch and keep the _()-wrapped raise inline at each site so pybabel extraction still picks up the strings.

…n coverage

Address review feedback (rebenitez1802 High/Medium/Low):

- Revert dead ExploreMixin.get_timestamp_expression guard; apply the
  two-clause UndefinedError + (TemplateError, SupersetSyntaxErrorException,
  SupersetTemplateException) handler to the real TableColumn dispatch path
- Add SupersetTemplateException to the caught tuple in
  convert_tbl_column_to_sqla_col and get_rendered_sql for consistency
- Extend the same guard pattern to TableColumn.get_sqla_col and
  SqlMetric.get_sqla_col (same get_sqla_query call path)
- Fix tests to inject SupersetSyntaxErrorException (what process_template
  actually emits) instead of raw TemplateSyntaxError
- Retarget get_timestamp_expression tests from __get__-bound ExploreMixin
  to real TableColumn via _stored_col helper
- Tighten match= regexes to pin interpolated content; drop dead
  datasource setup from convert_tbl_column tests
- Restore else: # SupersetTemplateException disambiguator comments

Deliberately deferred: _process_metric_sql_expression /
_process_validated_sql_expression (zero exception handling, noqa: C901,
broader adhoc-SQL path) and shared _template_error_message helper
(pybabel string-extraction risk).
@eschutho

Copy link
Copy Markdown
Member Author

@rebenitez1802 thanks for the thorough pass — the High was a real gap (the mixin method I'd patched has no production caller). Pushed c92265919732:

High — reverted ExploreMixin.get_timestamp_expression back to unguarded (it's dead code, TableColumn doesn't subclass ExploreMixin) and moved the two-clause handler into TableColumn.get_timestamp_expression instead, which is the method every call site actually dispatches to. Retargeted its two tests off the __get__-bound mixin onto the real method via the existing _stored_col fixture helper.

Medium — added SupersetTemplateException to the caught tuple (with a str(ex) fallback) in the retargeted get_timestamp_expression, in convert_tbl_column_to_sqla_col, and in get_rendered_sql for consistency.

Low (2 of 4) — extended the identical guard to the two sibling get_sqla_col methods (TableColumn.get_sqla_col, SqlMetric.get_sqla_col) since they're the same shape as what I was already touching. Fixed the tests injecting a raw TemplateSyntaxError (which process_template always re-wraps as SupersetSyntaxErrorException before a caller ever sees it) to inject the real wrapped type instead. Tightened match= assertions to the interpolated message, not just the static prefix. Restored the else: # SupersetSyntaxErrorException disambiguator.

Deliberately not done, flagging rather than guessing at scope: the adhoc-SQL sibling paths you flagged (_process_metric_sql_expression/_process_validated_sql_expression) have zero existing exception handling and sit on a much broader, noqa: C901-complex path with more collaborators — wrapping those felt like a separate, riskier change rather than a same-shape extension, so leaving it as a follow-up rather than bundling it here. Also skipped the handler-dedupe suggestion given your own pybabel-extraction caveat.

Full test file (92 tests) green, ruff/format clean, fail-before/pass-after verified individually for all 8 new/retargeted tests. Ready for another look.

@bito-code-review

bito-code-review Bot commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

Code Review Agent Run #4ae788

Actionable Suggestions - 0
Additional Suggestions - 1
  • tests/unit_tests/connectors/sqla/models_test.py - 1
    • Misleading test name · Line 1785-1785
      The test name `test_convert_tbl_column_to_sqla_col_wraps_jinja_template_error` no longer matches its body: it now injects `SupersetSyntaxErrorException` (a parse-time syntax error), not a jinja `TemplateError`. The docstring was updated but the function name was not, which can mislead future readers into thinking the jinja `TemplateError` path is still covered. Consider renaming to `..._wraps_superset_syntax_error`.
Review Details
  • Files reviewed - 3 · Commit Range: 7d4f277..c922659
    • superset/connectors/sqla/models.py
    • superset/models/helpers.py
    • tests/unit_tests/connectors/sqla/models_test.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 an incremental AI Review.

  • /review full - 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

@rebenitez1802 rebenitez1802 left a comment

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.

Approve — the retarget + exception-widening resolves both round-1 blockers; what's left is optional cleanup.

Resolved: the guard now lives on the real dispatch methods (TableColumn.get_timestamp_expression / get_sqla_col, SqlMetric.get_sqla_col), so the live temporal and metric paths no longer leak raw UndefinedError; every handler — including the get_rendered_sql reference — now also catches SupersetTemplateException / UndefinedTemplateFunctionException; the dead ExploreMixin.get_timestamp_expression edit was dropped; and the tests now hit the real path with realistic exception types (SupersetSyntaxErrorException, SupersetTemplateException) and detail-matching assertions. Dispatch verified correct: UndefinedError catches first (it's a TemplateError subclass), SupersetSyntaxErrorException and SupersetTemplateException are disjoint SupersetException siblings, and UndefinedTemplateFunctionException lands in the else.

🟢 Low — Adhoc-SQL expression paths still unguarded (follow-up)
_process_metric_sql_expression (helpers.py:1938) and _process_validated_sql_expression (helpers.py:1972) still call process_template with no Jinja try/except, so an adhoc metric/column/WHERE/HAVING/ORDER BY sqlExpression with bad Jinja still surfaces a raw error. Pre-existing (not a regression from this PR), so fine to defer — but it's the same bug class and the last sibling in ExploreMixin; worth a follow-up.

🟢 Nit — # noqa: C901 is a symptom of 5× handler duplication
The 3-branch isinstance dispatch is now copy-pasted at five sites (get_sqla_col, get_timestamp_expression, SqlMetric.get_sqla_col, convert_tbl_column_to_sqla_col, get_rendered_sql), which pushed get_timestamp_expression over the complexity threshold. Extracting a module-level _jinja_error_message(ex) -> str and calling it at all five would remove the duplication and let you drop the # noqa: C901. Keep the _()-wrapped raise inline at each site so pybabel still extracts the strings.

🟢 Nit — Message-less TemplateError renders "None"
In if isinstance(ex, TemplateError): error_msg = ex.message, a bare TemplateError() has .message is None, so the surfaced message ends in "None". Contrived (needs a custom filter/extension raising base TemplateError with no message) and inherited from the existing reference, so non-blocking. Fix: error_msg = ex.message or str(ex) (ideally in the extracted helper).

@eschutho
eschutho merged commit 3abc358 into master Sep 17, 2026
76 checks passed
@eschutho
eschutho deleted the fix-unprotected-jinja-template-helpers branch September 17, 2026 22:03
villebro pushed a commit that referenced this pull request Sep 20, 2026
…#44172)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
(cherry picked from commit 3abc358)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants