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.