You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ru...@apache.org on 2026/08/21 16:11:12 UTC
(superset) 01/04: fix(jinja): render escaped list/dict values without repr-added quote delimiters
This is an automated email from the ASF dual-hosted git repository.
rusackas pushed a commit to branch fix/sqllab-render-once-and-cache-scoping
in repository https://gitbox.apache.org/repos/asf/superset.git
commit 9674e7bfc86032089cbd82f6a5a94ba62a68eb0e
Author: Superset Dev <de...@superset.apache.org>
AuthorDate: Fri Aug 21 00:41:09 2026 -0700
fix(jinja): render escaped list/dict values without repr-added quote delimiters
ExtraCache._escape_value already escapes string leaves nested inside
lists and dicts, but interpolating the whole container in a template
goes through Python's default str()/repr(), which wraps each string
element in its own quote delimiters. Those delimiters were never
escaped, so a container value with no leading quote could still break
out of a surrounding SQL literal (e.g. LIKE '{{ escaped_val }}').
Return escaped lists/dicts as SQLSafeList/SQLSafeDict, subclasses that
render their whole-container string form as the already-escaped
elements joined together with no extra delimiters, while still
comparing equal to and behaving like plain list/dict everywhere else.
Co-Authored-By: Claude Sonnet 5 <no...@anthropic.com>
---
superset/jinja_context.py | 56 +++++++++++++++++++++++++++-
tests/unit_tests/jinja_context_test.py | 67 ++++++++++++++++++++++++++++++++++
2 files changed, 121 insertions(+), 2 deletions(-)
diff --git a/superset/jinja_context.py b/superset/jinja_context.py
index 96b3756a68f..75724a68def 100644
--- a/superset/jinja_context.py
+++ b/superset/jinja_context.py
@@ -118,6 +118,48 @@ class TimeFilter:
time_range: str | None
+class SQLSafeList(list[Any]): # noqa: FURB189
+ """
+ A list of dialect-escaped values whose *whole-container* string
+ rendering cannot re-introduce raw quote characters.
+
+ Rendering a plain Python list in a Jinja template goes through
+ ``str()``/``repr()``, which wraps every string element in fresh quote
+ delimiters (and switches to double-quote delimiters when the element
+ contains a single quote, emitting that single quote raw). Either way
+ the rendered text can contain quote characters that were never
+ escaped for SQL, so a template interpolating the list inside its own
+ quotes -- e.g. ``LIKE '{{ filter.get('escaped_val') }}'`` -- could be
+ broken out of even though every string leaf was individually escaped.
+ This subclass renders as its (already-escaped) elements joined with
+ ``", "``, with no additional delimiters, so every quote in the output
+ is one the dialect's literal processor already escaped.
+ """
+
+ def __str__(self) -> str:
+ return ", ".join(str(element) for element in self)
+
+ __repr__ = __str__
+
+
+class SQLSafeDict(dict[Any, Any]): # noqa: FURB189
+ """
+ A dict of dialect-escaped values whose *whole-container* string
+ rendering cannot re-introduce raw quote characters. Mirrors
+ :class:`SQLSafeList` for the mapping case.
+
+ Keys are rendered as-is: they are used for member lookups (for
+ example ``{{ get_guest_user_attribute('tenant').id }}``), not
+ interpolated into SQL, and are never passed through
+ ``ExtraCache._escape_value``.
+ """
+
+ def __str__(self) -> str:
+ return ", ".join(f"{key}: {value}" for key, value in self.items())
+
+ __repr__ = __str__
+
+
def _normalize_postgresql_backslash_escapes(dialect: Dialect) -> None:
"""Correct a PostgreSQL dialect instance's ``_backslash_escapes`` default
in place so backslashes round-trip unchanged when the dialect is used to
@@ -467,6 +509,16 @@ class ExtraCache:
strings nested inside JSON structures are also escaped; dict keys
are left untouched since they are used for member lookups, not
interpolation. Non-string leaf values are left as-is.
+
+ Lists and dicts are returned as :class:`SQLSafeList` /
+ :class:`SQLSafeDict` rather than plain ``list``/``dict``: Jinja
+ renders a whole container through ``str()``/``repr()``, which
+ wraps string elements in fresh quote delimiters that were never
+ escaped, so even a fully-escaped container could re-introduce raw
+ quotes when interpolated as a whole. The safe subclasses render
+ without adding such delimiters, while still comparing equal to
+ (and behaving like) their plain built-in counterparts everywhere
+ else.
"""
if not self.dialect:
return val
@@ -475,9 +527,9 @@ class ExtraCache:
_normalize_postgresql_backslash_escapes(compiler.dialect)
return compiler.render_literal_value(val, String())[1:-1]
if isinstance(val, list):
- return [self._escape_value(v) for v in val]
+ return SQLSafeList(self._escape_value(v) for v in val)
if isinstance(val, dict):
- return {k: self._escape_value(v) for k, v in val.items()}
+ return SQLSafeDict((k, self._escape_value(v)) for k, v in val.items())
return val
def get_filters(self, column: str, remove_filter: bool = False) -> list[Filter]:
diff --git a/tests/unit_tests/jinja_context_test.py b/tests/unit_tests/jinja_context_test.py
index 2b50b9d6a84..cad42e0c426 100644
--- a/tests/unit_tests/jinja_context_test.py
+++ b/tests/unit_tests/jinja_context_test.py
@@ -474,6 +474,73 @@ def test_get_filters_no_escaped_val_without_dialect() -> None:
assert "escaped_val" not in result[0]
+def test_escape_value_nested_list_is_escaped() -> None:
+ """
+ Regression: string leaves nested more than one level deep used to pass
+ through ``_escape_value`` untouched, and Jinja's whole-container
+ rendering of the nested list re-emitted the single quote raw --
+ terminating the surrounding SQL string literal.
+ """
+ cache = ExtraCache(
+ dialect=dialect(),
+ query_context_filters=[
+ {
+ "col": "name",
+ "op": "LIKE",
+ "val": [["x' UNION SELECT username FROM users --"]],
+ },
+ ],
+ )
+ [entry] = cache.get_filters("name")
+ rendered = str(entry["escaped_val"])
+ # every single quote in the rendered output must be escaped (doubled)
+ assert "'" not in rendered.replace("''", "")
+ assert "x'' UNION SELECT username FROM users --" in rendered
+
+
+def test_escape_value_flat_list_renders_without_raw_quotes() -> None:
+ """
+ Regression: rendering a whole escaped list used to go through Python's
+ default ``str()``/``repr()``, which wraps elements in fresh quote
+ delimiters -- letting even a payload containing no single quotes break
+ out of the template's own quotes. The escaped list must render as
+ escaped elements only, with no delimiters added.
+ """
+ cache = ExtraCache(
+ dialect=dialect(),
+ query_context_filters=[
+ {"col": "name", "op": "LIKE", "val": ['x") OR 1=1 --', "O'Brien"]},
+ ],
+ )
+ [entry] = cache.get_filters("name")
+ # element-wise access still compares equal to the plain escaped values
+ assert entry["escaped_val"] == ['x") OR 1=1 --', "O''Brien"]
+ # whole-list rendering must not add quote delimiters around elements
+ rendered = str(entry["escaped_val"])
+ assert rendered == "x\") OR 1=1 --, O''Brien"
+ assert "'" not in rendered.replace("''", "")
+
+
+def test_escape_value_dict_renders_without_raw_quotes() -> None:
+ """
+ Regression: a dict value returned by ``get_guest_user_attribute`` is
+ recursively escaped at the leaf level, but rendering the whole dict in
+ a template used to go through Python's default ``repr()``, which
+ re-wraps string values in fresh quote delimiters -- reopening the
+ same whole-container escape hatch as for lists.
+ """
+ cache = ExtraCache(dialect=dialect())
+ escaped = cache._escape_value( # pylint: disable=protected-access
+ {"id": "foo' OR 1=1 --", "names": ["O'Brien", 42]}
+ )
+ # equality and member access behave exactly like a plain dict
+ assert escaped == {"id": "foo'' OR 1=1 --", "names": ["O''Brien", 42]}
+ assert isinstance(escaped, dict)
+ # whole-container rendering must not add quote delimiters
+ rendered = str(escaped)
+ assert "'" not in rendered.replace("''", "")
+
+
def test_url_param_query() -> None:
"""
Test the ``url_param`` macro.