You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@superset.apache.org by "aminghadersohi (via GitHub)" <gi...@apache.org> on 2026/01/16 00:50:08 UTC
[PR] fix(mcp): remove html.escape to fix ampersand display in chart titles [superset]
aminghadersohi opened a new pull request, #37186:
URL: https://github.com/apache/superset/pull/37186
## Summary
Fixes a bug where ampersands (`&`) in MCP-generated chart titles display as `&` in the Superset UI.
**Root Cause:** The `html.escape()` function was being applied to chart names, column names, column labels, filter column names, and filter values in the MCP chart schemas validation layer. This HTML encoding was unnecessary because:
1. The data is stored in a database and returned via JSON API
2. React handles its own escaping when rendering text content
3. The existing XSS protections (blocking script tags, javascript: URLs, event handlers, SQL injection patterns) are sufficient
**Changes:**
- Removed `import html` from schemas.py
- Removed `html.escape()` calls from 5 sanitization validators:
- `ColumnRef.sanitize_name()` - column names
- `ColumnRef.sanitize_label()` - column labels
- `FilterConfig.sanitize_column()` - filter column names
- `FilterConfig.sanitize_value()` - filter values
- `UpdateChartRequest.sanitize_chart_name()` - chart names
## BEFORE/AFTER
**Before:** Creating a chart with title "A & B" via MCP `generate_chart` tool shows "A & B" in the UI
**After:** Creating a chart with title "A & B" displays correctly as "A & B"
## TESTING INSTRUCTIONS
1. Use MCP `generate_chart` tool to create a chart with a title containing `&`
2. Verify the title displays correctly in Superset UI without HTML encoding
## ADDITIONAL INFORMATION
- [ ] Has associated issue: N/A - simple bug fix
- [ ] Required feature flags: None
- [ ] Changes UI: No
- [ ] Includes DB Migration: No
- [ ] Changes API: No
🤖 Generated with [Claude Code](https://claude.com/claude-code)
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] fix(mcp): remove html.escape to fix ampersand display in chart titles [superset]
Posted by "codeant-ai-for-open-source[bot] (via GitHub)" <gi...@apache.org>.
codeant-ai-for-open-source[bot] commented on code in PR #37186:
URL: https://github.com/apache/superset/pull/37186#discussion_r2719657810
##########
tests/unit_tests/mcp_service/utils/test_sanitization.py:
##########
@@ -0,0 +1,411 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import pytest
+
+from superset.mcp_service.utils.sanitization import (
+ _check_dangerous_patterns,
+ _check_sql_patterns,
+ _remove_dangerous_unicode,
+ _strip_html_tags,
+ sanitize_filter_value,
+ sanitize_user_input,
+)
+
+
+# --- _strip_html_tags tests ---
+
+
+def test_strip_html_tags_plain_text():
+ assert _strip_html_tags("hello world") == "hello world"
+
+
+def test_strip_html_tags_preserves_ampersand():
+ assert _strip_html_tags("A & B") == "A & B"
+
+
+def test_strip_html_tags_preserves_multiple_ampersands():
+ assert _strip_html_tags("A & B & C") == "A & B & C"
+
+
+def test_strip_html_tags_strips_bold_tags():
+ assert _strip_html_tags("<b>hello</b>") == "hello"
+
+
+def test_strip_html_tags_strips_script_tags():
+ result = _strip_html_tags("<script>alert(1)</script>")
+ assert "<script>" not in result
+ assert "</script>" not in result
+
+
+def test_strip_html_tags_strips_entity_encoded_script():
+ """Entity-encoded tags must be decoded and stripped, not passed through."""
+ result = _strip_html_tags("<script>alert(1)</script>")
+ assert "<script>" not in result
+ assert "<script>" not in result
+
+
+def test_strip_html_tags_strips_double_encoded_script():
+ """Double-encoded entities must also be decoded and stripped."""
+ result = _strip_html_tags("&lt;script&gt;alert(1)&lt;/script&gt;")
+ assert "<script>" not in result
+ assert "<script>" not in result
+
+
+def test_strip_html_tags_strips_img_onerror():
+ result = _strip_html_tags('<img src=x onerror="alert(1)">')
+ assert "<img" not in result
+ assert "onerror" not in result
+
+
+def test_strip_html_tags_strips_div_tags():
+ assert _strip_html_tags("<div>content</div>") == "content"
+
+
+def test_strip_html_tags_preserves_less_than_in_text():
+ """A bare < not forming a tag should be preserved."""
+ result = _strip_html_tags("5 < 10")
+ assert "5" in result
+ assert "10" in result
+
+
+def test_strip_html_tags_empty_string():
+ assert _strip_html_tags("") == ""
+
+
+def test_strip_html_tags_triple_encoded_script():
+ """Triple-encoded entities must also be decoded and stripped."""
+ result = _strip_html_tags(
+ "&amp;lt;script&amp;gt;alert(1)&amp;lt;/script&amp;gt;"
+ )
+ assert "<script>" not in result
+
+
+def test_strip_html_tags_mixed_encoded_and_raw():
+ """Both raw and entity-encoded tags should be stripped."""
+ result = _strip_html_tags("<b>bold</b> and <i>italic</i>")
+ assert "<b>" not in result
+ assert "<i>" not in result
+ assert "bold" in result
+ assert "italic" in result
+
+
+def test_strip_html_tags_deep_encoding_terminates():
+ """Verify the iterative decode loop terminates on many encoding layers."""
+ value = "<script>alert(1)</script>"
+ for _ in range(10):
+ value = value.replace("&", "&").replace("<", "<").replace(">", ">")
+ result = _strip_html_tags(value)
+ assert "<script>" not in result
+
+
+def test_strip_html_tags_entity_ampersand():
+ """& in input should become & in output."""
+ assert _strip_html_tags("A & B") == "A & B"
+
+
+# --- _check_dangerous_patterns tests ---
+
+
+def test_check_dangerous_patterns_safe_input():
+ _check_dangerous_patterns("hello world", "test")
+
+
+def test_check_dangerous_patterns_javascript_scheme():
+ with pytest.raises(ValueError, match="malicious URL scheme"):
+ _check_dangerous_patterns("javascript:alert(1)", "test")
+
+
+def test_check_dangerous_patterns_vbscript_scheme():
+ with pytest.raises(ValueError, match="malicious URL scheme"):
+ _check_dangerous_patterns("vbscript:MsgBox", "test")
+
+
+def test_check_dangerous_patterns_data_scheme():
+ with pytest.raises(ValueError, match="malicious URL scheme"):
+ _check_dangerous_patterns("data:text/html,<script>", "test")
+
+
+def test_check_dangerous_patterns_case_insensitive():
+ with pytest.raises(ValueError, match="malicious URL scheme"):
+ _check_dangerous_patterns("JAVASCRIPT:alert(1)", "test")
+
+
+def test_check_dangerous_patterns_onclick():
+ with pytest.raises(ValueError, match="malicious event handler"):
+ _check_dangerous_patterns("onclick=alert(1)", "test")
+
+
+def test_check_dangerous_patterns_onerror():
+ with pytest.raises(ValueError, match="malicious event handler"):
+ _check_dangerous_patterns("onerror = alert(1)", "test")
+
+
+def test_check_dangerous_patterns_onload():
+ with pytest.raises(ValueError, match="malicious event handler"):
+ _check_dangerous_patterns("onload=fetch('x')", "test")
+
+
+# --- _check_sql_patterns tests ---
+
+
+def test_check_sql_patterns_safe_input():
+ _check_sql_patterns("revenue_total", "test")
+
+
+def test_check_sql_patterns_drop_table():
+ with pytest.raises(ValueError, match="unsafe SQL keywords"):
+ _check_sql_patterns("DROP TABLE users", "test")
+
+
+def test_check_sql_patterns_delete():
+ with pytest.raises(ValueError, match="unsafe SQL keywords"):
+ _check_sql_patterns("DELETE FROM users", "test")
+
+
+def test_check_sql_patterns_semicolon():
+ with pytest.raises(ValueError, match="unsafe characters"):
+ _check_sql_patterns("value; other", "test")
+
+
+def test_check_sql_patterns_sql_comment_dash():
+ with pytest.raises(ValueError, match="unsafe characters"):
+ _check_sql_patterns("value -- comment", "test")
+
+
+def test_check_sql_patterns_sql_comment_block():
+ with pytest.raises(ValueError, match="unsafe SQL comment"):
+ _check_sql_patterns("value /* comment */", "test")
+
+
+def test_check_sql_patterns_pipe():
+ with pytest.raises(ValueError, match="unsafe characters"):
+ _check_sql_patterns("value | other", "test")
+
+
+def test_check_sql_patterns_case_insensitive():
+ with pytest.raises(ValueError, match="unsafe SQL keywords"):
+ _check_sql_patterns("drop table users", "test")
+
+
+# --- _remove_dangerous_unicode tests ---
+
+
+def test_remove_dangerous_unicode_plain_text():
+ assert _remove_dangerous_unicode("hello") == "hello"
+
+
+def test_remove_dangerous_unicode_zero_width_space():
+ assert _remove_dangerous_unicode("he\u200bllo") == "hello"
+
+
+def test_remove_dangerous_unicode_zero_width_joiner():
+ assert _remove_dangerous_unicode("he\u200dllo") == "hello"
+
+
+def test_remove_dangerous_unicode_bom():
+ assert _remove_dangerous_unicode("\ufeffhello") == "hello"
+
+
+def test_remove_dangerous_unicode_null_byte():
+ assert _remove_dangerous_unicode("he\x00llo") == "hello"
+
+
+def test_remove_dangerous_unicode_preserves_normal_unicode():
+ assert _remove_dangerous_unicode("café résumé") == "café résumé"
+
+
+# --- sanitize_user_input tests ---
+
+
+def test_sanitize_user_input_plain_text():
+ assert sanitize_user_input("hello", "test") == "hello"
+
+
+def test_sanitize_user_input_preserves_ampersand():
+ assert sanitize_user_input("A & B", "test") == "A & B"
+
+
+def test_sanitize_user_input_strips_html():
+ assert sanitize_user_input("<b>hello</b>", "test") == "hello"
+
+
+def test_sanitize_user_input_none_not_allowed():
+ with pytest.raises(ValueError, match="cannot be empty"):
+ sanitize_user_input(None, "test")
+
+
+def test_sanitize_user_input_none_allowed():
+ assert sanitize_user_input(None, "test", allow_empty=True) is None
+
+
+def test_sanitize_user_input_empty_string_not_allowed():
+ with pytest.raises(ValueError, match="cannot be empty"):
+ sanitize_user_input("", "test")
+
+
+def test_sanitize_user_input_empty_string_allowed():
+ assert sanitize_user_input("", "test", allow_empty=True) is None
+
+
+def test_sanitize_user_input_whitespace_only():
+ with pytest.raises(ValueError, match="cannot be empty"):
+ sanitize_user_input(" ", "test")
+
+
+def test_sanitize_user_input_strips_whitespace():
+ assert sanitize_user_input(" hello ", "test") == "hello"
+
+
+def test_sanitize_user_input_too_long():
+ with pytest.raises(ValueError, match="too long"):
+ sanitize_user_input("a" * 256, "test", max_length=255)
+
+
+def test_sanitize_user_input_max_length_ok():
+ result = sanitize_user_input("a" * 255, "test", max_length=255)
+ assert result == "a" * 255
+
+
+def test_sanitize_user_input_blocks_javascript():
+ with pytest.raises(ValueError, match="malicious URL scheme"):
+ sanitize_user_input("javascript:alert(1)", "test")
+
+
+def test_sanitize_user_input_blocks_event_handler():
+ with pytest.raises(ValueError, match="malicious event handler"):
+ sanitize_user_input("onclick=alert(1)", "test")
+
+
+def test_sanitize_user_input_sql_keywords_not_checked_by_default():
+ result = sanitize_user_input("DROP TABLE", "test")
+ assert result == "DROP TABLE"
+
+
+def test_sanitize_user_input_sql_keywords_checked_when_enabled():
+ with pytest.raises(ValueError, match="unsafe SQL keywords"):
+ sanitize_user_input("DROP TABLE users", "test", check_sql_keywords=True)
+
+
+def test_sanitize_user_input_removes_zero_width_chars():
+ result = sanitize_user_input("hel\u200blo", "test")
+ assert result == "hello"
+
+
+def test_sanitize_user_input_xss_entity_encoded():
+ """Entity-encoded XSS attempts must be neutralized."""
+ result = sanitize_user_input("<script>alert(1)</script>", "test")
+ assert "<script>" not in result
+
+
+def test_sanitize_user_input_entity_encoded_javascript():
+ """Entity-encoded javascript: scheme should be caught after decoding."""
+ with pytest.raises(ValueError, match="malicious URL scheme"):
+ sanitize_user_input("javascript:alert(1)", "test")
+
+
+# --- sanitize_filter_value tests ---
+
+
+def test_sanitize_filter_value_integer():
+ assert sanitize_filter_value(42) == 42
+
+
+def test_sanitize_filter_value_float():
+ assert sanitize_filter_value(3.14) == 3.14
+
+
+def test_sanitize_filter_value_bool():
+ assert sanitize_filter_value(True) is True
+
+
+def test_sanitize_filter_value_plain_string():
+ assert sanitize_filter_value("hello") == "hello"
+
+
+def test_sanitize_filter_value_preserves_ampersand():
+ assert sanitize_filter_value("A & B") == "A & B"
+
+
+def test_sanitize_filter_value_strips_html():
+ assert sanitize_filter_value("<b>hello</b>") == "hello"
+
+
+def test_sanitize_filter_value_too_long():
+ with pytest.raises(ValueError, match="too long"):
+ sanitize_filter_value("a" * 1001)
+
+
+def test_sanitize_filter_value_blocks_javascript():
+ with pytest.raises(ValueError, match="malicious URL scheme"):
+ sanitize_filter_value("javascript:alert(1)")
+
+
+def test_sanitize_filter_value_blocks_xp_cmdshell():
+ with pytest.raises(ValueError, match="malicious SQL procedures"):
+ sanitize_filter_value("xp_cmdshell('dir')")
+
+
+def test_sanitize_filter_value_blocks_sp_executesql():
+ with pytest.raises(ValueError, match="malicious SQL procedures"):
+ sanitize_filter_value("sp_executesql @stmt")
+
+
+def test_sanitize_filter_value_blocks_union_select():
+ with pytest.raises(ValueError, match="malicious SQL patterns"):
+ sanitize_filter_value("' UNION SELECT * FROM users")
+
+
+def test_sanitize_filter_value_blocks_sql_comment():
+ with pytest.raises(ValueError, match="malicious SQL patterns"):
+ sanitize_filter_value("value -- drop")
+
+
+def test_sanitize_filter_value_blocks_shell_semicolon():
+ with pytest.raises(ValueError, match="unsafe shell characters"):
+ sanitize_filter_value("value;rm -rf")
+
+
+def test_sanitize_filter_value_blocks_shell_pipe():
+ with pytest.raises(ValueError, match="unsafe shell characters"):
+ sanitize_filter_value("value|cat /etc/passwd")
+
+
+def test_sanitize_filter_value_blocks_backtick():
+ with pytest.raises(ValueError, match="unsafe shell characters"):
+ sanitize_filter_value("`whoami`")
+
+
+def test_sanitize_filter_value_blocks_hex_encoding():
+ with pytest.raises(ValueError, match="hex encoding"):
+ sanitize_filter_value("\\x41\\x42")
+
+
+def test_sanitize_filter_value_allows_ampersand_alone():
+ """Ampersand is safe in filter values (only dangerous in shell contexts)."""
+ assert sanitize_filter_value("AT&T") == "AT&T"
Review Comment:
**Suggestion:** The test asserts that an ampersand is allowed ("AT&T"), but the current `_check_sql_patterns` flags `&` as an unsafe character, so this test will fail against the present implementation. Either the sanitizer must be changed to allow `&` in filter values, or the test should expect the current behavior; update the test to expect the sanitizer's current error. [logic error]
<details>
<summary><b>Severity Level:</b> Critical 🚨</summary>
```mdx
- ❌ CI unit tests fail blocking PR validation.
- ⚠️ Ambiguity about ampersand policy in sanitizer.
```
</details>
```suggestion
"""Ampersand is considered unsafe by the current implementation."""
with pytest.raises(ValueError, match="potentially unsafe characters"):
sanitize_filter_value("AT&T")
```
<details>
<summary><b>Steps of Reproduction ✅ </b></summary>
```mdx
1. Run the single unit test: pytest
tests/unit_tests/mcp_service/utils/test_sanitization.py::test_sanitize_filter_value_allows_ampersand_alone
- Test located at tests/unit_tests/mcp_service/utils/test_sanitization.py:398-400.
2. The test calls sanitize_filter_value("AT&T") at
tests/unit_tests/mcp_service/utils/test_sanitization.py:400.
3. sanitize_filter_value triggers SQL/shell-character checks implemented in:
- superset/mcp_service/utils/sanitization.py:_check_sql_patterns (see unsafe characters
check at lines 100-125).
- _check_sql_patterns currently considers & unsafe via the regex r"[;|&$`]|--"
(superset/mcp_service/utils/sanitization.py:109-112).
4. Given the current implementation, sanitize_filter_value("AT&T") will raise ValueError
for "potentially unsafe characters", so the test's assertion that the call returns "AT&T"
is false — running the test reproduces the failure immediately. The mismatch reflects
either an intentional sanitizer policy (disallow &), or a test that needs updating if the
project's policy is to allow &.
```
</details>
<details>
<summary><b>Prompt for AI Agent 🤖 </b></summary>
```mdx
This is a comment left during a code review.
**Path:** tests/unit_tests/mcp_service/utils/test_sanitization.py
**Line:** 399:400
**Comment:**
*Logic Error: The test asserts that an ampersand is allowed ("AT&T"), but the current `_check_sql_patterns` flags `&` as an unsafe character, so this test will fail against the present implementation. Either the sanitizer must be changed to allow `&` in filter values, or the test should expect the current behavior; update the test to expect the sanitizer's current error.
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.
```
</details>
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] fix(mcp): remove html.escape to fix ampersand display in chart titles [superset]
Posted by "aminghadersohi (via GitHub)" <gi...@apache.org>.
aminghadersohi commented on PR #37186:
URL: https://github.com/apache/superset/pull/37186#issuecomment-3776084040
Thanks @betodealmeida! Refactored to use `nh3` for HTML sanitization. The implementation now:
- Uses `nh3.clean()` with no allowed tags to strip all HTML
- Applies `html.unescape()` afterward to preserve characters like `&`
- Extracted helper functions to reduce code duplication
This is much cleaner than the regex-based approach.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] fix(mcp): remove html.escape to fix ampersand display in chart titles [superset]
Posted by "codeant-ai-for-open-source[bot] (via GitHub)" <gi...@apache.org>.
codeant-ai-for-open-source[bot] commented on code in PR #37186:
URL: https://github.com/apache/superset/pull/37186#discussion_r2710816414
##########
superset/mcp_service/utils/sanitization.py:
##########
@@ -0,0 +1,267 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""
+Centralized sanitization utilities for MCP service user inputs.
+
+This module uses the nh3 library (Rust-based HTML sanitizer) to strip malicious
+HTML tags and protocols from user inputs. nh3 is faster and safer than manual
+regex-based sanitization.
+
+Key features:
+- Strips all HTML tags using nh3.clean() with no allowed tags
+- Blocks dangerous URL schemes (javascript:, vbscript:, data:)
+- Preserves safe text content (e.g., '&' stays as '&', not '&')
+- Additional SQL injection protection for database-facing inputs
+"""
+
+import html
+import re
+
+import nh3
+
+
+def _strip_html_tags(value: str) -> str:
+ """
+ Strip all HTML tags from the input using nh3, then unescape HTML entities.
+
+ Uses nh3's Rust-based sanitizer with no allowed tags, which is
+ faster and safer than manual regex-based tag stripping.
+
+ Since nh3.clean() produces HTML-safe output (escaping & to &, etc.),
+ we use html.unescape() afterward to restore the original characters.
+ This ensures text like "A & B" stays as "A & B", not "A & B".
+
+ Args:
+ value: The input string that may contain HTML
+
+ Returns:
+ String with all HTML tags removed and entities unescaped
+ """
+ # nh3.clean with tags=set() strips ALL HTML tags
+ # url_schemes=set() blocks all URL schemes in any remaining attributes
+ cleaned = nh3.clean(value, tags=set(), url_schemes=set())
+
+ # Unescape HTML entities so & doesn't become &
+ return html.unescape(cleaned)
Review Comment:
**Suggestion:** Security bug: calling html.unescape() on nh3's cleaned output can reintroduce HTML tags and scripts that the sanitizer removed/escaped (e.g., "<script>...</script>" -> "<script>...<\/script>"), effectively undoing sanitization and creating an XSS vector; return the sanitizer output directly instead of unescaping it. [security]
<details>
<summary><b>Severity Level:</b> Critical 🚨</summary>
```mdx
- ❌ MCP chart creation XSS risk in titles
- ❌ Stored chart metadata may contain scripts
- ⚠️ Superset UI rendering could execute injected scripts
```
</details>
```suggestion
# Return the sanitizer output directly to avoid reintroducing escaped tags/entities.
return cleaned
```
<details>
<summary><b>Steps of Reproduction ✅ </b></summary>
```mdx
1. Call sanitize_user_input() in superset/mcp_service/utils/sanitization.py (function at
~lines 135-193). The function calls _strip_html_tags(value) at line 181: `value =
_strip_html_tags(value)`.
2. Execution enters _strip_html_tags() at
superset/mcp_service/utils/sanitization.py:38-60. At line 57 it runs `cleaned =
nh3.clean(value, tags=set(), url_schemes=set())`.
3. nh3.clean() will escape unsafe characters into entities (e.g. "<script>" ->
"<script>"). The current code then calls html.unescape() at line 60 which converts
"<script>...</script>" back to "<script>...</script>" and returns it to
sanitize_user_input().
4. If a downstream caller stores this returned value (for example MCP chart title via
schemas validators like UpdateChartRequest.sanitize_chart_name in
superset/mcp_service/schemas.py) it can reach the Superset UI via JSON APIs and be
rendered by React, reintroducing an executable <script> tag (XSS). Observe returned string
contains raw "<script>" when inspecting the value returned from sanitize_user_input() at
superset/mcp_service/utils/sanitization.py:181 after calling _strip_html_tags().
```
</details>
<details>
<summary><b>Prompt for AI Agent 🤖 </b></summary>
```mdx
This is a comment left during a code review.
**Path:** superset/mcp_service/utils/sanitization.py
**Line:** 58:60
**Comment:**
*Security: Security bug: calling html.unescape() on nh3's cleaned output can reintroduce HTML tags and scripts that the sanitizer removed/escaped (e.g., "<script>...</script>" -> "<script>...<\/script>"), effectively undoing sanitization and creating an XSS vector; return the sanitizer output directly instead of unescaping it.
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.
```
</details>
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] fix(mcp): remove html.escape to fix ampersand display in chart titles [superset]
Posted by "codeant-ai-for-open-source[bot] (via GitHub)" <gi...@apache.org>.
codeant-ai-for-open-source[bot] commented on PR #37186:
URL: https://github.com/apache/superset/pull/37186#issuecomment-3775949589
CodeAnt AI Incremental review completed.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] fix(mcp): remove html.escape to fix ampersand display in chart titles [superset]
Posted by "bito-code-review[bot] (via GitHub)" <gi...@apache.org>.
bito-code-review[bot] commented on code in PR #37186:
URL: https://github.com/apache/superset/pull/37186#discussion_r2757304966
##########
superset/mcp_service/chart/schemas.py:
##########
@@ -21,7 +21,6 @@
from __future__ import annotations
-import html
import re
Review Comment:
<!-- Bito Reply -->
The comment correctly explains that the PR replaces html.escape with nh3.clean(value, tags=set()), which strips all HTML tags entirely—including <img onerror>—rather than just encoding them. This is more secure as it removes malicious content instead of relying on consistent escaping. The <img onerror> vector is indeed tested at line 69 in test_sanitization.py, confirming the strip removes the tag.
##########
tests/unit_tests/mcp_service/utils/test_sanitization.py:
##########
@@ -0,0 +1,410 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import pytest
+
+from superset.mcp_service.utils.sanitization import (
+ _check_dangerous_patterns,
+ _check_sql_patterns,
+ _remove_dangerous_unicode,
+ _strip_html_tags,
+ sanitize_filter_value,
+ sanitize_user_input,
+)
+
+# --- _strip_html_tags tests ---
+
+
+def test_strip_html_tags_plain_text():
+ assert _strip_html_tags("hello world") == "hello world"
+
+
+def test_strip_html_tags_preserves_ampersand():
+ assert _strip_html_tags("A & B") == "A & B"
+
+
+def test_strip_html_tags_preserves_multiple_ampersands():
+ assert _strip_html_tags("A & B & C") == "A & B & C"
+
+
+def test_strip_html_tags_strips_bold_tags():
+ assert _strip_html_tags("<b>hello</b>") == "hello"
+
+
+def test_strip_html_tags_strips_script_tags():
+ result = _strip_html_tags("<script>alert(1)</script>")
+ assert "<script>" not in result
+ assert "</script>" not in result
+
+
+def test_strip_html_tags_strips_entity_encoded_script():
+ """Entity-encoded tags must be decoded and stripped, not passed through."""
+ result = _strip_html_tags("<script>alert(1)</script>")
+ assert "<script>" not in result
+ assert "<script>" not in result
+
+
+def test_strip_html_tags_strips_double_encoded_script():
+ """Double-encoded entities must also be decoded and stripped."""
+ result = _strip_html_tags("&lt;script&gt;alert(1)&lt;/script&gt;")
+ assert "<script>" not in result
+ assert "<script>" not in result
+
+
+def test_strip_html_tags_strips_img_onerror():
+ result = _strip_html_tags('<img src=x onerror="alert(1)">')
+ assert "<img" not in result
+ assert "onerror" not in result
+
+
+def test_strip_html_tags_strips_div_tags():
+ assert _strip_html_tags("<div>content</div>") == "content"
+
+
+def test_strip_html_tags_preserves_less_than_in_text():
+ """A bare < not forming a tag should be preserved."""
+ result = _strip_html_tags("5 < 10")
+ assert "5" in result
+ assert "10" in result
+
+
+def test_strip_html_tags_empty_string():
+ assert _strip_html_tags("") == ""
+
+
+def test_strip_html_tags_triple_encoded_script():
+ """Triple-encoded entities must also be decoded and stripped."""
+ result = _strip_html_tags(
+ "&amp;lt;script&amp;gt;alert(1)&amp;lt;/script&amp;gt;"
+ )
+ assert "<script>" not in result
+
+
+def test_strip_html_tags_mixed_encoded_and_raw():
+ """Both raw and entity-encoded tags should be stripped."""
+ result = _strip_html_tags("<b>bold</b> and <i>italic</i>")
+ assert "<b>" not in result
+ assert "<i>" not in result
+ assert "bold" in result
+ assert "italic" in result
+
+
+def test_strip_html_tags_deep_encoding_terminates():
+ """Verify the iterative decode loop terminates on many encoding layers."""
+ value = "<script>alert(1)</script>"
+ for _ in range(10):
+ value = value.replace("&", "&").replace("<", "<").replace(">", ">")
+ result = _strip_html_tags(value)
+ assert "<script>" not in result
+
+
+def test_strip_html_tags_entity_ampersand():
+ """& in input should become & in output."""
+ assert _strip_html_tags("A & B") == "A & B"
+
+
+# --- _check_dangerous_patterns tests ---
+
+
+def test_check_dangerous_patterns_safe_input():
+ _check_dangerous_patterns("hello world", "test")
+
+
+def test_check_dangerous_patterns_javascript_scheme():
+ with pytest.raises(ValueError, match="malicious URL scheme"):
+ _check_dangerous_patterns("javascript:alert(1)", "test")
+
+
+def test_check_dangerous_patterns_vbscript_scheme():
+ with pytest.raises(ValueError, match="malicious URL scheme"):
+ _check_dangerous_patterns("vbscript:MsgBox", "test")
+
+
+def test_check_dangerous_patterns_data_scheme():
+ with pytest.raises(ValueError, match="malicious URL scheme"):
+ _check_dangerous_patterns("data:text/html,<script>", "test")
+
+
+def test_check_dangerous_patterns_case_insensitive():
+ with pytest.raises(ValueError, match="malicious URL scheme"):
+ _check_dangerous_patterns("JAVASCRIPT:alert(1)", "test")
+
+
+def test_check_dangerous_patterns_onclick():
+ with pytest.raises(ValueError, match="malicious event handler"):
+ _check_dangerous_patterns("onclick=alert(1)", "test")
+
+
+def test_check_dangerous_patterns_onerror():
+ with pytest.raises(ValueError, match="malicious event handler"):
+ _check_dangerous_patterns("onerror = alert(1)", "test")
+
+
+def test_check_dangerous_patterns_onload():
+ with pytest.raises(ValueError, match="malicious event handler"):
+ _check_dangerous_patterns("onload=fetch('x')", "test")
+
+
+# --- _check_sql_patterns tests ---
+
+
+def test_check_sql_patterns_safe_input():
+ _check_sql_patterns("revenue_total", "test")
+
+
+def test_check_sql_patterns_drop_table():
+ with pytest.raises(ValueError, match="unsafe SQL keywords"):
+ _check_sql_patterns("DROP TABLE users", "test")
+
+
+def test_check_sql_patterns_delete():
+ with pytest.raises(ValueError, match="unsafe SQL keywords"):
+ _check_sql_patterns("DELETE FROM users", "test")
+
+
+def test_check_sql_patterns_semicolon():
+ with pytest.raises(ValueError, match="unsafe characters"):
+ _check_sql_patterns("value; other", "test")
+
+
+def test_check_sql_patterns_sql_comment_dash():
+ with pytest.raises(ValueError, match="unsafe characters"):
+ _check_sql_patterns("value -- comment", "test")
+
+
+def test_check_sql_patterns_sql_comment_block():
+ with pytest.raises(ValueError, match="unsafe SQL comment"):
+ _check_sql_patterns("value /* comment */", "test")
+
+
+def test_check_sql_patterns_pipe():
+ with pytest.raises(ValueError, match="unsafe characters"):
+ _check_sql_patterns("value | other", "test")
+
+
+def test_check_sql_patterns_case_insensitive():
+ with pytest.raises(ValueError, match="unsafe SQL keywords"):
+ _check_sql_patterns("drop table users", "test")
+
+
+# --- _remove_dangerous_unicode tests ---
+
+
+def test_remove_dangerous_unicode_plain_text():
+ assert _remove_dangerous_unicode("hello") == "hello"
+
+
+def test_remove_dangerous_unicode_zero_width_space():
+ assert _remove_dangerous_unicode("he\u200bllo") == "hello"
+
+
+def test_remove_dangerous_unicode_zero_width_joiner():
+ assert _remove_dangerous_unicode("he\u200dllo") == "hello"
+
+
+def test_remove_dangerous_unicode_bom():
+ assert _remove_dangerous_unicode("\ufeffhello") == "hello"
+
+
+def test_remove_dangerous_unicode_null_byte():
+ assert _remove_dangerous_unicode("he\x00llo") == "hello"
+
+
+def test_remove_dangerous_unicode_preserves_normal_unicode():
+ assert _remove_dangerous_unicode("café résumé") == "café résumé"
+
+
+# --- sanitize_user_input tests ---
+
+
+def test_sanitize_user_input_plain_text():
+ assert sanitize_user_input("hello", "test") == "hello"
+
+
+def test_sanitize_user_input_preserves_ampersand():
+ assert sanitize_user_input("A & B", "test") == "A & B"
+
+
+def test_sanitize_user_input_strips_html():
+ assert sanitize_user_input("<b>hello</b>", "test") == "hello"
+
+
+def test_sanitize_user_input_none_not_allowed():
+ with pytest.raises(ValueError, match="cannot be empty"):
+ sanitize_user_input(None, "test")
+
+
+def test_sanitize_user_input_none_allowed():
+ assert sanitize_user_input(None, "test", allow_empty=True) is None
+
+
+def test_sanitize_user_input_empty_string_not_allowed():
+ with pytest.raises(ValueError, match="cannot be empty"):
+ sanitize_user_input("", "test")
+
+
+def test_sanitize_user_input_empty_string_allowed():
+ assert sanitize_user_input("", "test", allow_empty=True) is None
+
+
+def test_sanitize_user_input_whitespace_only():
+ with pytest.raises(ValueError, match="cannot be empty"):
+ sanitize_user_input(" ", "test")
+
+
+def test_sanitize_user_input_strips_whitespace():
+ assert sanitize_user_input(" hello ", "test") == "hello"
+
+
+def test_sanitize_user_input_too_long():
+ with pytest.raises(ValueError, match="too long"):
+ sanitize_user_input("a" * 256, "test", max_length=255)
+
+
+def test_sanitize_user_input_max_length_ok():
+ result = sanitize_user_input("a" * 255, "test", max_length=255)
+ assert result == "a" * 255
+
+
+def test_sanitize_user_input_blocks_javascript():
+ with pytest.raises(ValueError, match="malicious URL scheme"):
+ sanitize_user_input("javascript:alert(1)", "test")
+
+
+def test_sanitize_user_input_blocks_event_handler():
+ with pytest.raises(ValueError, match="malicious event handler"):
+ sanitize_user_input("onclick=alert(1)", "test")
+
+
+def test_sanitize_user_input_sql_keywords_not_checked_by_default():
+ result = sanitize_user_input("DROP TABLE", "test")
+ assert result == "DROP TABLE"
+
+
+def test_sanitize_user_input_sql_keywords_checked_when_enabled():
+ with pytest.raises(ValueError, match="unsafe SQL keywords"):
+ sanitize_user_input("DROP TABLE users", "test", check_sql_keywords=True)
+
+
+def test_sanitize_user_input_removes_zero_width_chars():
+ result = sanitize_user_input("hel\u200blo", "test")
+ assert result == "hello"
+
+
+def test_sanitize_user_input_xss_entity_encoded():
+ """Entity-encoded XSS attempts must be neutralized."""
+ result = sanitize_user_input("<script>alert(1)</script>", "test")
+ assert "<script>" not in result
+
+
+def test_sanitize_user_input_entity_encoded_javascript():
+ """Entity-encoded javascript: scheme should be caught after decoding."""
+ with pytest.raises(ValueError, match="malicious URL scheme"):
+ sanitize_user_input("javascript:alert(1)", "test")
+
+
+# --- sanitize_filter_value tests ---
+
+
+def test_sanitize_filter_value_integer():
+ assert sanitize_filter_value(42) == 42
+
+
+def test_sanitize_filter_value_float():
+ assert sanitize_filter_value(3.14) == 3.14
+
+
+def test_sanitize_filter_value_bool():
+ assert sanitize_filter_value(True) is True
+
+
+def test_sanitize_filter_value_plain_string():
+ assert sanitize_filter_value("hello") == "hello"
+
+
+def test_sanitize_filter_value_preserves_ampersand():
+ assert sanitize_filter_value("A & B") == "A & B"
+
+
+def test_sanitize_filter_value_strips_html():
+ assert sanitize_filter_value("<b>hello</b>") == "hello"
+
+
+def test_sanitize_filter_value_too_long():
+ with pytest.raises(ValueError, match="too long"):
+ sanitize_filter_value("a" * 1001)
+
+
+def test_sanitize_filter_value_blocks_javascript():
+ with pytest.raises(ValueError, match="malicious URL scheme"):
+ sanitize_filter_value("javascript:alert(1)")
+
+
+def test_sanitize_filter_value_blocks_xp_cmdshell():
+ with pytest.raises(ValueError, match="malicious SQL procedures"):
+ sanitize_filter_value("xp_cmdshell('dir')")
+
+
+def test_sanitize_filter_value_blocks_sp_executesql():
+ with pytest.raises(ValueError, match="malicious SQL procedures"):
+ sanitize_filter_value("sp_executesql @stmt")
+
+
+def test_sanitize_filter_value_blocks_union_select():
+ with pytest.raises(ValueError, match="malicious SQL patterns"):
+ sanitize_filter_value("' UNION SELECT * FROM users")
+
+
+def test_sanitize_filter_value_blocks_sql_comment():
+ with pytest.raises(ValueError, match="malicious SQL patterns"):
+ sanitize_filter_value("value -- drop")
+
+
+def test_sanitize_filter_value_blocks_shell_semicolon():
+ with pytest.raises(ValueError, match="unsafe shell characters"):
+ sanitize_filter_value("value;rm -rf")
+
+
+def test_sanitize_filter_value_blocks_shell_pipe():
+ with pytest.raises(ValueError, match="unsafe shell characters"):
+ sanitize_filter_value("value|cat /etc/passwd")
+
+
+def test_sanitize_filter_value_blocks_backtick():
+ with pytest.raises(ValueError, match="unsafe shell characters"):
+ sanitize_filter_value("`whoami`")
+
+
+def test_sanitize_filter_value_blocks_hex_encoding():
+ with pytest.raises(ValueError, match="hex encoding"):
+ sanitize_filter_value("\\x41\\x42")
+
+
+def test_sanitize_filter_value_allows_ampersand_alone():
+ """Ampersand is safe in filter values (only dangerous in shell contexts)."""
+ assert sanitize_filter_value("AT&T") == "AT&T"
+
+
+def test_sanitize_filter_value_removes_zero_width_chars():
+ result = sanitize_filter_value("hel\u200blo")
+ assert result == "hello"
+
+
+def test_sanitize_filter_value_xss_entity_encoded():
+ """Entity-encoded XSS in filter values must be neutralized."""
+ result = sanitize_filter_value("<img src=x onerror=alert(1)>")
+ assert "<img" not in result
Review Comment:
<!-- Bito Reply -->
The comment is correct—the listed patterns (' OR ', ' AND ', /*, ; DROP, parentheses, javascript:, event handlers) are all covered by tests in the new test file tests/unit_tests/mcp_service/utils/test_sanitization.py. These tests were added in this PR to ensure the sanitization functions properly block these injection attempts.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] fix(mcp): remove html.escape to fix ampersand display in chart titles [superset]
Posted by "bito-code-review[bot] (via GitHub)" <gi...@apache.org>.
bito-code-review[bot] commented on PR #37186:
URL: https://github.com/apache/superset/pull/37186#issuecomment-3839418005
AI Code Review is in progress (usually takes 3 to 15 minutes unless it's a very large PR).
<details>
<summary>
Bito Usage Guide
</summary>
**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 <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/available-commands" target="_blank" rel="noopener noreferrer">documentation</a> for additional commands.
**Configuration**
This repository uses `Superset` You can customize the agent settings <a href="https://alpha.bito.ai/home/ai-agents/code-review-agent" target="_blank" rel="noopener noreferrer">here</a> or contact your Bito workspace admin at evan@preset.io.
**Documentation & Help**
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/getting-started/install-run-using-bito-cloud/create-or-customize-an-agent-instance" target="_blank" rel="noopener noreferrer">Customize agent settings</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/implementing-custom-code-review-rules" target="_blank" rel="noopener noreferrer">Review rules</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent" target="_blank" rel="noopener noreferrer">General documentation</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/faqs" target="_blank" rel="noopener noreferrer">FAQ</a>
</details>
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] fix(mcp): remove html.escape to fix ampersand display in chart titles [superset]
Posted by "bito-code-review[bot] (via GitHub)" <gi...@apache.org>.
bito-code-review[bot] commented on PR #37186:
URL: https://github.com/apache/superset/pull/37186#issuecomment-3757558000
AI Code Review is in progress (usually takes 3 to 15 minutes unless it's a very large PR).
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] fix(mcp): remove html.escape to fix ampersand display in chart titles [superset]
Posted by "aminghadersohi (via GitHub)" <gi...@apache.org>.
aminghadersohi commented on code in PR #37186:
URL: https://github.com/apache/superset/pull/37186#discussion_r2756346729
##########
tests/unit_tests/mcp_service/utils/test_sanitization.py:
##########
@@ -0,0 +1,411 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import pytest
+
+from superset.mcp_service.utils.sanitization import (
+ _check_dangerous_patterns,
+ _check_sql_patterns,
+ _remove_dangerous_unicode,
+ _strip_html_tags,
+ sanitize_filter_value,
+ sanitize_user_input,
+)
+
+
+# --- _strip_html_tags tests ---
+
+
+def test_strip_html_tags_plain_text():
+ assert _strip_html_tags("hello world") == "hello world"
+
+
+def test_strip_html_tags_preserves_ampersand():
+ assert _strip_html_tags("A & B") == "A & B"
+
+
+def test_strip_html_tags_preserves_multiple_ampersands():
+ assert _strip_html_tags("A & B & C") == "A & B & C"
+
+
+def test_strip_html_tags_strips_bold_tags():
+ assert _strip_html_tags("<b>hello</b>") == "hello"
+
+
+def test_strip_html_tags_strips_script_tags():
+ result = _strip_html_tags("<script>alert(1)</script>")
+ assert "<script>" not in result
+ assert "</script>" not in result
+
+
+def test_strip_html_tags_strips_entity_encoded_script():
+ """Entity-encoded tags must be decoded and stripped, not passed through."""
+ result = _strip_html_tags("<script>alert(1)</script>")
+ assert "<script>" not in result
+ assert "<script>" not in result
+
+
+def test_strip_html_tags_strips_double_encoded_script():
+ """Double-encoded entities must also be decoded and stripped."""
+ result = _strip_html_tags("&lt;script&gt;alert(1)&lt;/script&gt;")
+ assert "<script>" not in result
+ assert "<script>" not in result
+
+
+def test_strip_html_tags_strips_img_onerror():
+ result = _strip_html_tags('<img src=x onerror="alert(1)">')
+ assert "<img" not in result
+ assert "onerror" not in result
+
+
+def test_strip_html_tags_strips_div_tags():
+ assert _strip_html_tags("<div>content</div>") == "content"
+
+
+def test_strip_html_tags_preserves_less_than_in_text():
+ """A bare < not forming a tag should be preserved."""
+ result = _strip_html_tags("5 < 10")
+ assert "5" in result
+ assert "10" in result
+
+
+def test_strip_html_tags_empty_string():
+ assert _strip_html_tags("") == ""
+
+
+def test_strip_html_tags_triple_encoded_script():
+ """Triple-encoded entities must also be decoded and stripped."""
+ result = _strip_html_tags(
+ "&amp;lt;script&amp;gt;alert(1)&amp;lt;/script&amp;gt;"
+ )
+ assert "<script>" not in result
+
+
+def test_strip_html_tags_mixed_encoded_and_raw():
+ """Both raw and entity-encoded tags should be stripped."""
+ result = _strip_html_tags("<b>bold</b> and <i>italic</i>")
+ assert "<b>" not in result
+ assert "<i>" not in result
+ assert "bold" in result
+ assert "italic" in result
+
+
+def test_strip_html_tags_deep_encoding_terminates():
+ """Verify the iterative decode loop terminates on many encoding layers."""
+ value = "<script>alert(1)</script>"
+ for _ in range(10):
+ value = value.replace("&", "&").replace("<", "<").replace(">", ">")
+ result = _strip_html_tags(value)
+ assert "<script>" not in result
+
+
+def test_strip_html_tags_entity_ampersand():
+ """& in input should become & in output."""
+ assert _strip_html_tags("A & B") == "A & B"
+
+
+# --- _check_dangerous_patterns tests ---
+
+
+def test_check_dangerous_patterns_safe_input():
+ _check_dangerous_patterns("hello world", "test")
+
+
+def test_check_dangerous_patterns_javascript_scheme():
+ with pytest.raises(ValueError, match="malicious URL scheme"):
+ _check_dangerous_patterns("javascript:alert(1)", "test")
+
+
+def test_check_dangerous_patterns_vbscript_scheme():
+ with pytest.raises(ValueError, match="malicious URL scheme"):
+ _check_dangerous_patterns("vbscript:MsgBox", "test")
+
+
+def test_check_dangerous_patterns_data_scheme():
+ with pytest.raises(ValueError, match="malicious URL scheme"):
+ _check_dangerous_patterns("data:text/html,<script>", "test")
+
+
+def test_check_dangerous_patterns_case_insensitive():
+ with pytest.raises(ValueError, match="malicious URL scheme"):
+ _check_dangerous_patterns("JAVASCRIPT:alert(1)", "test")
+
+
+def test_check_dangerous_patterns_onclick():
+ with pytest.raises(ValueError, match="malicious event handler"):
+ _check_dangerous_patterns("onclick=alert(1)", "test")
+
+
+def test_check_dangerous_patterns_onerror():
+ with pytest.raises(ValueError, match="malicious event handler"):
+ _check_dangerous_patterns("onerror = alert(1)", "test")
+
+
+def test_check_dangerous_patterns_onload():
+ with pytest.raises(ValueError, match="malicious event handler"):
+ _check_dangerous_patterns("onload=fetch('x')", "test")
+
+
+# --- _check_sql_patterns tests ---
+
+
+def test_check_sql_patterns_safe_input():
+ _check_sql_patterns("revenue_total", "test")
+
+
+def test_check_sql_patterns_drop_table():
+ with pytest.raises(ValueError, match="unsafe SQL keywords"):
+ _check_sql_patterns("DROP TABLE users", "test")
+
+
+def test_check_sql_patterns_delete():
+ with pytest.raises(ValueError, match="unsafe SQL keywords"):
+ _check_sql_patterns("DELETE FROM users", "test")
+
+
+def test_check_sql_patterns_semicolon():
+ with pytest.raises(ValueError, match="unsafe characters"):
+ _check_sql_patterns("value; other", "test")
+
+
+def test_check_sql_patterns_sql_comment_dash():
+ with pytest.raises(ValueError, match="unsafe characters"):
+ _check_sql_patterns("value -- comment", "test")
+
+
+def test_check_sql_patterns_sql_comment_block():
+ with pytest.raises(ValueError, match="unsafe SQL comment"):
+ _check_sql_patterns("value /* comment */", "test")
+
+
+def test_check_sql_patterns_pipe():
+ with pytest.raises(ValueError, match="unsafe characters"):
+ _check_sql_patterns("value | other", "test")
+
+
+def test_check_sql_patterns_case_insensitive():
+ with pytest.raises(ValueError, match="unsafe SQL keywords"):
+ _check_sql_patterns("drop table users", "test")
+
+
+# --- _remove_dangerous_unicode tests ---
+
+
+def test_remove_dangerous_unicode_plain_text():
+ assert _remove_dangerous_unicode("hello") == "hello"
+
+
+def test_remove_dangerous_unicode_zero_width_space():
+ assert _remove_dangerous_unicode("he\u200bllo") == "hello"
+
+
+def test_remove_dangerous_unicode_zero_width_joiner():
+ assert _remove_dangerous_unicode("he\u200dllo") == "hello"
+
+
+def test_remove_dangerous_unicode_bom():
+ assert _remove_dangerous_unicode("\ufeffhello") == "hello"
+
+
+def test_remove_dangerous_unicode_null_byte():
+ assert _remove_dangerous_unicode("he\x00llo") == "hello"
+
+
+def test_remove_dangerous_unicode_preserves_normal_unicode():
+ assert _remove_dangerous_unicode("café résumé") == "café résumé"
+
+
+# --- sanitize_user_input tests ---
+
+
+def test_sanitize_user_input_plain_text():
+ assert sanitize_user_input("hello", "test") == "hello"
+
+
+def test_sanitize_user_input_preserves_ampersand():
+ assert sanitize_user_input("A & B", "test") == "A & B"
+
+
+def test_sanitize_user_input_strips_html():
+ assert sanitize_user_input("<b>hello</b>", "test") == "hello"
+
+
+def test_sanitize_user_input_none_not_allowed():
+ with pytest.raises(ValueError, match="cannot be empty"):
+ sanitize_user_input(None, "test")
+
+
+def test_sanitize_user_input_none_allowed():
+ assert sanitize_user_input(None, "test", allow_empty=True) is None
+
+
+def test_sanitize_user_input_empty_string_not_allowed():
+ with pytest.raises(ValueError, match="cannot be empty"):
+ sanitize_user_input("", "test")
+
+
+def test_sanitize_user_input_empty_string_allowed():
+ assert sanitize_user_input("", "test", allow_empty=True) is None
+
+
+def test_sanitize_user_input_whitespace_only():
+ with pytest.raises(ValueError, match="cannot be empty"):
+ sanitize_user_input(" ", "test")
+
+
+def test_sanitize_user_input_strips_whitespace():
+ assert sanitize_user_input(" hello ", "test") == "hello"
+
+
+def test_sanitize_user_input_too_long():
+ with pytest.raises(ValueError, match="too long"):
+ sanitize_user_input("a" * 256, "test", max_length=255)
+
+
+def test_sanitize_user_input_max_length_ok():
+ result = sanitize_user_input("a" * 255, "test", max_length=255)
+ assert result == "a" * 255
+
+
+def test_sanitize_user_input_blocks_javascript():
+ with pytest.raises(ValueError, match="malicious URL scheme"):
+ sanitize_user_input("javascript:alert(1)", "test")
+
+
+def test_sanitize_user_input_blocks_event_handler():
+ with pytest.raises(ValueError, match="malicious event handler"):
+ sanitize_user_input("onclick=alert(1)", "test")
+
+
+def test_sanitize_user_input_sql_keywords_not_checked_by_default():
+ result = sanitize_user_input("DROP TABLE", "test")
+ assert result == "DROP TABLE"
+
+
+def test_sanitize_user_input_sql_keywords_checked_when_enabled():
+ with pytest.raises(ValueError, match="unsafe SQL keywords"):
+ sanitize_user_input("DROP TABLE users", "test", check_sql_keywords=True)
+
+
+def test_sanitize_user_input_removes_zero_width_chars():
+ result = sanitize_user_input("hel\u200blo", "test")
+ assert result == "hello"
+
+
+def test_sanitize_user_input_xss_entity_encoded():
+ """Entity-encoded XSS attempts must be neutralized."""
+ result = sanitize_user_input("<script>alert(1)</script>", "test")
+ assert "<script>" not in result
+
+
+def test_sanitize_user_input_entity_encoded_javascript():
+ """Entity-encoded javascript: scheme should be caught after decoding."""
+ with pytest.raises(ValueError, match="malicious URL scheme"):
+ sanitize_user_input("javascript:alert(1)", "test")
+
+
+# --- sanitize_filter_value tests ---
+
+
+def test_sanitize_filter_value_integer():
+ assert sanitize_filter_value(42) == 42
+
+
+def test_sanitize_filter_value_float():
+ assert sanitize_filter_value(3.14) == 3.14
+
+
+def test_sanitize_filter_value_bool():
+ assert sanitize_filter_value(True) is True
+
+
+def test_sanitize_filter_value_plain_string():
+ assert sanitize_filter_value("hello") == "hello"
+
+
+def test_sanitize_filter_value_preserves_ampersand():
+ assert sanitize_filter_value("A & B") == "A & B"
+
+
+def test_sanitize_filter_value_strips_html():
+ assert sanitize_filter_value("<b>hello</b>") == "hello"
+
+
+def test_sanitize_filter_value_too_long():
+ with pytest.raises(ValueError, match="too long"):
+ sanitize_filter_value("a" * 1001)
+
+
+def test_sanitize_filter_value_blocks_javascript():
+ with pytest.raises(ValueError, match="malicious URL scheme"):
+ sanitize_filter_value("javascript:alert(1)")
+
+
+def test_sanitize_filter_value_blocks_xp_cmdshell():
+ with pytest.raises(ValueError, match="malicious SQL procedures"):
+ sanitize_filter_value("xp_cmdshell('dir')")
+
+
+def test_sanitize_filter_value_blocks_sp_executesql():
+ with pytest.raises(ValueError, match="malicious SQL procedures"):
+ sanitize_filter_value("sp_executesql @stmt")
+
+
+def test_sanitize_filter_value_blocks_union_select():
+ with pytest.raises(ValueError, match="malicious SQL patterns"):
+ sanitize_filter_value("' UNION SELECT * FROM users")
+
+
+def test_sanitize_filter_value_blocks_sql_comment():
+ with pytest.raises(ValueError, match="malicious SQL patterns"):
+ sanitize_filter_value("value -- drop")
+
+
+def test_sanitize_filter_value_blocks_shell_semicolon():
+ with pytest.raises(ValueError, match="unsafe shell characters"):
+ sanitize_filter_value("value;rm -rf")
+
+
+def test_sanitize_filter_value_blocks_shell_pipe():
+ with pytest.raises(ValueError, match="unsafe shell characters"):
+ sanitize_filter_value("value|cat /etc/passwd")
+
+
+def test_sanitize_filter_value_blocks_backtick():
+ with pytest.raises(ValueError, match="unsafe shell characters"):
+ sanitize_filter_value("`whoami`")
+
+
+def test_sanitize_filter_value_blocks_hex_encoding():
+ with pytest.raises(ValueError, match="hex encoding"):
+ sanitize_filter_value("\\x41\\x42")
+
+
+def test_sanitize_filter_value_allows_ampersand_alone():
+ """Ampersand is safe in filter values (only dangerous in shell contexts)."""
+ assert sanitize_filter_value("AT&T") == "AT&T"
Review Comment:
Claude on behalf of Amin commenting:
This flag was incorrect. The `sanitize_filter_value` function uses a different regex than `_check_sql_patterns` - it specifically excludes `&` from the shell characters check (line 271: `[;|\`$()]` - no `&`). The comment on lines 269-270 explains this intentional decision. The test passes as-is and the `AT&T` value is correctly allowed.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] fix(mcp): remove html.escape to fix ampersand display in chart titles [superset]
Posted by "bito-code-review[bot] (via GitHub)" <gi...@apache.org>.
bito-code-review[bot] commented on PR #37186:
URL: https://github.com/apache/superset/pull/37186#issuecomment-3839685482
AI Code Review is in progress (usually takes 3 to 15 minutes unless it's a very large PR).
<details>
<summary>
Bito Usage Guide
</summary>
**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 <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/available-commands" target="_blank" rel="noopener noreferrer">documentation</a> for additional commands.
**Configuration**
This repository uses `Superset` You can customize the agent settings <a href="https://alpha.bito.ai/home/ai-agents/code-review-agent" target="_blank" rel="noopener noreferrer">here</a> or contact your Bito workspace admin at evan@preset.io.
**Documentation & Help**
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/getting-started/install-run-using-bito-cloud/create-or-customize-an-agent-instance" target="_blank" rel="noopener noreferrer">Customize agent settings</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/implementing-custom-code-review-rules" target="_blank" rel="noopener noreferrer">Review rules</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent" target="_blank" rel="noopener noreferrer">General documentation</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/faqs" target="_blank" rel="noopener noreferrer">FAQ</a>
</details>
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] fix(mcp): remove html.escape to fix ampersand display in chart titles [superset]
Posted by "aminghadersohi (via GitHub)" <gi...@apache.org>.
aminghadersohi commented on code in PR #37186:
URL: https://github.com/apache/superset/pull/37186#discussion_r2756346242
##########
superset/mcp_service/utils/sanitization.py:
##########
@@ -0,0 +1,267 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""
+Centralized sanitization utilities for MCP service user inputs.
+
+This module uses the nh3 library (Rust-based HTML sanitizer) to strip malicious
+HTML tags and protocols from user inputs. nh3 is faster and safer than manual
+regex-based sanitization.
+
+Key features:
+- Strips all HTML tags using nh3.clean() with no allowed tags
+- Blocks dangerous URL schemes (javascript:, vbscript:, data:)
+- Preserves safe text content (e.g., '&' stays as '&', not '&')
+- Additional SQL injection protection for database-facing inputs
+"""
+
+import html
+import re
+
+import nh3
+
+
+def _strip_html_tags(value: str) -> str:
+ """
+ Strip all HTML tags from the input using nh3, then unescape HTML entities.
+
+ Uses nh3's Rust-based sanitizer with no allowed tags, which is
+ faster and safer than manual regex-based tag stripping.
+
+ Since nh3.clean() produces HTML-safe output (escaping & to &, etc.),
+ we use html.unescape() afterward to restore the original characters.
+ This ensures text like "A & B" stays as "A & B", not "A & B".
+
+ Args:
+ value: The input string that may contain HTML
+
+ Returns:
+ String with all HTML tags removed and entities unescaped
+ """
+ # nh3.clean with tags=set() strips ALL HTML tags
+ # url_schemes=set() blocks all URL schemes in any remaining attributes
+ cleaned = nh3.clean(value, tags=set(), url_schemes=set())
+
+ # Unescape HTML entities so & doesn't become &
+ return html.unescape(cleaned)
Review Comment:
Claude on behalf of Amin commenting:
This was a valid catch. I confirmed the issue by testing: `nh3.clean('<script>', tags=set())` passes entities through unchanged, and `html.unescape()` on that output produces `<script>` - a real XSS vector.
Fixed in d50d600946 by replacing `html.unescape(cleaned)` with `cleaned.replace("&", "&")`. This only restores ampersands (so "A & B" displays correctly) without risking angle bracket reintroduction. Added defense-in-depth tests to verify.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] fix(mcp): remove html.escape to fix ampersand display in chart titles [superset]
Posted by "codecov[bot] (via GitHub)" <gi...@apache.org>.
codecov[bot] commented on PR #37186:
URL: https://github.com/apache/superset/pull/37186#issuecomment-3757576824
## [Codecov](https://app.codecov.io/gh/apache/superset/pull/37186?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) Report
:white_check_mark: All modified and coverable lines are covered by tests.
:white_check_mark: Project coverage is 48.20%. Comparing base ([`82d74d1`](https://app.codecov.io/gh/apache/superset/commit/82d74d15ecd9a977427b2c7538d849ec54670bdd?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache)) to head ([`aa5aac1`](https://app.codecov.io/gh/apache/superset/commit/aa5aac18b4bfd9aa9bb381b107b9d383bf859bc1?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache)).
<details><summary>Additional details and impacted files</summary>
```diff
@@ Coverage Diff @@
## master #37186 +/- ##
===========================================
+ Coverage 0 48.20% +48.20%
===========================================
Files 0 642 +642
Lines 0 48077 +48077
Branches 0 5261 +5261
===========================================
+ Hits 0 23175 +23175
- Misses 0 24243 +24243
- Partials 0 659 +659
```
| [Flag](https://app.codecov.io/gh/apache/superset/pull/37186/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) | Coverage Δ | |
|---|---|---|
| [hive](https://app.codecov.io/gh/apache/superset/pull/37186/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) | `42.85% <ø> (?)` | |
| [presto](https://app.codecov.io/gh/apache/superset/pull/37186/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) | `46.42% <ø> (?)` | |
| [python](https://app.codecov.io/gh/apache/superset/pull/37186/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) | `48.19% <ø> (?)` | |
| [unit](https://app.codecov.io/gh/apache/superset/pull/37186/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) | `100.00% <ø> (?)` | |
Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache#carryforward-flags-in-the-pull-request-comment) to find out more.
</details>
[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/apache/superset/pull/37186?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache).
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache).
<details><summary> :rocket: New features to boost your workflow: </summary>
- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] fix(mcp): remove html.escape to fix ampersand display in chart titles [superset]
Posted by "netlify[bot] (via GitHub)" <gi...@apache.org>.
netlify[bot] commented on PR #37186:
URL: https://github.com/apache/superset/pull/37186#issuecomment-3757567065
### <span aria-hidden="true">✅</span> Deploy Preview for *superset-docs-preview* ready!
| Name | Link |
|:-:|------------------------|
|<span aria-hidden="true">🔨</span> Latest commit | aa5aac18b4bfd9aa9bb381b107b9d383bf859bc1 |
|<span aria-hidden="true">🔍</span> Latest deploy log | https://app.netlify.com/projects/superset-docs-preview/deploys/69698b4198259e000871044c |
|<span aria-hidden="true">😎</span> Deploy Preview | [https://deploy-preview-37186--superset-docs-preview.netlify.app](https://deploy-preview-37186--superset-docs-preview.netlify.app) |
|<span aria-hidden="true">📱</span> Preview on mobile | <details><summary> Toggle QR Code... </summary><br /><br /><br /><br />_Use your smartphone camera to open QR code link._</details> |
---
<!-- [superset-docs-preview Preview](https://deploy-preview-37186--superset-docs-preview.netlify.app) -->
_To edit notification comments on pull requests, go to your [Netlify project configuration](https://app.netlify.com/projects/superset-docs-preview/configuration/notifications#deploy-notifications)._
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] fix(mcp): remove html.escape to fix ampersand display in chart titles [superset]
Posted by "bito-code-review[bot] (via GitHub)" <gi...@apache.org>.
bito-code-review[bot] commented on code in PR #37186:
URL: https://github.com/apache/superset/pull/37186#discussion_r2696484775
##########
superset/mcp_service/chart/schemas.py:
##########
@@ -21,7 +21,6 @@
from __future__ import annotations
-import html
import re
Review Comment:
<div>
<div id="suggestion">
<div id="issue"><b>Security: XSS via HTML injection</b></div>
<div id="fix">
Removing html.escape from sanitization methods allows HTML injection in chart names, labels, and fields, creating XSS vulnerabilities when these values are displayed in HTML contexts. The tag-blocking approach is insufficient, as it misses vectors like <img onerror>. Revert to escaping HTML characters for security.
</div>
</div>
<small><i>Code Review Run #eff4b2</i></small>
</div>
---
Should Bito avoid suggestions like this for future reviews? (<a href=https://alpha.bito.ai/home/ai-agents/review-rules>Manage Rules</a>)
- [ ] Yes, avoid them
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] fix(mcp): remove html.escape to fix ampersand display in chart titles [superset]
Posted by "bito-code-review[bot] (via GitHub)" <gi...@apache.org>.
bito-code-review[bot] commented on PR #37186:
URL: https://github.com/apache/superset/pull/37186#issuecomment-3791582503
AI Code Review is in progress (usually takes 3 to 15 minutes unless it's a very large PR).
<details>
<summary>
Bito Usage Guide
</summary>
**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 <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/available-commands" target="_blank" rel="noopener noreferrer">documentation</a> for additional commands.
**Configuration**
This repository uses `Superset` You can customize the agent settings <a href="https://alpha.bito.ai/home/ai-agents/code-review-agent" target="_blank" rel="noopener noreferrer">here</a> or contact your Bito workspace admin at evan@preset.io.
**Documentation & Help**
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/getting-started/install-run-using-bito-cloud/create-or-customize-an-agent-instance" target="_blank" rel="noopener noreferrer">Customize agent settings</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/implementing-custom-code-review-rules" target="_blank" rel="noopener noreferrer">Review rules</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent" target="_blank" rel="noopener noreferrer">General documentation</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/faqs" target="_blank" rel="noopener noreferrer">FAQ</a>
</details>
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] fix(mcp): remove html.escape to fix ampersand display in chart titles [superset]
Posted by "bito-code-review[bot] (via GitHub)" <gi...@apache.org>.
bito-code-review[bot] commented on PR #37186:
URL: https://github.com/apache/superset/pull/37186#issuecomment-3788528774
AI Code Review is in progress (usually takes 3 to 15 minutes unless it's a very large PR).
<details>
<summary>
Bito Usage Guide
</summary>
**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 <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/available-commands" target="_blank" rel="noopener noreferrer">documentation</a> for additional commands.
**Configuration**
This repository uses `Superset` You can customize the agent settings <a href="https://alpha.bito.ai/home/ai-agents/code-review-agent" target="_blank" rel="noopener noreferrer">here</a> or contact your Bito workspace admin at evan@preset.io.
**Documentation & Help**
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/getting-started/install-run-using-bito-cloud/create-or-customize-an-agent-instance" target="_blank" rel="noopener noreferrer">Customize agent settings</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/implementing-custom-code-review-rules" target="_blank" rel="noopener noreferrer">Review rules</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent" target="_blank" rel="noopener noreferrer">General documentation</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/faqs" target="_blank" rel="noopener noreferrer">FAQ</a>
</details>
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] fix(mcp): remove html.escape to fix ampersand display in chart titles [superset]
Posted by "bito-code-review[bot] (via GitHub)" <gi...@apache.org>.
bito-code-review[bot] commented on code in PR #37186:
URL: https://github.com/apache/superset/pull/37186#discussion_r2757786570
##########
tests/unit_tests/mcp_service/utils/test_sanitization.py:
##########
@@ -0,0 +1,480 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import pytest
+
+from superset.mcp_service.utils.sanitization import (
+ _check_dangerous_patterns,
+ _check_sql_patterns,
+ _remove_dangerous_unicode,
+ _strip_html_tags,
+ sanitize_filter_value,
+ sanitize_user_input,
+)
+
+# --- _strip_html_tags tests ---
+
+
+def test_strip_html_tags_plain_text():
+ assert _strip_html_tags("hello world") == "hello world"
+
+
+def test_strip_html_tags_preserves_ampersand():
+ assert _strip_html_tags("A & B") == "A & B"
+
+
+def test_strip_html_tags_preserves_multiple_ampersands():
+ assert _strip_html_tags("A & B & C") == "A & B & C"
+
+
+def test_strip_html_tags_strips_bold_tags():
+ assert _strip_html_tags("<b>hello</b>") == "hello"
+
+
+def test_strip_html_tags_strips_script_tags():
+ result = _strip_html_tags("<script>alert(1)</script>")
+ assert "<script>" not in result
+ assert "</script>" not in result
+
+
+def test_strip_html_tags_strips_entity_encoded_script():
+ """Entity-encoded tags must be decoded and stripped, not passed through."""
+ result = _strip_html_tags("<script>alert(1)</script>")
+ assert "<script>" not in result
+ assert "<script>" not in result
+
+
+def test_strip_html_tags_strips_double_encoded_script():
+ """Double-encoded entities must also be decoded and stripped."""
+ result = _strip_html_tags("&lt;script&gt;alert(1)&lt;/script&gt;")
+ assert "<script>" not in result
+ assert "<script>" not in result
+
+
+def test_strip_html_tags_strips_img_onerror():
+ result = _strip_html_tags('<img src=x onerror="alert(1)">')
+ assert "<img" not in result
+ assert "onerror" not in result
+
+
+def test_strip_html_tags_strips_div_tags():
+ assert _strip_html_tags("<div>content</div>") == "content"
+
+
+def test_strip_html_tags_preserves_less_than_in_text():
+ """A bare < not forming a tag should be preserved."""
+ result = _strip_html_tags("5 < 10")
+ assert "5" in result
+ assert "10" in result
+
+
+def test_strip_html_tags_empty_string():
+ assert _strip_html_tags("") == ""
+
+
+def test_strip_html_tags_triple_encoded_script():
+ """Triple-encoded entities must also be decoded and stripped."""
+ result = _strip_html_tags(
+ "&amp;lt;script&amp;gt;alert(1)&amp;lt;/script&amp;gt;"
+ )
+ assert "<script>" not in result
+
+
+def test_strip_html_tags_mixed_encoded_and_raw():
+ """Both raw and entity-encoded tags should be stripped."""
+ result = _strip_html_tags("<b>bold</b> and <i>italic</i>")
+ assert "<b>" not in result
+ assert "<i>" not in result
+ assert "bold" in result
+ assert "italic" in result
+
+
+def test_strip_html_tags_deep_encoding_terminates():
+ """Verify the iterative decode loop terminates on many encoding layers."""
+ value = "<script>alert(1)</script>"
+ for _ in range(10):
+ value = value.replace("&", "&").replace("<", "<").replace(">", ">")
+ result = _strip_html_tags(value)
+ assert "<script>" not in result
+
+
+def test_strip_html_tags_entity_ampersand():
+ """& in input should become & in output."""
+ assert _strip_html_tags("A & B") == "A & B"
+
+
+# --- _check_dangerous_patterns tests ---
+
+
+def test_check_dangerous_patterns_safe_input():
+ _check_dangerous_patterns("hello world", "test")
+
+
+def test_check_dangerous_patterns_javascript_scheme():
+ with pytest.raises(ValueError, match="malicious URL scheme"):
+ _check_dangerous_patterns("javascript:alert(1)", "test")
+
+
+def test_check_dangerous_patterns_vbscript_scheme():
+ with pytest.raises(ValueError, match="malicious URL scheme"):
+ _check_dangerous_patterns("vbscript:MsgBox", "test")
+
+
+def test_check_dangerous_patterns_data_scheme():
+ with pytest.raises(ValueError, match="malicious URL scheme"):
+ _check_dangerous_patterns("data:text/html,<script>", "test")
+
+
+def test_check_dangerous_patterns_case_insensitive():
+ with pytest.raises(ValueError, match="malicious URL scheme"):
+ _check_dangerous_patterns("JAVASCRIPT:alert(1)", "test")
+
+
+def test_check_dangerous_patterns_onclick():
+ with pytest.raises(ValueError, match="malicious event handler"):
+ _check_dangerous_patterns("onclick=alert(1)", "test")
+
+
+def test_check_dangerous_patterns_onerror():
+ with pytest.raises(ValueError, match="malicious event handler"):
+ _check_dangerous_patterns("onerror = alert(1)", "test")
+
+
+def test_check_dangerous_patterns_onload():
+ with pytest.raises(ValueError, match="malicious event handler"):
+ _check_dangerous_patterns("onload=fetch('x')", "test")
+
+
+# --- _check_sql_patterns tests ---
+
+
+def test_check_sql_patterns_safe_input():
+ _check_sql_patterns("revenue_total", "test")
+
+
+def test_check_sql_patterns_drop_table():
+ with pytest.raises(ValueError, match="unsafe SQL keywords"):
+ _check_sql_patterns("DROP TABLE users", "test")
+
+
+def test_check_sql_patterns_delete():
+ with pytest.raises(ValueError, match="unsafe SQL keywords"):
+ _check_sql_patterns("DELETE FROM users", "test")
+
+
+def test_check_sql_patterns_semicolon():
+ with pytest.raises(ValueError, match="unsafe characters"):
+ _check_sql_patterns("value; other", "test")
+
+
+def test_check_sql_patterns_sql_comment_dash():
+ with pytest.raises(ValueError, match="unsafe characters"):
+ _check_sql_patterns("value -- comment", "test")
+
+
+def test_check_sql_patterns_sql_comment_block():
+ with pytest.raises(ValueError, match="unsafe SQL comment"):
+ _check_sql_patterns("value /* comment */", "test")
+
+
+def test_check_sql_patterns_pipe():
+ with pytest.raises(ValueError, match="unsafe characters"):
+ _check_sql_patterns("value | other", "test")
+
+
+def test_check_sql_patterns_case_insensitive():
+ with pytest.raises(ValueError, match="unsafe SQL keywords"):
+ _check_sql_patterns("drop table users", "test")
+
+
+# --- _remove_dangerous_unicode tests ---
+
+
+def test_remove_dangerous_unicode_plain_text():
+ assert _remove_dangerous_unicode("hello") == "hello"
+
+
+def test_remove_dangerous_unicode_zero_width_space():
+ assert _remove_dangerous_unicode("he\u200bllo") == "hello"
+
+
+def test_remove_dangerous_unicode_zero_width_joiner():
+ assert _remove_dangerous_unicode("he\u200dllo") == "hello"
+
+
+def test_remove_dangerous_unicode_bom():
+ assert _remove_dangerous_unicode("\ufeffhello") == "hello"
+
+
+def test_remove_dangerous_unicode_null_byte():
+ assert _remove_dangerous_unicode("he\x00llo") == "hello"
+
+
+def test_remove_dangerous_unicode_preserves_normal_unicode():
+ assert _remove_dangerous_unicode("café résumé") == "café résumé"
+
+
+# --- sanitize_user_input tests ---
+
+
+def test_sanitize_user_input_plain_text():
+ assert sanitize_user_input("hello", "test") == "hello"
+
+
+def test_sanitize_user_input_preserves_ampersand():
+ assert sanitize_user_input("A & B", "test") == "A & B"
+
+
+def test_sanitize_user_input_strips_html():
+ assert sanitize_user_input("<b>hello</b>", "test") == "hello"
+
+
+def test_sanitize_user_input_none_not_allowed():
+ with pytest.raises(ValueError, match="cannot be empty"):
+ sanitize_user_input(None, "test")
+
+
+def test_sanitize_user_input_none_allowed():
+ assert sanitize_user_input(None, "test", allow_empty=True) is None
+
+
+def test_sanitize_user_input_empty_string_not_allowed():
+ with pytest.raises(ValueError, match="cannot be empty"):
+ sanitize_user_input("", "test")
+
+
+def test_sanitize_user_input_empty_string_allowed():
+ assert sanitize_user_input("", "test", allow_empty=True) is None
+
+
+def test_sanitize_user_input_whitespace_only():
+ with pytest.raises(ValueError, match="cannot be empty"):
+ sanitize_user_input(" ", "test")
+
+
+def test_sanitize_user_input_strips_whitespace():
+ assert sanitize_user_input(" hello ", "test") == "hello"
+
+
+def test_sanitize_user_input_too_long():
+ with pytest.raises(ValueError, match="too long"):
+ sanitize_user_input("a" * 256, "test", max_length=255)
+
+
+def test_sanitize_user_input_max_length_ok():
+ result = sanitize_user_input("a" * 255, "test", max_length=255)
+ assert result == "a" * 255
+
+
+def test_sanitize_user_input_blocks_javascript():
+ with pytest.raises(ValueError, match="malicious URL scheme"):
+ sanitize_user_input("javascript:alert(1)", "test")
+
+
+def test_sanitize_user_input_blocks_event_handler():
+ with pytest.raises(ValueError, match="malicious event handler"):
+ sanitize_user_input("onclick=alert(1)", "test")
+
+
+def test_sanitize_user_input_sql_keywords_not_checked_by_default():
+ result = sanitize_user_input("DROP TABLE", "test")
+ assert result == "DROP TABLE"
+
+
+def test_sanitize_user_input_sql_keywords_checked_when_enabled():
+ with pytest.raises(ValueError, match="unsafe SQL keywords"):
+ sanitize_user_input("DROP TABLE users", "test", check_sql_keywords=True)
+
+
+def test_sanitize_user_input_removes_zero_width_chars():
+ result = sanitize_user_input("hel\u200blo", "test")
+ assert result == "hello"
+
+
+def test_sanitize_user_input_xss_entity_encoded():
+ """Entity-encoded XSS attempts must be neutralized."""
+ result = sanitize_user_input("<script>alert(1)</script>", "test")
+ assert "<script>" not in result
+
+
+def test_sanitize_user_input_entity_encoded_javascript():
+ """Entity-encoded javascript: scheme should be caught after decoding."""
+ with pytest.raises(ValueError, match="malicious URL scheme"):
+ sanitize_user_input("javascript:alert(1)", "test")
+
+
+# --- sanitize_filter_value tests ---
+
+
+def test_sanitize_filter_value_integer():
+ assert sanitize_filter_value(42) == 42
+
+
+def test_sanitize_filter_value_float():
+ assert sanitize_filter_value(3.14) == 3.14
+
+
+def test_sanitize_filter_value_bool():
+ assert sanitize_filter_value(True) is True
+
+
+def test_sanitize_filter_value_plain_string():
+ assert sanitize_filter_value("hello") == "hello"
+
+
+def test_sanitize_filter_value_preserves_ampersand():
+ assert sanitize_filter_value("A & B") == "A & B"
+
+
+def test_sanitize_filter_value_strips_html():
+ assert sanitize_filter_value("<b>hello</b>") == "hello"
+
+
+def test_sanitize_filter_value_too_long():
+ with pytest.raises(ValueError, match="too long"):
+ sanitize_filter_value("a" * 1001)
+
+
+def test_sanitize_filter_value_blocks_javascript():
+ with pytest.raises(ValueError, match="malicious URL scheme"):
+ sanitize_filter_value("javascript:alert(1)")
Review Comment:
<div>
<div id="suggestion">
<div id="issue"><b>Missing Security Test Coverage</b></div>
<div id="fix">
The tests for sanitize_filter_value miss coverage of its security validations for SQL procedures, injection patterns, shell characters, and hex encoding. These checks are critical for preventing malicious inputs in filter values. Add comprehensive test cases to ensure the security logic works as expected.
</div>
</div>
<small><i>Code Review Run #a8b719</i></small>
</div>
---
Should Bito avoid suggestions like this for future reviews? (<a href=https://alpha.bito.ai/home/ai-agents/review-rules>Manage Rules</a>)
- [ ] Yes, avoid them
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] fix(mcp): remove html.escape to fix ampersand display in chart titles [superset]
Posted by "bito-code-review[bot] (via GitHub)" <gi...@apache.org>.
bito-code-review[bot] commented on PR #37186:
URL: https://github.com/apache/superset/pull/37186#issuecomment-3855792325
AI Code Review is in progress (usually takes 3 to 15 minutes unless it's a very large PR).
<details>
<summary>
Bito Usage Guide
</summary>
**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 <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/available-commands" target="_blank" rel="noopener noreferrer">documentation</a> for additional commands.
**Configuration**
This repository uses `Superset` You can customize the agent settings <a href="https://alpha.bito.ai/home/ai-agents/code-review-agent" target="_blank" rel="noopener noreferrer">here</a> or contact your Bito workspace admin at evan@preset.io.
**Documentation & Help**
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/getting-started/install-run-using-bito-cloud/create-or-customize-an-agent-instance" target="_blank" rel="noopener noreferrer">Customize agent settings</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/implementing-custom-code-review-rules" target="_blank" rel="noopener noreferrer">Review rules</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent" target="_blank" rel="noopener noreferrer">General documentation</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/faqs" target="_blank" rel="noopener noreferrer">FAQ</a>
</details>
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] fix(mcp): remove html.escape to fix ampersand display in chart titles [superset]
Posted by "bito-code-review[bot] (via GitHub)" <gi...@apache.org>.
bito-code-review[bot] commented on PR #37186:
URL: https://github.com/apache/superset/pull/37186#issuecomment-3757898649
AI Code Review is in progress (usually takes 3 to 15 minutes unless it's a very large PR).
<details>
<summary>
Bito Usage Guide
</summary>
**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 <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/available-commands" target="_blank" rel="noopener noreferrer">documentation</a> for additional commands.
**Configuration**
This repository uses `Superset` You can customize the agent settings <a href="https://alpha.bito.ai/home/ai-agents/code-review-agent" target="_blank" rel="noopener noreferrer">here</a> or contact your Bito workspace admin at evan@preset.io.
**Documentation & Help**
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/getting-started/install-run-using-bito-cloud/create-or-customize-an-agent-instance" target="_blank" rel="noopener noreferrer">Customize agent settings</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/implementing-custom-code-review-rules" target="_blank" rel="noopener noreferrer">Review rules</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent" target="_blank" rel="noopener noreferrer">General documentation</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/faqs" target="_blank" rel="noopener noreferrer">FAQ</a>
</details>
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] fix(mcp): remove html.escape to fix ampersand display in chart titles [superset]
Posted by "codeant-ai-for-open-source[bot] (via GitHub)" <gi...@apache.org>.
codeant-ai-for-open-source[bot] commented on PR #37186:
URL: https://github.com/apache/superset/pull/37186#issuecomment-3757564429
CodeAnt AI finished reviewing your PR.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] fix(mcp): remove html.escape to fix ampersand display in chart titles [superset]
Posted by "bito-code-review[bot] (via GitHub)" <gi...@apache.org>.
bito-code-review[bot] commented on code in PR #37186:
URL: https://github.com/apache/superset/pull/37186#discussion_r2719821705
##########
tests/unit_tests/mcp_service/utils/test_sanitization.py:
##########
@@ -0,0 +1,410 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import pytest
+
+from superset.mcp_service.utils.sanitization import (
+ _check_dangerous_patterns,
+ _check_sql_patterns,
+ _remove_dangerous_unicode,
+ _strip_html_tags,
+ sanitize_filter_value,
+ sanitize_user_input,
+)
+
+# --- _strip_html_tags tests ---
+
+
+def test_strip_html_tags_plain_text():
+ assert _strip_html_tags("hello world") == "hello world"
+
+
+def test_strip_html_tags_preserves_ampersand():
+ assert _strip_html_tags("A & B") == "A & B"
+
+
+def test_strip_html_tags_preserves_multiple_ampersands():
+ assert _strip_html_tags("A & B & C") == "A & B & C"
+
+
+def test_strip_html_tags_strips_bold_tags():
+ assert _strip_html_tags("<b>hello</b>") == "hello"
+
+
+def test_strip_html_tags_strips_script_tags():
+ result = _strip_html_tags("<script>alert(1)</script>")
+ assert "<script>" not in result
+ assert "</script>" not in result
+
+
+def test_strip_html_tags_strips_entity_encoded_script():
+ """Entity-encoded tags must be decoded and stripped, not passed through."""
+ result = _strip_html_tags("<script>alert(1)</script>")
+ assert "<script>" not in result
+ assert "<script>" not in result
+
+
+def test_strip_html_tags_strips_double_encoded_script():
+ """Double-encoded entities must also be decoded and stripped."""
+ result = _strip_html_tags("&lt;script&gt;alert(1)&lt;/script&gt;")
+ assert "<script>" not in result
+ assert "<script>" not in result
+
+
+def test_strip_html_tags_strips_img_onerror():
+ result = _strip_html_tags('<img src=x onerror="alert(1)">')
+ assert "<img" not in result
+ assert "onerror" not in result
+
+
+def test_strip_html_tags_strips_div_tags():
+ assert _strip_html_tags("<div>content</div>") == "content"
+
+
+def test_strip_html_tags_preserves_less_than_in_text():
+ """A bare < not forming a tag should be preserved."""
+ result = _strip_html_tags("5 < 10")
+ assert "5" in result
+ assert "10" in result
+
+
+def test_strip_html_tags_empty_string():
+ assert _strip_html_tags("") == ""
+
+
+def test_strip_html_tags_triple_encoded_script():
+ """Triple-encoded entities must also be decoded and stripped."""
+ result = _strip_html_tags(
+ "&amp;lt;script&amp;gt;alert(1)&amp;lt;/script&amp;gt;"
+ )
+ assert "<script>" not in result
+
+
+def test_strip_html_tags_mixed_encoded_and_raw():
+ """Both raw and entity-encoded tags should be stripped."""
+ result = _strip_html_tags("<b>bold</b> and <i>italic</i>")
+ assert "<b>" not in result
+ assert "<i>" not in result
+ assert "bold" in result
+ assert "italic" in result
+
+
+def test_strip_html_tags_deep_encoding_terminates():
+ """Verify the iterative decode loop terminates on many encoding layers."""
+ value = "<script>alert(1)</script>"
+ for _ in range(10):
+ value = value.replace("&", "&").replace("<", "<").replace(">", ">")
+ result = _strip_html_tags(value)
+ assert "<script>" not in result
+
+
+def test_strip_html_tags_entity_ampersand():
+ """& in input should become & in output."""
+ assert _strip_html_tags("A & B") == "A & B"
+
+
+# --- _check_dangerous_patterns tests ---
+
+
+def test_check_dangerous_patterns_safe_input():
+ _check_dangerous_patterns("hello world", "test")
+
+
+def test_check_dangerous_patterns_javascript_scheme():
+ with pytest.raises(ValueError, match="malicious URL scheme"):
+ _check_dangerous_patterns("javascript:alert(1)", "test")
+
+
+def test_check_dangerous_patterns_vbscript_scheme():
+ with pytest.raises(ValueError, match="malicious URL scheme"):
+ _check_dangerous_patterns("vbscript:MsgBox", "test")
+
+
+def test_check_dangerous_patterns_data_scheme():
+ with pytest.raises(ValueError, match="malicious URL scheme"):
+ _check_dangerous_patterns("data:text/html,<script>", "test")
+
+
+def test_check_dangerous_patterns_case_insensitive():
+ with pytest.raises(ValueError, match="malicious URL scheme"):
+ _check_dangerous_patterns("JAVASCRIPT:alert(1)", "test")
+
+
+def test_check_dangerous_patterns_onclick():
+ with pytest.raises(ValueError, match="malicious event handler"):
+ _check_dangerous_patterns("onclick=alert(1)", "test")
+
+
+def test_check_dangerous_patterns_onerror():
+ with pytest.raises(ValueError, match="malicious event handler"):
+ _check_dangerous_patterns("onerror = alert(1)", "test")
+
+
+def test_check_dangerous_patterns_onload():
+ with pytest.raises(ValueError, match="malicious event handler"):
+ _check_dangerous_patterns("onload=fetch('x')", "test")
+
+
+# --- _check_sql_patterns tests ---
+
+
+def test_check_sql_patterns_safe_input():
+ _check_sql_patterns("revenue_total", "test")
+
+
+def test_check_sql_patterns_drop_table():
+ with pytest.raises(ValueError, match="unsafe SQL keywords"):
+ _check_sql_patterns("DROP TABLE users", "test")
+
+
+def test_check_sql_patterns_delete():
+ with pytest.raises(ValueError, match="unsafe SQL keywords"):
+ _check_sql_patterns("DELETE FROM users", "test")
+
+
+def test_check_sql_patterns_semicolon():
+ with pytest.raises(ValueError, match="unsafe characters"):
+ _check_sql_patterns("value; other", "test")
+
+
+def test_check_sql_patterns_sql_comment_dash():
+ with pytest.raises(ValueError, match="unsafe characters"):
+ _check_sql_patterns("value -- comment", "test")
+
+
+def test_check_sql_patterns_sql_comment_block():
+ with pytest.raises(ValueError, match="unsafe SQL comment"):
+ _check_sql_patterns("value /* comment */", "test")
+
+
+def test_check_sql_patterns_pipe():
+ with pytest.raises(ValueError, match="unsafe characters"):
+ _check_sql_patterns("value | other", "test")
+
+
+def test_check_sql_patterns_case_insensitive():
+ with pytest.raises(ValueError, match="unsafe SQL keywords"):
+ _check_sql_patterns("drop table users", "test")
+
+
+# --- _remove_dangerous_unicode tests ---
+
+
+def test_remove_dangerous_unicode_plain_text():
+ assert _remove_dangerous_unicode("hello") == "hello"
+
+
+def test_remove_dangerous_unicode_zero_width_space():
+ assert _remove_dangerous_unicode("he\u200bllo") == "hello"
+
+
+def test_remove_dangerous_unicode_zero_width_joiner():
+ assert _remove_dangerous_unicode("he\u200dllo") == "hello"
+
+
+def test_remove_dangerous_unicode_bom():
+ assert _remove_dangerous_unicode("\ufeffhello") == "hello"
+
+
+def test_remove_dangerous_unicode_null_byte():
+ assert _remove_dangerous_unicode("he\x00llo") == "hello"
+
+
+def test_remove_dangerous_unicode_preserves_normal_unicode():
+ assert _remove_dangerous_unicode("café résumé") == "café résumé"
+
+
+# --- sanitize_user_input tests ---
+
+
+def test_sanitize_user_input_plain_text():
+ assert sanitize_user_input("hello", "test") == "hello"
+
+
+def test_sanitize_user_input_preserves_ampersand():
+ assert sanitize_user_input("A & B", "test") == "A & B"
+
+
+def test_sanitize_user_input_strips_html():
+ assert sanitize_user_input("<b>hello</b>", "test") == "hello"
+
+
+def test_sanitize_user_input_none_not_allowed():
+ with pytest.raises(ValueError, match="cannot be empty"):
+ sanitize_user_input(None, "test")
+
+
+def test_sanitize_user_input_none_allowed():
+ assert sanitize_user_input(None, "test", allow_empty=True) is None
+
+
+def test_sanitize_user_input_empty_string_not_allowed():
+ with pytest.raises(ValueError, match="cannot be empty"):
+ sanitize_user_input("", "test")
+
+
+def test_sanitize_user_input_empty_string_allowed():
+ assert sanitize_user_input("", "test", allow_empty=True) is None
+
+
+def test_sanitize_user_input_whitespace_only():
+ with pytest.raises(ValueError, match="cannot be empty"):
+ sanitize_user_input(" ", "test")
+
+
+def test_sanitize_user_input_strips_whitespace():
+ assert sanitize_user_input(" hello ", "test") == "hello"
+
+
+def test_sanitize_user_input_too_long():
+ with pytest.raises(ValueError, match="too long"):
+ sanitize_user_input("a" * 256, "test", max_length=255)
+
+
+def test_sanitize_user_input_max_length_ok():
+ result = sanitize_user_input("a" * 255, "test", max_length=255)
+ assert result == "a" * 255
+
+
+def test_sanitize_user_input_blocks_javascript():
+ with pytest.raises(ValueError, match="malicious URL scheme"):
+ sanitize_user_input("javascript:alert(1)", "test")
+
+
+def test_sanitize_user_input_blocks_event_handler():
+ with pytest.raises(ValueError, match="malicious event handler"):
+ sanitize_user_input("onclick=alert(1)", "test")
+
+
+def test_sanitize_user_input_sql_keywords_not_checked_by_default():
+ result = sanitize_user_input("DROP TABLE", "test")
+ assert result == "DROP TABLE"
+
+
+def test_sanitize_user_input_sql_keywords_checked_when_enabled():
+ with pytest.raises(ValueError, match="unsafe SQL keywords"):
+ sanitize_user_input("DROP TABLE users", "test", check_sql_keywords=True)
+
+
+def test_sanitize_user_input_removes_zero_width_chars():
+ result = sanitize_user_input("hel\u200blo", "test")
+ assert result == "hello"
+
+
+def test_sanitize_user_input_xss_entity_encoded():
+ """Entity-encoded XSS attempts must be neutralized."""
+ result = sanitize_user_input("<script>alert(1)</script>", "test")
+ assert "<script>" not in result
+
+
+def test_sanitize_user_input_entity_encoded_javascript():
+ """Entity-encoded javascript: scheme should be caught after decoding."""
+ with pytest.raises(ValueError, match="malicious URL scheme"):
+ sanitize_user_input("javascript:alert(1)", "test")
+
+
+# --- sanitize_filter_value tests ---
+
+
+def test_sanitize_filter_value_integer():
+ assert sanitize_filter_value(42) == 42
+
+
+def test_sanitize_filter_value_float():
+ assert sanitize_filter_value(3.14) == 3.14
+
+
+def test_sanitize_filter_value_bool():
+ assert sanitize_filter_value(True) is True
+
+
+def test_sanitize_filter_value_plain_string():
+ assert sanitize_filter_value("hello") == "hello"
+
+
+def test_sanitize_filter_value_preserves_ampersand():
+ assert sanitize_filter_value("A & B") == "A & B"
+
+
+def test_sanitize_filter_value_strips_html():
+ assert sanitize_filter_value("<b>hello</b>") == "hello"
+
+
+def test_sanitize_filter_value_too_long():
+ with pytest.raises(ValueError, match="too long"):
+ sanitize_filter_value("a" * 1001)
+
+
+def test_sanitize_filter_value_blocks_javascript():
+ with pytest.raises(ValueError, match="malicious URL scheme"):
+ sanitize_filter_value("javascript:alert(1)")
+
+
+def test_sanitize_filter_value_blocks_xp_cmdshell():
+ with pytest.raises(ValueError, match="malicious SQL procedures"):
+ sanitize_filter_value("xp_cmdshell('dir')")
+
+
+def test_sanitize_filter_value_blocks_sp_executesql():
+ with pytest.raises(ValueError, match="malicious SQL procedures"):
+ sanitize_filter_value("sp_executesql @stmt")
+
+
+def test_sanitize_filter_value_blocks_union_select():
+ with pytest.raises(ValueError, match="malicious SQL patterns"):
+ sanitize_filter_value("' UNION SELECT * FROM users")
+
+
+def test_sanitize_filter_value_blocks_sql_comment():
+ with pytest.raises(ValueError, match="malicious SQL patterns"):
+ sanitize_filter_value("value -- drop")
+
+
+def test_sanitize_filter_value_blocks_shell_semicolon():
+ with pytest.raises(ValueError, match="unsafe shell characters"):
+ sanitize_filter_value("value;rm -rf")
+
+
+def test_sanitize_filter_value_blocks_shell_pipe():
+ with pytest.raises(ValueError, match="unsafe shell characters"):
+ sanitize_filter_value("value|cat /etc/passwd")
+
+
+def test_sanitize_filter_value_blocks_backtick():
+ with pytest.raises(ValueError, match="unsafe shell characters"):
+ sanitize_filter_value("`whoami`")
+
+
+def test_sanitize_filter_value_blocks_hex_encoding():
+ with pytest.raises(ValueError, match="hex encoding"):
+ sanitize_filter_value("\\x41\\x42")
+
+
+def test_sanitize_filter_value_allows_ampersand_alone():
+ """Ampersand is safe in filter values (only dangerous in shell contexts)."""
+ assert sanitize_filter_value("AT&T") == "AT&T"
+
+
+def test_sanitize_filter_value_removes_zero_width_chars():
+ result = sanitize_filter_value("hel\u200blo")
+ assert result == "hello"
+
+
+def test_sanitize_filter_value_xss_entity_encoded():
+ """Entity-encoded XSS in filter values must be neutralized."""
+ result = sanitize_filter_value("<img src=x onerror=alert(1)>")
+ assert "<img" not in result
Review Comment:
<div>
<div id="suggestion">
<div id="issue"><b>Incomplete security test coverage</b></div>
<div id="fix">
The added tests cover some security checks in sanitize_filter_value but miss key patterns like ' OR ' SQL injection, ' AND ' SQL injection, /* comments, ; DROP statements, ( and ) shell characters, javascript: URLs, and event handlers. This leaves untested logic that could hide security bugs.
</div>
</div>
<small><i>Code Review Run #ab1579</i></small>
</div>
---
Should Bito avoid suggestions like this for future reviews? (<a href=https://alpha.bito.ai/home/ai-agents/review-rules>Manage Rules</a>)
- [ ] Yes, avoid them
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] fix(mcp): remove html.escape to fix ampersand display in chart titles [superset]
Posted by "rusackas (via GitHub)" <gi...@apache.org>.
rusackas merged PR #37186:
URL: https://github.com/apache/superset/pull/37186
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] fix(mcp): remove html.escape to fix ampersand display in chart titles [superset]
Posted by "bito-code-review[bot] (via GitHub)" <gi...@apache.org>.
bito-code-review[bot] commented on PR #37186:
URL: https://github.com/apache/superset/pull/37186#issuecomment-3837840321
AI Code Review is in progress (usually takes 3 to 15 minutes unless it's a very large PR).
<details>
<summary>
Bito Usage Guide
</summary>
**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 <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/available-commands" target="_blank" rel="noopener noreferrer">documentation</a> for additional commands.
**Configuration**
This repository uses `Superset` You can customize the agent settings <a href="https://alpha.bito.ai/home/ai-agents/code-review-agent" target="_blank" rel="noopener noreferrer">here</a> or contact your Bito workspace admin at evan@preset.io.
**Documentation & Help**
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/getting-started/install-run-using-bito-cloud/create-or-customize-an-agent-instance" target="_blank" rel="noopener noreferrer">Customize agent settings</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/implementing-custom-code-review-rules" target="_blank" rel="noopener noreferrer">Review rules</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent" target="_blank" rel="noopener noreferrer">General documentation</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/faqs" target="_blank" rel="noopener noreferrer">FAQ</a>
</details>
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] fix(mcp): remove html.escape to fix ampersand display in chart titles [superset]
Posted by "codeant-ai-for-open-source[bot] (via GitHub)" <gi...@apache.org>.
codeant-ai-for-open-source[bot] commented on PR #37186:
URL: https://github.com/apache/superset/pull/37186#issuecomment-3757557780
CodeAnt AI is reviewing your PR.
---
### Thanks for using CodeAnt! 🎉
We're free for open-source projects. if you're enjoying it, help us grow by sharing.
[Share on X](https://twitter.com/intent/tweet?text=Just%20tried%20%40CodeAntAI%20for%20automated%20code%20review%20and%20I%27m%20impressed%21%20Free%20for%20open%20source%20with%20a%20free%20trial%20for%20private%20repos.%20Worth%20checking%20out%3A&url=https%3A//codeant.ai) ·
[Reddit](https://www.reddit.com/submit?title=Check%20out%20CodeAnt%20for%20automated%20code%20review&text=Just%20tried%20CodeAnt%20for%20automated%20code%20review%20and%20I%27m%20impressed%21%20Free%20for%20open%20source%20with%20a%20free%20trial%20for%20private%20repos.%20Worth%20checking%20out%3A%20https%3A//codeant.ai) ·
[LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fcodeant.ai&mini=true&title=Check%20out%20CodeAnt%20for%20automated%20code%20review&summary=Just%20tried%20CodeAnt%20for%20automated%20code%20review%20and%20I%27m%20impressed%21%20Free%20for%20open%20source%20with%20a%20free%20trial%20for%20private%20repos)
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] fix(mcp): remove html.escape to fix ampersand display in chart titles [superset]
Posted by "codeant-ai-for-open-source[bot] (via GitHub)" <gi...@apache.org>.
codeant-ai-for-open-source[bot] commented on PR #37186:
URL: https://github.com/apache/superset/pull/37186#issuecomment-3775942441
CodeAnt AI is running Incremental review
---
### Thanks for using CodeAnt! 🎉
We're free for open-source projects. if you're enjoying it, help us grow by sharing.
[Share on X](https://twitter.com/intent/tweet?text=Just%20tried%20%40CodeAntAI%20for%20automated%20code%20review%20and%20I%27m%20impressed%21%20Free%20for%20open%20source%20with%20a%20free%20trial%20for%20private%20repos.%20Worth%20checking%20out%3A&url=https%3A//codeant.ai) ·
[Reddit](https://www.reddit.com/submit?title=Check%20out%20CodeAnt%20for%20automated%20code%20review&text=Just%20tried%20CodeAnt%20for%20automated%20code%20review%20and%20I%27m%20impressed%21%20Free%20for%20open%20source%20with%20a%20free%20trial%20for%20private%20repos.%20Worth%20checking%20out%3A%20https%3A//codeant.ai) ·
[LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fcodeant.ai&mini=true&title=Check%20out%20CodeAnt%20for%20automated%20code%20review&summary=Just%20tried%20CodeAnt%20for%20automated%20code%20review%20and%20I%27m%20impressed%21%20Free%20for%20open%20source%20with%20a%20free%20trial%20for%20private%20repos)
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] fix(mcp): remove html.escape to fix ampersand display in chart titles [superset]
Posted by "codeant-ai-for-open-source[bot] (via GitHub)" <gi...@apache.org>.
codeant-ai-for-open-source[bot] commented on code in PR #37186:
URL: https://github.com/apache/superset/pull/37186#discussion_r2756341258
##########
superset/mcp_service/utils/sanitization.py:
##########
@@ -0,0 +1,283 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""
+Centralized sanitization utilities for MCP service user inputs.
+
+This module uses the nh3 library (Rust-based HTML sanitizer) to strip malicious
+HTML tags and protocols from user inputs. nh3 is faster and safer than manual
+regex-based sanitization.
+
+Key features:
+- Strips all HTML tags using nh3.clean() with no allowed tags
+- Blocks dangerous URL schemes (javascript:, vbscript:, data:)
+- Preserves safe text content (e.g., '&' stays as '&', not '&')
+- Additional SQL injection protection for database-facing inputs
+"""
+
+import html
+import re
+
+import nh3
+
+
+def _strip_html_tags(value: str) -> str:
+ """
+ Strip all HTML tags from the input using nh3.
+
+ Decodes all layers of HTML entity encoding BEFORE passing to nh3,
+ so entity-encoded tags (e.g., ``<script>``) are decoded into
+ real tags that nh3 can detect and strip. After nh3 removes all tags,
+ we only restore ``&`` back to ``&`` (not a full html.unescape)
+ to preserve ampersands in display text without risking XSS from
+ re-introducing angle brackets or other HTML-significant characters.
+
+ Args:
+ value: The input string that may contain HTML
+
+ Returns:
+ String with all HTML tags removed and ampersands preserved
+ """
+ # Decode all layers of HTML entity encoding to prevent bypass
+ # via entity-encoded tags (e.g., <script> or &lt;script&gt;)
+ # The loop terminates when unescape produces no change (idempotent on decoded text).
+ # Max iterations cap provides defense-in-depth against pathological inputs.
+ max_iterations = 100
+ decoded = value
+ prev = None
+ iterations = 0
+ while prev != decoded and iterations < max_iterations:
+ prev = decoded
+ decoded = html.unescape(decoded)
+ iterations += 1
+
+ # nh3.clean with tags=set() strips ALL HTML tags from the decoded input
+ # url_schemes=set() blocks all URL schemes in any remaining attributes
+ cleaned = nh3.clean(decoded, tags=set(), url_schemes=set())
+
+ # Only restore & → & to preserve ampersands in display text (e.g. "A & B").
+ # Do NOT use html.unescape() here: nh3 may pass through HTML entities from
+ # the input (e.g. <script>), and a full unescape would re-introduce
+ # raw angle brackets, creating an XSS vector.
+ return cleaned.replace("&", "&")
+
+
+def _check_dangerous_patterns(value: str, field_name: str) -> None:
+ """
+ Check for dangerous patterns that nh3 doesn't catch.
+
+ This includes URL schemes in plain text (not in HTML attributes),
+ event handler patterns, and dangerous Unicode characters.
+
+ Args:
+ value: The input string to check
+ field_name: Name of the field (for error messages)
+
+ Raises:
+ ValueError: If dangerous patterns are found
+ """
+ # Block dangerous URL schemes in plain text (word boundary check)
+ if re.search(r"\b(javascript|vbscript|data):", value, re.IGNORECASE):
+ raise ValueError(f"{field_name} contains potentially malicious URL scheme")
+
+ # Block event handler patterns (onclick=, onerror=, etc.)
+ if re.search(r"on\w+\s*=", value, re.IGNORECASE):
+ raise ValueError(f"{field_name} contains potentially malicious event handler")
+
+
+def _check_sql_patterns(value: str, field_name: str) -> None:
+ """
+ Check for SQL injection patterns.
Review Comment:
**Suggestion:** The SQL/shell metacharacter check currently treats a bare ampersand '&' as unsafe, which will cause false validation failures for otherwise safe user inputs (e.g., labels or names containing '&') when SQL checks are enabled, and it is also inconsistent with the more permissive handling of '&' in filter values described in the comments. Removing '&' from this character class keeps strong protection against actual shell/metacharacter injection while aligning behavior with the intended allowance of ampersands in text. [logic error]
<details>
<summary><b>Severity Level:</b> Major ⚠️</summary>
```mdx
- ❌ Column/field validation rejects names with '&'.
- ⚠️ MCP chart/schema creation fails during validation.
- ⚠️ Inconsistent behavior versus filter value handling.
```
</details>
```suggestion
# Allow '&' here to avoid blocking safe text like "A & B"; it is not
# dangerous in SQL parameters and is already handled appropriately elsewhere.
if re.search(r"[;|$`]|--", value):
```
<details>
<summary><b>Steps of Reproduction ✅ </b></summary>
```mdx
1. Call sanitize_user_input(..., check_sql_keywords=True) in
`superset/mcp_service/utils/sanitization.py` (function defined in this file). The
`check_sql_keywords` flag triggers the SQL/shell checks via `_check_sql_patterns()`
(function in same file).
2. Pass a realistic column/field name that contains an ampersand, e.g. `"Revenue &
Expenses"`. This value is stripped of HTML and then `_check_sql_patterns()` runs the
metacharacters check at the lines with `re.search(r"[;|&$`]|--", value)` (present in
`_check_sql_patterns` in the same file).
3. The regex matches the `&` and `_check_sql_patterns()` raises ValueError: `"{field_name}
contains potentially unsafe characters"`, causing input validation to fail and callers to
reject the name.
4. Result: calling code that enables SQL keyword checking (e.g., column-name validation
paths in MCP chart/schema generation) will reject otherwise legitimate names containing
`&`. This is inconsistent with sanitize_filter_value which explicitly allows `&` in filter
text.
```
</details>
<details>
<summary><b>Prompt for AI Agent 🤖 </b></summary>
```mdx
This is a comment left during a code review.
**Path:** superset/mcp_service/utils/sanitization.py
**Line:** 104:104
**Comment:**
*Logic Error: The SQL/shell metacharacter check currently treats a bare ampersand '&' as unsafe, which will cause false validation failures for otherwise safe user inputs (e.g., labels or names containing '&') when SQL checks are enabled, and it is also inconsistent with the more permissive handling of '&' in filter values described in the comments. Removing '&' from this character class keeps strong protection against actual shell/metacharacter injection while aligning behavior with the intended allowance of ampersands in text.
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.
```
</details>
##########
tests/unit_tests/mcp_service/utils/test_sanitization.py:
##########
@@ -0,0 +1,476 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import pytest
+
+from superset.mcp_service.utils.sanitization import (
+ _check_dangerous_patterns,
+ _check_sql_patterns,
+ _remove_dangerous_unicode,
+ _strip_html_tags,
+ sanitize_filter_value,
+ sanitize_user_input,
+)
+
+# --- _strip_html_tags tests ---
+
+
+def test_strip_html_tags_plain_text():
+ assert _strip_html_tags("hello world") == "hello world"
+
+
+def test_strip_html_tags_preserves_ampersand():
+ assert _strip_html_tags("A & B") == "A & B"
+
+
+def test_strip_html_tags_preserves_multiple_ampersands():
+ assert _strip_html_tags("A & B & C") == "A & B & C"
+
+
+def test_strip_html_tags_strips_bold_tags():
+ assert _strip_html_tags("<b>hello</b>") == "hello"
+
+
+def test_strip_html_tags_strips_script_tags():
+ result = _strip_html_tags("<script>alert(1)</script>")
+ assert "<script>" not in result
+ assert "</script>" not in result
+
+
+def test_strip_html_tags_strips_entity_encoded_script():
+ """Entity-encoded tags must be decoded and stripped, not passed through."""
+ result = _strip_html_tags("<script>alert(1)</script>")
+ assert "<script>" not in result
+ assert "<script>" not in result
+
+
+def test_strip_html_tags_strips_double_encoded_script():
+ """Double-encoded entities must also be decoded and stripped."""
+ result = _strip_html_tags("&lt;script&gt;alert(1)&lt;/script&gt;")
+ assert "<script>" not in result
+ assert "<script>" not in result
+
+
+def test_strip_html_tags_strips_img_onerror():
+ result = _strip_html_tags('<img src=x onerror="alert(1)">')
+ assert "<img" not in result
+ assert "onerror" not in result
+
+
+def test_strip_html_tags_strips_div_tags():
+ assert _strip_html_tags("<div>content</div>") == "content"
+
+
+def test_strip_html_tags_preserves_less_than_in_text():
+ """A bare < not forming a tag should be preserved."""
+ result = _strip_html_tags("5 < 10")
+ assert "5" in result
+ assert "10" in result
+
+
+def test_strip_html_tags_empty_string():
+ assert _strip_html_tags("") == ""
+
+
+def test_strip_html_tags_triple_encoded_script():
+ """Triple-encoded entities must also be decoded and stripped."""
+ result = _strip_html_tags(
+ "&amp;lt;script&amp;gt;alert(1)&amp;lt;/script&amp;gt;"
+ )
+ assert "<script>" not in result
+
+
+def test_strip_html_tags_mixed_encoded_and_raw():
+ """Both raw and entity-encoded tags should be stripped."""
+ result = _strip_html_tags("<b>bold</b> and <i>italic</i>")
+ assert "<b>" not in result
+ assert "<i>" not in result
+ assert "bold" in result
+ assert "italic" in result
+
+
+def test_strip_html_tags_deep_encoding_terminates():
+ """Verify the iterative decode loop terminates on many encoding layers."""
+ value = "<script>alert(1)</script>"
+ for _ in range(10):
+ value = value.replace("&", "&").replace("<", "<").replace(">", ">")
+ result = _strip_html_tags(value)
+ assert "<script>" not in result
+
+
+def test_strip_html_tags_entity_ampersand():
+ """& in input should become & in output."""
+ assert _strip_html_tags("A & B") == "A & B"
+
+
+# --- _check_dangerous_patterns tests ---
+
+
+def test_check_dangerous_patterns_safe_input():
+ _check_dangerous_patterns("hello world", "test")
+
+
+def test_check_dangerous_patterns_javascript_scheme():
+ with pytest.raises(ValueError, match="malicious URL scheme"):
+ _check_dangerous_patterns("javascript:alert(1)", "test")
+
+
+def test_check_dangerous_patterns_vbscript_scheme():
+ with pytest.raises(ValueError, match="malicious URL scheme"):
+ _check_dangerous_patterns("vbscript:MsgBox", "test")
+
+
+def test_check_dangerous_patterns_data_scheme():
+ with pytest.raises(ValueError, match="malicious URL scheme"):
+ _check_dangerous_patterns("data:text/html,<script>", "test")
+
+
+def test_check_dangerous_patterns_case_insensitive():
+ with pytest.raises(ValueError, match="malicious URL scheme"):
+ _check_dangerous_patterns("JAVASCRIPT:alert(1)", "test")
+
+
+def test_check_dangerous_patterns_onclick():
+ with pytest.raises(ValueError, match="malicious event handler"):
+ _check_dangerous_patterns("onclick=alert(1)", "test")
+
+
+def test_check_dangerous_patterns_onerror():
+ with pytest.raises(ValueError, match="malicious event handler"):
+ _check_dangerous_patterns("onerror = alert(1)", "test")
+
+
+def test_check_dangerous_patterns_onload():
+ with pytest.raises(ValueError, match="malicious event handler"):
+ _check_dangerous_patterns("onload=fetch('x')", "test")
+
+
+# --- _check_sql_patterns tests ---
+
+
+def test_check_sql_patterns_safe_input():
+ _check_sql_patterns("revenue_total", "test")
+
+
+def test_check_sql_patterns_drop_table():
+ with pytest.raises(ValueError, match="unsafe SQL keywords"):
+ _check_sql_patterns("DROP TABLE users", "test")
+
+
+def test_check_sql_patterns_delete():
+ with pytest.raises(ValueError, match="unsafe SQL keywords"):
+ _check_sql_patterns("DELETE FROM users", "test")
+
+
+def test_check_sql_patterns_semicolon():
+ with pytest.raises(ValueError, match="unsafe characters"):
+ _check_sql_patterns("value; other", "test")
+
+
+def test_check_sql_patterns_sql_comment_dash():
+ with pytest.raises(ValueError, match="unsafe characters"):
+ _check_sql_patterns("value -- comment", "test")
+
+
+def test_check_sql_patterns_sql_comment_block():
+ with pytest.raises(ValueError, match="unsafe SQL comment"):
+ _check_sql_patterns("value /* comment */", "test")
+
+
+def test_check_sql_patterns_pipe():
+ with pytest.raises(ValueError, match="unsafe characters"):
+ _check_sql_patterns("value | other", "test")
+
+
+def test_check_sql_patterns_case_insensitive():
+ with pytest.raises(ValueError, match="unsafe SQL keywords"):
+ _check_sql_patterns("drop table users", "test")
+
+
+# --- _remove_dangerous_unicode tests ---
+
+
+def test_remove_dangerous_unicode_plain_text():
+ assert _remove_dangerous_unicode("hello") == "hello"
+
+
+def test_remove_dangerous_unicode_zero_width_space():
+ assert _remove_dangerous_unicode("he\u200bllo") == "hello"
+
+
+def test_remove_dangerous_unicode_zero_width_joiner():
+ assert _remove_dangerous_unicode("he\u200dllo") == "hello"
+
+
+def test_remove_dangerous_unicode_bom():
+ assert _remove_dangerous_unicode("\ufeffhello") == "hello"
+
+
+def test_remove_dangerous_unicode_null_byte():
+ assert _remove_dangerous_unicode("he\x00llo") == "hello"
+
+
+def test_remove_dangerous_unicode_preserves_normal_unicode():
+ assert _remove_dangerous_unicode("café résumé") == "café résumé"
+
+
+# --- sanitize_user_input tests ---
+
+
+def test_sanitize_user_input_plain_text():
+ assert sanitize_user_input("hello", "test") == "hello"
+
+
+def test_sanitize_user_input_preserves_ampersand():
+ assert sanitize_user_input("A & B", "test") == "A & B"
+
+
+def test_sanitize_user_input_strips_html():
+ assert sanitize_user_input("<b>hello</b>", "test") == "hello"
+
+
+def test_sanitize_user_input_none_not_allowed():
+ with pytest.raises(ValueError, match="cannot be empty"):
+ sanitize_user_input(None, "test")
+
+
+def test_sanitize_user_input_none_allowed():
+ assert sanitize_user_input(None, "test", allow_empty=True) is None
+
+
+def test_sanitize_user_input_empty_string_not_allowed():
+ with pytest.raises(ValueError, match="cannot be empty"):
+ sanitize_user_input("", "test")
+
+
+def test_sanitize_user_input_empty_string_allowed():
+ assert sanitize_user_input("", "test", allow_empty=True) is None
+
+
+def test_sanitize_user_input_whitespace_only():
+ with pytest.raises(ValueError, match="cannot be empty"):
+ sanitize_user_input(" ", "test")
+
+
+def test_sanitize_user_input_strips_whitespace():
+ assert sanitize_user_input(" hello ", "test") == "hello"
+
+
+def test_sanitize_user_input_too_long():
+ with pytest.raises(ValueError, match="too long"):
+ sanitize_user_input("a" * 256, "test", max_length=255)
+
+
+def test_sanitize_user_input_max_length_ok():
+ result = sanitize_user_input("a" * 255, "test", max_length=255)
+ assert result == "a" * 255
+
+
+def test_sanitize_user_input_blocks_javascript():
+ with pytest.raises(ValueError, match="malicious URL scheme"):
+ sanitize_user_input("javascript:alert(1)", "test")
+
+
+def test_sanitize_user_input_blocks_event_handler():
+ with pytest.raises(ValueError, match="malicious event handler"):
+ sanitize_user_input("onclick=alert(1)", "test")
+
+
+def test_sanitize_user_input_sql_keywords_not_checked_by_default():
+ result = sanitize_user_input("DROP TABLE", "test")
+ assert result == "DROP TABLE"
+
+
+def test_sanitize_user_input_sql_keywords_checked_when_enabled():
+ with pytest.raises(ValueError, match="unsafe SQL keywords"):
+ sanitize_user_input("DROP TABLE users", "test", check_sql_keywords=True)
+
+
+def test_sanitize_user_input_removes_zero_width_chars():
+ result = sanitize_user_input("hel\u200blo", "test")
+ assert result == "hello"
+
+
+def test_sanitize_user_input_xss_entity_encoded():
+ """Entity-encoded XSS attempts must be neutralized."""
+ result = sanitize_user_input("<script>alert(1)</script>", "test")
+ assert "<script>" not in result
+
+
+def test_sanitize_user_input_entity_encoded_javascript():
+ """Entity-encoded javascript: scheme should be caught after decoding."""
+ with pytest.raises(ValueError, match="malicious URL scheme"):
+ sanitize_user_input("javascript:alert(1)", "test")
+
+
+# --- sanitize_filter_value tests ---
+
+
+def test_sanitize_filter_value_integer():
+ assert sanitize_filter_value(42) == 42
+
+
+def test_sanitize_filter_value_float():
+ assert sanitize_filter_value(3.14) == 3.14
+
+
+def test_sanitize_filter_value_bool():
+ assert sanitize_filter_value(True) is True
+
+
+def test_sanitize_filter_value_plain_string():
+ assert sanitize_filter_value("hello") == "hello"
+
+
+def test_sanitize_filter_value_preserves_ampersand():
+ assert sanitize_filter_value("A & B") == "A & B"
+
+
+def test_sanitize_filter_value_strips_html():
+ assert sanitize_filter_value("<b>hello</b>") == "hello"
+
+
+def test_sanitize_filter_value_too_long():
+ with pytest.raises(ValueError, match="too long"):
+ sanitize_filter_value("a" * 1001)
+
+
+def test_sanitize_filter_value_blocks_javascript():
+ with pytest.raises(ValueError, match="malicious URL scheme"):
+ sanitize_filter_value("javascript:alert(1)")
+
+
+def test_sanitize_filter_value_blocks_xp_cmdshell():
+ with pytest.raises(ValueError, match="malicious SQL procedures"):
+ sanitize_filter_value("xp_cmdshell('dir')")
+
+
+def test_sanitize_filter_value_blocks_sp_executesql():
+ with pytest.raises(ValueError, match="malicious SQL procedures"):
+ sanitize_filter_value("sp_executesql @stmt")
+
+
+def test_sanitize_filter_value_blocks_union_select():
+ with pytest.raises(ValueError, match="malicious SQL patterns"):
+ sanitize_filter_value("' UNION SELECT * FROM users")
+
+
+def test_sanitize_filter_value_blocks_sql_comment():
+ with pytest.raises(ValueError, match="malicious SQL patterns"):
+ sanitize_filter_value("value -- drop")
+
+
+def test_sanitize_filter_value_blocks_shell_semicolon():
+ with pytest.raises(ValueError, match="unsafe shell characters"):
+ sanitize_filter_value("value;rm -rf")
+
+
+def test_sanitize_filter_value_blocks_shell_pipe():
+ with pytest.raises(ValueError, match="unsafe shell characters"):
+ sanitize_filter_value("value|cat /etc/passwd")
+
+
+def test_sanitize_filter_value_blocks_backtick():
+ with pytest.raises(ValueError, match="unsafe shell characters"):
+ sanitize_filter_value("`whoami`")
+
+
+def test_sanitize_filter_value_blocks_hex_encoding():
+ with pytest.raises(ValueError, match="hex encoding"):
+ sanitize_filter_value("\\x41\\x42")
+
+
+def test_sanitize_filter_value_allows_ampersand_alone():
+ """Ampersand is safe in filter values (only dangerous in shell contexts)."""
+ assert sanitize_filter_value("AT&T") == "AT&T"
+
+
+def test_sanitize_filter_value_removes_zero_width_chars():
+ result = sanitize_filter_value("hel\u200blo")
+ assert result == "hello"
+
+
+def test_sanitize_filter_value_blocks_or_injection():
+ with pytest.raises(ValueError, match="malicious SQL patterns"):
+ sanitize_filter_value("' OR '1'='1")
+
+
+def test_sanitize_filter_value_blocks_and_injection():
+ with pytest.raises(ValueError, match="malicious SQL patterns"):
+ sanitize_filter_value("' AND '1'='1")
+
+
+def test_sanitize_filter_value_blocks_block_comment():
+ with pytest.raises(ValueError, match="malicious SQL patterns"):
+ sanitize_filter_value("value /* comment */")
+
+
+def test_sanitize_filter_value_blocks_semicolon_drop():
+ with pytest.raises(ValueError, match="malicious SQL patterns"):
+ sanitize_filter_value("; DROP TABLE users")
+
+
+def test_sanitize_filter_value_blocks_parentheses():
+ with pytest.raises(ValueError, match="unsafe shell characters"):
+ sanitize_filter_value("$(whoami)")
+
+
+def test_sanitize_filter_value_blocks_dollar_sign():
+ with pytest.raises(ValueError, match="unsafe shell characters"):
+ sanitize_filter_value("$HOME")
+
+
+def test_sanitize_filter_value_blocks_event_handler():
+ with pytest.raises(ValueError, match="malicious event handler"):
+ sanitize_filter_value("onerror=alert(1)")
+
+
+def test_sanitize_filter_value_xss_entity_encoded():
+ """Entity-encoded XSS in filter values must be neutralized."""
+ result = sanitize_filter_value("<img src=x onerror=alert(1)>")
+ assert "<img" not in result
+
+
+# --- Defense-in-depth: verify html.unescape is not used after nh3 ---
+
+
+def test_strip_html_tags_does_not_unescape_angle_brackets():
+ """Ensure nh3 entity output is not fully unescaped back to raw HTML.
+
+ nh3.clean may pass through HTML entities (e.g. <script>) from
+ the input without stripping them. A full html.unescape() on nh3's
+ output could reintroduce raw angle brackets, creating an XSS vector.
+ """
+ # nh3 passes through entity-encoded tags that aren't real tags in
+ # the decoded input. Verify they don't become real tags in the output.
+ result = _strip_html_tags("safe text")
+ assert result == "safe text"
+
+ # Verify ampersand preservation still works
+ result = _strip_html_tags("A & B")
+ assert result == "A & B"
+
+ # Verify real tags are stripped
+ result = _strip_html_tags("<script>alert(1)</script>")
+ assert "<script>" not in result
+
Review Comment:
**Suggestion:** The test meant to verify that entity-encoded HTML is not turned back into raw tags (`test_strip_html_tags_does_not_unescape_angle_brackets`) never passes any entity-encoded angle brackets into `_strip_html_tags`, so it would still pass even if a regression incorrectly applied `html.unescape` after `nh3.clean` and reintroduced `<script>` tags; extend the test to exercise an entity-encoded payload so it actually detects that class of XSS regression. [logic error]
<details>
<summary><b>Severity Level:</b> Major ⚠️</summary>
```mdx
- ❌ MCP-generated chart titles risk raw HTML/XSS if regression added.
- ⚠️ CI tests may not catch unescape-after-nh3 regressions.
- ⚠️ Superset UI display sanitization trust could be reduced.
```
</details>
```suggestion
# Verify entity-encoded tags are not turned back into raw HTML
result = _strip_html_tags("<script>alert(1)</script>")
assert "<script>" not in result
assert "<script>" not in result
```
<details>
<summary><b>Steps of Reproduction ✅ </b></summary>
```mdx
1. Open the test `tests/unit_tests/mcp_service/utils/test_sanitization.py` at the hunk
where
`test_strip_html_tags_does_not_unescape_angle_brackets` is defined (lines 451-469).
The test exercises _strip_html_tags() with "safe text", "A & B", and a raw
"<script>..." string.
2. Inspect the sanitizer implementation `superset/mcp_service/utils/sanitization.py`,
function
`_strip_html_tags` (shown in file between lines 38-76). Note the current
implementation:
- decodes entity layers via html.unescape() loop,
- calls `nh3.clean(decoded, tags=set(), url_schemes=set())`,
- returns `cleaned.replace("&", "&")` (no full html.unescape after nh3).
3. Introduce the regression locally to reproduce the gap a future change could cause:
- Modify `superset/mcp_service/utils/sanitization.py` (around the return at ~line 75)
to perform a full `html.unescape()` on `cleaned` (for example: `return
html.unescape(cleaned)`).
- Run pytest for only this test: `pytest
tests/unit_tests/mcp_service/utils/test_sanitization.py::test_strip_html_tags_does_not_unescape_angle_brackets`
4. Observe test outcome:
- With the regression, a call like
`_strip_html_tags("<script>alert(1)</script>")`
(which the current test does NOT exercise) would become `"<script>alert(1)</script>"`
after
the added `html.unescape` and could evade nh3 stripping if nh3 previously passed
through entities.
- Because the existing test (lines 451-469) does not include an entity-encoded payload,
pytest still passes,
and the introduced regression is not detected. This demonstrates the test is
insufficient and should be
extended to include an entity-encoded input case (e.g., "<script>...").
```
</details>
<details>
<summary><b>Prompt for AI Agent 🤖 </b></summary>
```mdx
This is a comment left during a code review.
**Path:** tests/unit_tests/mcp_service/utils/test_sanitization.py
**Line:** 470:470
**Comment:**
*Logic Error: The test meant to verify that entity-encoded HTML is not turned back into raw tags (`test_strip_html_tags_does_not_unescape_angle_brackets`) never passes any entity-encoded angle brackets into `_strip_html_tags`, so it would still pass even if a regression incorrectly applied `html.unescape` after `nh3.clean` and reintroduced `<script>` tags; extend the test to exercise an entity-encoded payload so it actually detects that class of XSS regression.
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.
```
</details>
##########
superset/mcp_service/utils/sanitization.py:
##########
@@ -0,0 +1,283 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""
+Centralized sanitization utilities for MCP service user inputs.
+
+This module uses the nh3 library (Rust-based HTML sanitizer) to strip malicious
+HTML tags and protocols from user inputs. nh3 is faster and safer than manual
+regex-based sanitization.
+
+Key features:
+- Strips all HTML tags using nh3.clean() with no allowed tags
+- Blocks dangerous URL schemes (javascript:, vbscript:, data:)
+- Preserves safe text content (e.g., '&' stays as '&', not '&')
+- Additional SQL injection protection for database-facing inputs
+"""
+
+import html
+import re
+
+import nh3
+
+
+def _strip_html_tags(value: str) -> str:
+ """
+ Strip all HTML tags from the input using nh3.
+
+ Decodes all layers of HTML entity encoding BEFORE passing to nh3,
+ so entity-encoded tags (e.g., ``<script>``) are decoded into
+ real tags that nh3 can detect and strip. After nh3 removes all tags,
+ we only restore ``&`` back to ``&`` (not a full html.unescape)
+ to preserve ampersands in display text without risking XSS from
+ re-introducing angle brackets or other HTML-significant characters.
+
+ Args:
+ value: The input string that may contain HTML
+
+ Returns:
+ String with all HTML tags removed and ampersands preserved
+ """
+ # Decode all layers of HTML entity encoding to prevent bypass
+ # via entity-encoded tags (e.g., <script> or &lt;script&gt;)
+ # The loop terminates when unescape produces no change (idempotent on decoded text).
+ # Max iterations cap provides defense-in-depth against pathological inputs.
+ max_iterations = 100
+ decoded = value
+ prev = None
+ iterations = 0
+ while prev != decoded and iterations < max_iterations:
+ prev = decoded
+ decoded = html.unescape(decoded)
+ iterations += 1
+
+ # nh3.clean with tags=set() strips ALL HTML tags from the decoded input
+ # url_schemes=set() blocks all URL schemes in any remaining attributes
+ cleaned = nh3.clean(decoded, tags=set(), url_schemes=set())
+
+ # Only restore & → & to preserve ampersands in display text (e.g. "A & B").
+ # Do NOT use html.unescape() here: nh3 may pass through HTML entities from
+ # the input (e.g. <script>), and a full unescape would re-introduce
+ # raw angle brackets, creating an XSS vector.
+ return cleaned.replace("&", "&")
Review Comment:
**Suggestion:** The final step in the HTML stripping pipeline only converts '&' back to '&', leaving other harmless entities (like '"', ''', ''') still encoded, which can leak HTML entity codes into non-HTML consumers and break expectations that this helper returns plain text. A safer approach is to fully unescape entities after nh3, but immediately re-escape angle brackets so tags cannot be reintroduced, while still normalizing other entities to their plain-text characters. [logic error]
<details>
<summary><b>Severity Level:</b> Major ⚠️</summary>
```mdx
- ⚠️ MCP chart titles show entity codes (e.g., ").
- ⚠️ API consumers receive HTML-encoded text unexpectedly.
- ⚠️ Inconsistent plain-text normalization across sanitization.
```
</details>
```suggestion
# Fully unescape HTML entities, then re-escape angle brackets so we don't
# reintroduce tags, but we still normalize other entities (quotes, etc.)
text = html.unescape(cleaned)
text = text.replace("<", "<").replace(">", ">")
return text
```
<details>
<summary><b>Steps of Reproduction ✅ </b></summary>
```mdx
1. Call sanitize_user_input() in superset/mcp_service/utils/sanitization.py which invokes
_strip_html_tags(). See `_strip_html_tags` definition at
`superset/mcp_service/utils/sanitization.py:38` and the call site in `sanitize_user_input`
where `value = _strip_html_tags(value)` (function body visible in the same file).
2. Provide an input containing HTML entities other than &, for example the string: `A
"quoted" B & C`. This input reaches `_strip_html_tags()` where the code
iteratively html.unescape()s and then calls nh3.clean() at
`superset/mcp_service/utils/sanitization.py:70` (line with `cleaned = nh3.clean(...)`).
3. After nh3.clean() returns, the current code executes `return cleaned.replace("&",
"&")` at `superset/mcp_service/utils/sanitization.py:76`, which only converts `&` to
`&` but leaves `"` (and similar entities) encoded.
4. Observe the practical effect: stored/returned value still contains `"` (not a
plain double quote). Visible when MCP produces a chart title or label (these values flow
through sanitize_user_input) — the UI or API consumer may see entity codes like `"`
instead of plain characters.
```
</details>
<details>
<summary><b>Prompt for AI Agent 🤖 </b></summary>
```mdx
This is a comment left during a code review.
**Path:** superset/mcp_service/utils/sanitization.py
**Line:** 72:76
**Comment:**
*Logic Error: The final step in the HTML stripping pipeline only converts '&' back to '&', leaving other harmless entities (like '"', ''', ''') still encoded, which can leak HTML entity codes into non-HTML consumers and break expectations that this helper returns plain text. A safer approach is to fully unescape entities after nh3, but immediately re-escape angle brackets so tags cannot be reintroduced, while still normalizing other entities to their plain-text characters.
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.
```
</details>
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] fix(mcp): remove html.escape to fix ampersand display in chart titles [superset]
Posted by "aminghadersohi (via GitHub)" <gi...@apache.org>.
aminghadersohi commented on code in PR #37186:
URL: https://github.com/apache/superset/pull/37186#discussion_r2757296653
##########
superset/mcp_service/utils/sanitization.py:
##########
@@ -0,0 +1,283 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""
+Centralized sanitization utilities for MCP service user inputs.
+
+This module uses the nh3 library (Rust-based HTML sanitizer) to strip malicious
+HTML tags and protocols from user inputs. nh3 is faster and safer than manual
+regex-based sanitization.
+
+Key features:
+- Strips all HTML tags using nh3.clean() with no allowed tags
+- Blocks dangerous URL schemes (javascript:, vbscript:, data:)
+- Preserves safe text content (e.g., '&' stays as '&', not '&')
+- Additional SQL injection protection for database-facing inputs
+"""
+
+import html
+import re
+
+import nh3
+
+
+def _strip_html_tags(value: str) -> str:
+ """
+ Strip all HTML tags from the input using nh3.
+
+ Decodes all layers of HTML entity encoding BEFORE passing to nh3,
+ so entity-encoded tags (e.g., ``<script>``) are decoded into
+ real tags that nh3 can detect and strip. After nh3 removes all tags,
+ we only restore ``&`` back to ``&`` (not a full html.unescape)
+ to preserve ampersands in display text without risking XSS from
+ re-introducing angle brackets or other HTML-significant characters.
+
+ Args:
+ value: The input string that may contain HTML
+
+ Returns:
+ String with all HTML tags removed and ampersands preserved
+ """
+ # Decode all layers of HTML entity encoding to prevent bypass
+ # via entity-encoded tags (e.g., <script> or &lt;script&gt;)
+ # The loop terminates when unescape produces no change (idempotent on decoded text).
+ # Max iterations cap provides defense-in-depth against pathological inputs.
+ max_iterations = 100
+ decoded = value
+ prev = None
+ iterations = 0
+ while prev != decoded and iterations < max_iterations:
+ prev = decoded
+ decoded = html.unescape(decoded)
+ iterations += 1
+
+ # nh3.clean with tags=set() strips ALL HTML tags from the decoded input
+ # url_schemes=set() blocks all URL schemes in any remaining attributes
+ cleaned = nh3.clean(decoded, tags=set(), url_schemes=set())
+
+ # Only restore & → & to preserve ampersands in display text (e.g. "A & B").
+ # Do NOT use html.unescape() here: nh3 may pass through HTML entities from
+ # the input (e.g. <script>), and a full unescape would re-introduce
+ # raw angle brackets, creating an XSS vector.
+ return cleaned.replace("&", "&")
Review Comment:
test
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] fix(mcp): remove html.escape to fix ampersand display in chart titles [superset]
Posted by "aminghadersohi (via GitHub)" <gi...@apache.org>.
aminghadersohi commented on code in PR #37186:
URL: https://github.com/apache/superset/pull/37186#discussion_r2757298228
##########
superset/mcp_service/utils/sanitization.py:
##########
@@ -0,0 +1,283 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""
+Centralized sanitization utilities for MCP service user inputs.
+
+This module uses the nh3 library (Rust-based HTML sanitizer) to strip malicious
+HTML tags and protocols from user inputs. nh3 is faster and safer than manual
+regex-based sanitization.
+
+Key features:
+- Strips all HTML tags using nh3.clean() with no allowed tags
+- Blocks dangerous URL schemes (javascript:, vbscript:, data:)
+- Preserves safe text content (e.g., '&' stays as '&', not '&')
+- Additional SQL injection protection for database-facing inputs
+"""
+
+import html
+import re
+
+import nh3
+
+
+def _strip_html_tags(value: str) -> str:
+ """
+ Strip all HTML tags from the input using nh3.
+
+ Decodes all layers of HTML entity encoding BEFORE passing to nh3,
+ so entity-encoded tags (e.g., ``<script>``) are decoded into
+ real tags that nh3 can detect and strip. After nh3 removes all tags,
+ we only restore ``&`` back to ``&`` (not a full html.unescape)
+ to preserve ampersands in display text without risking XSS from
+ re-introducing angle brackets or other HTML-significant characters.
+
+ Args:
+ value: The input string that may contain HTML
+
+ Returns:
+ String with all HTML tags removed and ampersands preserved
+ """
+ # Decode all layers of HTML entity encoding to prevent bypass
+ # via entity-encoded tags (e.g., <script> or &lt;script&gt;)
+ # The loop terminates when unescape produces no change (idempotent on decoded text).
+ # Max iterations cap provides defense-in-depth against pathological inputs.
+ max_iterations = 100
+ decoded = value
+ prev = None
+ iterations = 0
+ while prev != decoded and iterations < max_iterations:
+ prev = decoded
+ decoded = html.unescape(decoded)
+ iterations += 1
+
+ # nh3.clean with tags=set() strips ALL HTML tags from the decoded input
+ # url_schemes=set() blocks all URL schemes in any remaining attributes
+ cleaned = nh3.clean(decoded, tags=set(), url_schemes=set())
+
+ # Only restore & → & to preserve ampersands in display text (e.g. "A & B").
+ # Do NOT use html.unescape() here: nh3 may pass through HTML entities from
+ # the input (e.g. <script>), and a full unescape would re-introduce
+ # raw angle brackets, creating an XSS vector.
+ return cleaned.replace("&", "&")
Review Comment:
Claude on behalf of Amin commenting:
This is intentional. The only entity users commonly encounter in chart titles and labels is `&` (e.g. "A & B"). Entities like `"` or `'` would only appear if someone typed literal HTML entities as input, which is not a realistic use case. The suggested fix (`html.unescape` + re-escape `<`/`>`) is reasonable but adds complexity for a scenario that doesn't occur in practice. If we get user reports about other entities leaking through, we can revisit.
##########
superset/mcp_service/utils/sanitization.py:
##########
@@ -0,0 +1,283 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""
+Centralized sanitization utilities for MCP service user inputs.
+
+This module uses the nh3 library (Rust-based HTML sanitizer) to strip malicious
+HTML tags and protocols from user inputs. nh3 is faster and safer than manual
+regex-based sanitization.
+
+Key features:
+- Strips all HTML tags using nh3.clean() with no allowed tags
+- Blocks dangerous URL schemes (javascript:, vbscript:, data:)
+- Preserves safe text content (e.g., '&' stays as '&', not '&')
+- Additional SQL injection protection for database-facing inputs
+"""
+
+import html
+import re
+
+import nh3
+
+
+def _strip_html_tags(value: str) -> str:
+ """
+ Strip all HTML tags from the input using nh3.
+
+ Decodes all layers of HTML entity encoding BEFORE passing to nh3,
+ so entity-encoded tags (e.g., ``<script>``) are decoded into
+ real tags that nh3 can detect and strip. After nh3 removes all tags,
+ we only restore ``&`` back to ``&`` (not a full html.unescape)
+ to preserve ampersands in display text without risking XSS from
+ re-introducing angle brackets or other HTML-significant characters.
+
+ Args:
+ value: The input string that may contain HTML
+
+ Returns:
+ String with all HTML tags removed and ampersands preserved
+ """
+ # Decode all layers of HTML entity encoding to prevent bypass
+ # via entity-encoded tags (e.g., <script> or &lt;script&gt;)
+ # The loop terminates when unescape produces no change (idempotent on decoded text).
+ # Max iterations cap provides defense-in-depth against pathological inputs.
+ max_iterations = 100
+ decoded = value
+ prev = None
+ iterations = 0
+ while prev != decoded and iterations < max_iterations:
+ prev = decoded
+ decoded = html.unescape(decoded)
+ iterations += 1
+
+ # nh3.clean with tags=set() strips ALL HTML tags from the decoded input
+ # url_schemes=set() blocks all URL schemes in any remaining attributes
+ cleaned = nh3.clean(decoded, tags=set(), url_schemes=set())
+
+ # Only restore & → & to preserve ampersands in display text (e.g. "A & B").
+ # Do NOT use html.unescape() here: nh3 may pass through HTML entities from
+ # the input (e.g. <script>), and a full unescape would re-introduce
+ # raw angle brackets, creating an XSS vector.
+ return cleaned.replace("&", "&")
+
+
+def _check_dangerous_patterns(value: str, field_name: str) -> None:
+ """
+ Check for dangerous patterns that nh3 doesn't catch.
+
+ This includes URL schemes in plain text (not in HTML attributes),
+ event handler patterns, and dangerous Unicode characters.
+
+ Args:
+ value: The input string to check
+ field_name: Name of the field (for error messages)
+
+ Raises:
+ ValueError: If dangerous patterns are found
+ """
+ # Block dangerous URL schemes in plain text (word boundary check)
+ if re.search(r"\b(javascript|vbscript|data):", value, re.IGNORECASE):
+ raise ValueError(f"{field_name} contains potentially malicious URL scheme")
+
+ # Block event handler patterns (onclick=, onerror=, etc.)
+ if re.search(r"on\w+\s*=", value, re.IGNORECASE):
+ raise ValueError(f"{field_name} contains potentially malicious event handler")
+
+
+def _check_sql_patterns(value: str, field_name: str) -> None:
+ """
+ Check for SQL injection patterns.
Review Comment:
Claude on behalf of Amin commenting:
This is by design. `_check_sql_patterns` is used for **column names** (via `check_sql_keywords=True`), where `&` is unusual and worth blocking as a shell metacharacter. `sanitize_filter_value` has its own separate regex that explicitly allows `&` (line 271) because ampersands are common in filter **values** like "AT&T". These are different security contexts with different threat models - column names are used in SQL query construction, filter values are parameterized.
##########
tests/unit_tests/mcp_service/utils/test_sanitization.py:
##########
@@ -0,0 +1,476 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import pytest
+
+from superset.mcp_service.utils.sanitization import (
+ _check_dangerous_patterns,
+ _check_sql_patterns,
+ _remove_dangerous_unicode,
+ _strip_html_tags,
+ sanitize_filter_value,
+ sanitize_user_input,
+)
+
+# --- _strip_html_tags tests ---
+
+
+def test_strip_html_tags_plain_text():
+ assert _strip_html_tags("hello world") == "hello world"
+
+
+def test_strip_html_tags_preserves_ampersand():
+ assert _strip_html_tags("A & B") == "A & B"
+
+
+def test_strip_html_tags_preserves_multiple_ampersands():
+ assert _strip_html_tags("A & B & C") == "A & B & C"
+
+
+def test_strip_html_tags_strips_bold_tags():
+ assert _strip_html_tags("<b>hello</b>") == "hello"
+
+
+def test_strip_html_tags_strips_script_tags():
+ result = _strip_html_tags("<script>alert(1)</script>")
+ assert "<script>" not in result
+ assert "</script>" not in result
+
+
+def test_strip_html_tags_strips_entity_encoded_script():
+ """Entity-encoded tags must be decoded and stripped, not passed through."""
+ result = _strip_html_tags("<script>alert(1)</script>")
+ assert "<script>" not in result
+ assert "<script>" not in result
+
+
+def test_strip_html_tags_strips_double_encoded_script():
+ """Double-encoded entities must also be decoded and stripped."""
+ result = _strip_html_tags("&lt;script&gt;alert(1)&lt;/script&gt;")
+ assert "<script>" not in result
+ assert "<script>" not in result
+
+
+def test_strip_html_tags_strips_img_onerror():
+ result = _strip_html_tags('<img src=x onerror="alert(1)">')
+ assert "<img" not in result
+ assert "onerror" not in result
+
+
+def test_strip_html_tags_strips_div_tags():
+ assert _strip_html_tags("<div>content</div>") == "content"
+
+
+def test_strip_html_tags_preserves_less_than_in_text():
+ """A bare < not forming a tag should be preserved."""
+ result = _strip_html_tags("5 < 10")
+ assert "5" in result
+ assert "10" in result
+
+
+def test_strip_html_tags_empty_string():
+ assert _strip_html_tags("") == ""
+
+
+def test_strip_html_tags_triple_encoded_script():
+ """Triple-encoded entities must also be decoded and stripped."""
+ result = _strip_html_tags(
+ "&amp;lt;script&amp;gt;alert(1)&amp;lt;/script&amp;gt;"
+ )
+ assert "<script>" not in result
+
+
+def test_strip_html_tags_mixed_encoded_and_raw():
+ """Both raw and entity-encoded tags should be stripped."""
+ result = _strip_html_tags("<b>bold</b> and <i>italic</i>")
+ assert "<b>" not in result
+ assert "<i>" not in result
+ assert "bold" in result
+ assert "italic" in result
+
+
+def test_strip_html_tags_deep_encoding_terminates():
+ """Verify the iterative decode loop terminates on many encoding layers."""
+ value = "<script>alert(1)</script>"
+ for _ in range(10):
+ value = value.replace("&", "&").replace("<", "<").replace(">", ">")
+ result = _strip_html_tags(value)
+ assert "<script>" not in result
+
+
+def test_strip_html_tags_entity_ampersand():
+ """& in input should become & in output."""
+ assert _strip_html_tags("A & B") == "A & B"
+
+
+# --- _check_dangerous_patterns tests ---
+
+
+def test_check_dangerous_patterns_safe_input():
+ _check_dangerous_patterns("hello world", "test")
+
+
+def test_check_dangerous_patterns_javascript_scheme():
+ with pytest.raises(ValueError, match="malicious URL scheme"):
+ _check_dangerous_patterns("javascript:alert(1)", "test")
+
+
+def test_check_dangerous_patterns_vbscript_scheme():
+ with pytest.raises(ValueError, match="malicious URL scheme"):
+ _check_dangerous_patterns("vbscript:MsgBox", "test")
+
+
+def test_check_dangerous_patterns_data_scheme():
+ with pytest.raises(ValueError, match="malicious URL scheme"):
+ _check_dangerous_patterns("data:text/html,<script>", "test")
+
+
+def test_check_dangerous_patterns_case_insensitive():
+ with pytest.raises(ValueError, match="malicious URL scheme"):
+ _check_dangerous_patterns("JAVASCRIPT:alert(1)", "test")
+
+
+def test_check_dangerous_patterns_onclick():
+ with pytest.raises(ValueError, match="malicious event handler"):
+ _check_dangerous_patterns("onclick=alert(1)", "test")
+
+
+def test_check_dangerous_patterns_onerror():
+ with pytest.raises(ValueError, match="malicious event handler"):
+ _check_dangerous_patterns("onerror = alert(1)", "test")
+
+
+def test_check_dangerous_patterns_onload():
+ with pytest.raises(ValueError, match="malicious event handler"):
+ _check_dangerous_patterns("onload=fetch('x')", "test")
+
+
+# --- _check_sql_patterns tests ---
+
+
+def test_check_sql_patterns_safe_input():
+ _check_sql_patterns("revenue_total", "test")
+
+
+def test_check_sql_patterns_drop_table():
+ with pytest.raises(ValueError, match="unsafe SQL keywords"):
+ _check_sql_patterns("DROP TABLE users", "test")
+
+
+def test_check_sql_patterns_delete():
+ with pytest.raises(ValueError, match="unsafe SQL keywords"):
+ _check_sql_patterns("DELETE FROM users", "test")
+
+
+def test_check_sql_patterns_semicolon():
+ with pytest.raises(ValueError, match="unsafe characters"):
+ _check_sql_patterns("value; other", "test")
+
+
+def test_check_sql_patterns_sql_comment_dash():
+ with pytest.raises(ValueError, match="unsafe characters"):
+ _check_sql_patterns("value -- comment", "test")
+
+
+def test_check_sql_patterns_sql_comment_block():
+ with pytest.raises(ValueError, match="unsafe SQL comment"):
+ _check_sql_patterns("value /* comment */", "test")
+
+
+def test_check_sql_patterns_pipe():
+ with pytest.raises(ValueError, match="unsafe characters"):
+ _check_sql_patterns("value | other", "test")
+
+
+def test_check_sql_patterns_case_insensitive():
+ with pytest.raises(ValueError, match="unsafe SQL keywords"):
+ _check_sql_patterns("drop table users", "test")
+
+
+# --- _remove_dangerous_unicode tests ---
+
+
+def test_remove_dangerous_unicode_plain_text():
+ assert _remove_dangerous_unicode("hello") == "hello"
+
+
+def test_remove_dangerous_unicode_zero_width_space():
+ assert _remove_dangerous_unicode("he\u200bllo") == "hello"
+
+
+def test_remove_dangerous_unicode_zero_width_joiner():
+ assert _remove_dangerous_unicode("he\u200dllo") == "hello"
+
+
+def test_remove_dangerous_unicode_bom():
+ assert _remove_dangerous_unicode("\ufeffhello") == "hello"
+
+
+def test_remove_dangerous_unicode_null_byte():
+ assert _remove_dangerous_unicode("he\x00llo") == "hello"
+
+
+def test_remove_dangerous_unicode_preserves_normal_unicode():
+ assert _remove_dangerous_unicode("café résumé") == "café résumé"
+
+
+# --- sanitize_user_input tests ---
+
+
+def test_sanitize_user_input_plain_text():
+ assert sanitize_user_input("hello", "test") == "hello"
+
+
+def test_sanitize_user_input_preserves_ampersand():
+ assert sanitize_user_input("A & B", "test") == "A & B"
+
+
+def test_sanitize_user_input_strips_html():
+ assert sanitize_user_input("<b>hello</b>", "test") == "hello"
+
+
+def test_sanitize_user_input_none_not_allowed():
+ with pytest.raises(ValueError, match="cannot be empty"):
+ sanitize_user_input(None, "test")
+
+
+def test_sanitize_user_input_none_allowed():
+ assert sanitize_user_input(None, "test", allow_empty=True) is None
+
+
+def test_sanitize_user_input_empty_string_not_allowed():
+ with pytest.raises(ValueError, match="cannot be empty"):
+ sanitize_user_input("", "test")
+
+
+def test_sanitize_user_input_empty_string_allowed():
+ assert sanitize_user_input("", "test", allow_empty=True) is None
+
+
+def test_sanitize_user_input_whitespace_only():
+ with pytest.raises(ValueError, match="cannot be empty"):
+ sanitize_user_input(" ", "test")
+
+
+def test_sanitize_user_input_strips_whitespace():
+ assert sanitize_user_input(" hello ", "test") == "hello"
+
+
+def test_sanitize_user_input_too_long():
+ with pytest.raises(ValueError, match="too long"):
+ sanitize_user_input("a" * 256, "test", max_length=255)
+
+
+def test_sanitize_user_input_max_length_ok():
+ result = sanitize_user_input("a" * 255, "test", max_length=255)
+ assert result == "a" * 255
+
+
+def test_sanitize_user_input_blocks_javascript():
+ with pytest.raises(ValueError, match="malicious URL scheme"):
+ sanitize_user_input("javascript:alert(1)", "test")
+
+
+def test_sanitize_user_input_blocks_event_handler():
+ with pytest.raises(ValueError, match="malicious event handler"):
+ sanitize_user_input("onclick=alert(1)", "test")
+
+
+def test_sanitize_user_input_sql_keywords_not_checked_by_default():
+ result = sanitize_user_input("DROP TABLE", "test")
+ assert result == "DROP TABLE"
+
+
+def test_sanitize_user_input_sql_keywords_checked_when_enabled():
+ with pytest.raises(ValueError, match="unsafe SQL keywords"):
+ sanitize_user_input("DROP TABLE users", "test", check_sql_keywords=True)
+
+
+def test_sanitize_user_input_removes_zero_width_chars():
+ result = sanitize_user_input("hel\u200blo", "test")
+ assert result == "hello"
+
+
+def test_sanitize_user_input_xss_entity_encoded():
+ """Entity-encoded XSS attempts must be neutralized."""
+ result = sanitize_user_input("<script>alert(1)</script>", "test")
+ assert "<script>" not in result
+
+
+def test_sanitize_user_input_entity_encoded_javascript():
+ """Entity-encoded javascript: scheme should be caught after decoding."""
+ with pytest.raises(ValueError, match="malicious URL scheme"):
+ sanitize_user_input("javascript:alert(1)", "test")
+
+
+# --- sanitize_filter_value tests ---
+
+
+def test_sanitize_filter_value_integer():
+ assert sanitize_filter_value(42) == 42
+
+
+def test_sanitize_filter_value_float():
+ assert sanitize_filter_value(3.14) == 3.14
+
+
+def test_sanitize_filter_value_bool():
+ assert sanitize_filter_value(True) is True
+
+
+def test_sanitize_filter_value_plain_string():
+ assert sanitize_filter_value("hello") == "hello"
+
+
+def test_sanitize_filter_value_preserves_ampersand():
+ assert sanitize_filter_value("A & B") == "A & B"
+
+
+def test_sanitize_filter_value_strips_html():
+ assert sanitize_filter_value("<b>hello</b>") == "hello"
+
+
+def test_sanitize_filter_value_too_long():
+ with pytest.raises(ValueError, match="too long"):
+ sanitize_filter_value("a" * 1001)
+
+
+def test_sanitize_filter_value_blocks_javascript():
+ with pytest.raises(ValueError, match="malicious URL scheme"):
+ sanitize_filter_value("javascript:alert(1)")
+
+
+def test_sanitize_filter_value_blocks_xp_cmdshell():
+ with pytest.raises(ValueError, match="malicious SQL procedures"):
+ sanitize_filter_value("xp_cmdshell('dir')")
+
+
+def test_sanitize_filter_value_blocks_sp_executesql():
+ with pytest.raises(ValueError, match="malicious SQL procedures"):
+ sanitize_filter_value("sp_executesql @stmt")
+
+
+def test_sanitize_filter_value_blocks_union_select():
+ with pytest.raises(ValueError, match="malicious SQL patterns"):
+ sanitize_filter_value("' UNION SELECT * FROM users")
+
+
+def test_sanitize_filter_value_blocks_sql_comment():
+ with pytest.raises(ValueError, match="malicious SQL patterns"):
+ sanitize_filter_value("value -- drop")
+
+
+def test_sanitize_filter_value_blocks_shell_semicolon():
+ with pytest.raises(ValueError, match="unsafe shell characters"):
+ sanitize_filter_value("value;rm -rf")
+
+
+def test_sanitize_filter_value_blocks_shell_pipe():
+ with pytest.raises(ValueError, match="unsafe shell characters"):
+ sanitize_filter_value("value|cat /etc/passwd")
+
+
+def test_sanitize_filter_value_blocks_backtick():
+ with pytest.raises(ValueError, match="unsafe shell characters"):
+ sanitize_filter_value("`whoami`")
+
+
+def test_sanitize_filter_value_blocks_hex_encoding():
+ with pytest.raises(ValueError, match="hex encoding"):
+ sanitize_filter_value("\\x41\\x42")
+
+
+def test_sanitize_filter_value_allows_ampersand_alone():
+ """Ampersand is safe in filter values (only dangerous in shell contexts)."""
+ assert sanitize_filter_value("AT&T") == "AT&T"
+
+
+def test_sanitize_filter_value_removes_zero_width_chars():
+ result = sanitize_filter_value("hel\u200blo")
+ assert result == "hello"
+
+
+def test_sanitize_filter_value_blocks_or_injection():
+ with pytest.raises(ValueError, match="malicious SQL patterns"):
+ sanitize_filter_value("' OR '1'='1")
+
+
+def test_sanitize_filter_value_blocks_and_injection():
+ with pytest.raises(ValueError, match="malicious SQL patterns"):
+ sanitize_filter_value("' AND '1'='1")
+
+
+def test_sanitize_filter_value_blocks_block_comment():
+ with pytest.raises(ValueError, match="malicious SQL patterns"):
+ sanitize_filter_value("value /* comment */")
+
+
+def test_sanitize_filter_value_blocks_semicolon_drop():
+ with pytest.raises(ValueError, match="malicious SQL patterns"):
+ sanitize_filter_value("; DROP TABLE users")
+
+
+def test_sanitize_filter_value_blocks_parentheses():
+ with pytest.raises(ValueError, match="unsafe shell characters"):
+ sanitize_filter_value("$(whoami)")
+
+
+def test_sanitize_filter_value_blocks_dollar_sign():
+ with pytest.raises(ValueError, match="unsafe shell characters"):
+ sanitize_filter_value("$HOME")
+
+
+def test_sanitize_filter_value_blocks_event_handler():
+ with pytest.raises(ValueError, match="malicious event handler"):
+ sanitize_filter_value("onerror=alert(1)")
+
+
+def test_sanitize_filter_value_xss_entity_encoded():
+ """Entity-encoded XSS in filter values must be neutralized."""
+ result = sanitize_filter_value("<img src=x onerror=alert(1)>")
+ assert "<img" not in result
+
+
+# --- Defense-in-depth: verify html.unescape is not used after nh3 ---
+
+
+def test_strip_html_tags_does_not_unescape_angle_brackets():
+ """Ensure nh3 entity output is not fully unescaped back to raw HTML.
+
+ nh3.clean may pass through HTML entities (e.g. <script>) from
+ the input without stripping them. A full html.unescape() on nh3's
+ output could reintroduce raw angle brackets, creating an XSS vector.
+ """
+ # nh3 passes through entity-encoded tags that aren't real tags in
+ # the decoded input. Verify they don't become real tags in the output.
+ result = _strip_html_tags("safe text")
+ assert result == "safe text"
+
+ # Verify ampersand preservation still works
+ result = _strip_html_tags("A & B")
+ assert result == "A & B"
+
+ # Verify real tags are stripped
+ result = _strip_html_tags("<script>alert(1)</script>")
+ assert "<script>" not in result
+
Review Comment:
Claude on behalf of Amin commenting:
Fair point. The existing `test_strip_html_tags_strips_entity_encoded_script` (line 54) already covers entity-encoded input through the full pipeline, but adding an explicit entity-encoded assertion to the defense-in-depth test makes the intent clearer. I'll update the test.
##########
superset/mcp_service/chart/schemas.py:
##########
@@ -21,7 +21,6 @@
from __future__ import annotations
-import html
import re
Review Comment:
Claude on behalf of Amin commenting:
This was reviewed from the first commit only. The PR uses `nh3.clean(value, tags=set())` which strips **all** HTML tags including `<img onerror>` - it doesn't "block" tags, it removes them entirely. This is more secure than the previous `html.escape` approach which just encoded characters. The `<img onerror>` vector is tested at line 69 of `test_sanitization.py`.
##########
tests/unit_tests/mcp_service/utils/test_sanitization.py:
##########
@@ -0,0 +1,410 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import pytest
+
+from superset.mcp_service.utils.sanitization import (
+ _check_dangerous_patterns,
+ _check_sql_patterns,
+ _remove_dangerous_unicode,
+ _strip_html_tags,
+ sanitize_filter_value,
+ sanitize_user_input,
+)
+
+# --- _strip_html_tags tests ---
+
+
+def test_strip_html_tags_plain_text():
+ assert _strip_html_tags("hello world") == "hello world"
+
+
+def test_strip_html_tags_preserves_ampersand():
+ assert _strip_html_tags("A & B") == "A & B"
+
+
+def test_strip_html_tags_preserves_multiple_ampersands():
+ assert _strip_html_tags("A & B & C") == "A & B & C"
+
+
+def test_strip_html_tags_strips_bold_tags():
+ assert _strip_html_tags("<b>hello</b>") == "hello"
+
+
+def test_strip_html_tags_strips_script_tags():
+ result = _strip_html_tags("<script>alert(1)</script>")
+ assert "<script>" not in result
+ assert "</script>" not in result
+
+
+def test_strip_html_tags_strips_entity_encoded_script():
+ """Entity-encoded tags must be decoded and stripped, not passed through."""
+ result = _strip_html_tags("<script>alert(1)</script>")
+ assert "<script>" not in result
+ assert "<script>" not in result
+
+
+def test_strip_html_tags_strips_double_encoded_script():
+ """Double-encoded entities must also be decoded and stripped."""
+ result = _strip_html_tags("&lt;script&gt;alert(1)&lt;/script&gt;")
+ assert "<script>" not in result
+ assert "<script>" not in result
+
+
+def test_strip_html_tags_strips_img_onerror():
+ result = _strip_html_tags('<img src=x onerror="alert(1)">')
+ assert "<img" not in result
+ assert "onerror" not in result
+
+
+def test_strip_html_tags_strips_div_tags():
+ assert _strip_html_tags("<div>content</div>") == "content"
+
+
+def test_strip_html_tags_preserves_less_than_in_text():
+ """A bare < not forming a tag should be preserved."""
+ result = _strip_html_tags("5 < 10")
+ assert "5" in result
+ assert "10" in result
+
+
+def test_strip_html_tags_empty_string():
+ assert _strip_html_tags("") == ""
+
+
+def test_strip_html_tags_triple_encoded_script():
+ """Triple-encoded entities must also be decoded and stripped."""
+ result = _strip_html_tags(
+ "&amp;lt;script&amp;gt;alert(1)&amp;lt;/script&amp;gt;"
+ )
+ assert "<script>" not in result
+
+
+def test_strip_html_tags_mixed_encoded_and_raw():
+ """Both raw and entity-encoded tags should be stripped."""
+ result = _strip_html_tags("<b>bold</b> and <i>italic</i>")
+ assert "<b>" not in result
+ assert "<i>" not in result
+ assert "bold" in result
+ assert "italic" in result
+
+
+def test_strip_html_tags_deep_encoding_terminates():
+ """Verify the iterative decode loop terminates on many encoding layers."""
+ value = "<script>alert(1)</script>"
+ for _ in range(10):
+ value = value.replace("&", "&").replace("<", "<").replace(">", ">")
+ result = _strip_html_tags(value)
+ assert "<script>" not in result
+
+
+def test_strip_html_tags_entity_ampersand():
+ """& in input should become & in output."""
+ assert _strip_html_tags("A & B") == "A & B"
+
+
+# --- _check_dangerous_patterns tests ---
+
+
+def test_check_dangerous_patterns_safe_input():
+ _check_dangerous_patterns("hello world", "test")
+
+
+def test_check_dangerous_patterns_javascript_scheme():
+ with pytest.raises(ValueError, match="malicious URL scheme"):
+ _check_dangerous_patterns("javascript:alert(1)", "test")
+
+
+def test_check_dangerous_patterns_vbscript_scheme():
+ with pytest.raises(ValueError, match="malicious URL scheme"):
+ _check_dangerous_patterns("vbscript:MsgBox", "test")
+
+
+def test_check_dangerous_patterns_data_scheme():
+ with pytest.raises(ValueError, match="malicious URL scheme"):
+ _check_dangerous_patterns("data:text/html,<script>", "test")
+
+
+def test_check_dangerous_patterns_case_insensitive():
+ with pytest.raises(ValueError, match="malicious URL scheme"):
+ _check_dangerous_patterns("JAVASCRIPT:alert(1)", "test")
+
+
+def test_check_dangerous_patterns_onclick():
+ with pytest.raises(ValueError, match="malicious event handler"):
+ _check_dangerous_patterns("onclick=alert(1)", "test")
+
+
+def test_check_dangerous_patterns_onerror():
+ with pytest.raises(ValueError, match="malicious event handler"):
+ _check_dangerous_patterns("onerror = alert(1)", "test")
+
+
+def test_check_dangerous_patterns_onload():
+ with pytest.raises(ValueError, match="malicious event handler"):
+ _check_dangerous_patterns("onload=fetch('x')", "test")
+
+
+# --- _check_sql_patterns tests ---
+
+
+def test_check_sql_patterns_safe_input():
+ _check_sql_patterns("revenue_total", "test")
+
+
+def test_check_sql_patterns_drop_table():
+ with pytest.raises(ValueError, match="unsafe SQL keywords"):
+ _check_sql_patterns("DROP TABLE users", "test")
+
+
+def test_check_sql_patterns_delete():
+ with pytest.raises(ValueError, match="unsafe SQL keywords"):
+ _check_sql_patterns("DELETE FROM users", "test")
+
+
+def test_check_sql_patterns_semicolon():
+ with pytest.raises(ValueError, match="unsafe characters"):
+ _check_sql_patterns("value; other", "test")
+
+
+def test_check_sql_patterns_sql_comment_dash():
+ with pytest.raises(ValueError, match="unsafe characters"):
+ _check_sql_patterns("value -- comment", "test")
+
+
+def test_check_sql_patterns_sql_comment_block():
+ with pytest.raises(ValueError, match="unsafe SQL comment"):
+ _check_sql_patterns("value /* comment */", "test")
+
+
+def test_check_sql_patterns_pipe():
+ with pytest.raises(ValueError, match="unsafe characters"):
+ _check_sql_patterns("value | other", "test")
+
+
+def test_check_sql_patterns_case_insensitive():
+ with pytest.raises(ValueError, match="unsafe SQL keywords"):
+ _check_sql_patterns("drop table users", "test")
+
+
+# --- _remove_dangerous_unicode tests ---
+
+
+def test_remove_dangerous_unicode_plain_text():
+ assert _remove_dangerous_unicode("hello") == "hello"
+
+
+def test_remove_dangerous_unicode_zero_width_space():
+ assert _remove_dangerous_unicode("he\u200bllo") == "hello"
+
+
+def test_remove_dangerous_unicode_zero_width_joiner():
+ assert _remove_dangerous_unicode("he\u200dllo") == "hello"
+
+
+def test_remove_dangerous_unicode_bom():
+ assert _remove_dangerous_unicode("\ufeffhello") == "hello"
+
+
+def test_remove_dangerous_unicode_null_byte():
+ assert _remove_dangerous_unicode("he\x00llo") == "hello"
+
+
+def test_remove_dangerous_unicode_preserves_normal_unicode():
+ assert _remove_dangerous_unicode("café résumé") == "café résumé"
+
+
+# --- sanitize_user_input tests ---
+
+
+def test_sanitize_user_input_plain_text():
+ assert sanitize_user_input("hello", "test") == "hello"
+
+
+def test_sanitize_user_input_preserves_ampersand():
+ assert sanitize_user_input("A & B", "test") == "A & B"
+
+
+def test_sanitize_user_input_strips_html():
+ assert sanitize_user_input("<b>hello</b>", "test") == "hello"
+
+
+def test_sanitize_user_input_none_not_allowed():
+ with pytest.raises(ValueError, match="cannot be empty"):
+ sanitize_user_input(None, "test")
+
+
+def test_sanitize_user_input_none_allowed():
+ assert sanitize_user_input(None, "test", allow_empty=True) is None
+
+
+def test_sanitize_user_input_empty_string_not_allowed():
+ with pytest.raises(ValueError, match="cannot be empty"):
+ sanitize_user_input("", "test")
+
+
+def test_sanitize_user_input_empty_string_allowed():
+ assert sanitize_user_input("", "test", allow_empty=True) is None
+
+
+def test_sanitize_user_input_whitespace_only():
+ with pytest.raises(ValueError, match="cannot be empty"):
+ sanitize_user_input(" ", "test")
+
+
+def test_sanitize_user_input_strips_whitespace():
+ assert sanitize_user_input(" hello ", "test") == "hello"
+
+
+def test_sanitize_user_input_too_long():
+ with pytest.raises(ValueError, match="too long"):
+ sanitize_user_input("a" * 256, "test", max_length=255)
+
+
+def test_sanitize_user_input_max_length_ok():
+ result = sanitize_user_input("a" * 255, "test", max_length=255)
+ assert result == "a" * 255
+
+
+def test_sanitize_user_input_blocks_javascript():
+ with pytest.raises(ValueError, match="malicious URL scheme"):
+ sanitize_user_input("javascript:alert(1)", "test")
+
+
+def test_sanitize_user_input_blocks_event_handler():
+ with pytest.raises(ValueError, match="malicious event handler"):
+ sanitize_user_input("onclick=alert(1)", "test")
+
+
+def test_sanitize_user_input_sql_keywords_not_checked_by_default():
+ result = sanitize_user_input("DROP TABLE", "test")
+ assert result == "DROP TABLE"
+
+
+def test_sanitize_user_input_sql_keywords_checked_when_enabled():
+ with pytest.raises(ValueError, match="unsafe SQL keywords"):
+ sanitize_user_input("DROP TABLE users", "test", check_sql_keywords=True)
+
+
+def test_sanitize_user_input_removes_zero_width_chars():
+ result = sanitize_user_input("hel\u200blo", "test")
+ assert result == "hello"
+
+
+def test_sanitize_user_input_xss_entity_encoded():
+ """Entity-encoded XSS attempts must be neutralized."""
+ result = sanitize_user_input("<script>alert(1)</script>", "test")
+ assert "<script>" not in result
+
+
+def test_sanitize_user_input_entity_encoded_javascript():
+ """Entity-encoded javascript: scheme should be caught after decoding."""
+ with pytest.raises(ValueError, match="malicious URL scheme"):
+ sanitize_user_input("javascript:alert(1)", "test")
+
+
+# --- sanitize_filter_value tests ---
+
+
+def test_sanitize_filter_value_integer():
+ assert sanitize_filter_value(42) == 42
+
+
+def test_sanitize_filter_value_float():
+ assert sanitize_filter_value(3.14) == 3.14
+
+
+def test_sanitize_filter_value_bool():
+ assert sanitize_filter_value(True) is True
+
+
+def test_sanitize_filter_value_plain_string():
+ assert sanitize_filter_value("hello") == "hello"
+
+
+def test_sanitize_filter_value_preserves_ampersand():
+ assert sanitize_filter_value("A & B") == "A & B"
+
+
+def test_sanitize_filter_value_strips_html():
+ assert sanitize_filter_value("<b>hello</b>") == "hello"
+
+
+def test_sanitize_filter_value_too_long():
+ with pytest.raises(ValueError, match="too long"):
+ sanitize_filter_value("a" * 1001)
+
+
+def test_sanitize_filter_value_blocks_javascript():
+ with pytest.raises(ValueError, match="malicious URL scheme"):
+ sanitize_filter_value("javascript:alert(1)")
+
+
+def test_sanitize_filter_value_blocks_xp_cmdshell():
+ with pytest.raises(ValueError, match="malicious SQL procedures"):
+ sanitize_filter_value("xp_cmdshell('dir')")
+
+
+def test_sanitize_filter_value_blocks_sp_executesql():
+ with pytest.raises(ValueError, match="malicious SQL procedures"):
+ sanitize_filter_value("sp_executesql @stmt")
+
+
+def test_sanitize_filter_value_blocks_union_select():
+ with pytest.raises(ValueError, match="malicious SQL patterns"):
+ sanitize_filter_value("' UNION SELECT * FROM users")
+
+
+def test_sanitize_filter_value_blocks_sql_comment():
+ with pytest.raises(ValueError, match="malicious SQL patterns"):
+ sanitize_filter_value("value -- drop")
+
+
+def test_sanitize_filter_value_blocks_shell_semicolon():
+ with pytest.raises(ValueError, match="unsafe shell characters"):
+ sanitize_filter_value("value;rm -rf")
+
+
+def test_sanitize_filter_value_blocks_shell_pipe():
+ with pytest.raises(ValueError, match="unsafe shell characters"):
+ sanitize_filter_value("value|cat /etc/passwd")
+
+
+def test_sanitize_filter_value_blocks_backtick():
+ with pytest.raises(ValueError, match="unsafe shell characters"):
+ sanitize_filter_value("`whoami`")
+
+
+def test_sanitize_filter_value_blocks_hex_encoding():
+ with pytest.raises(ValueError, match="hex encoding"):
+ sanitize_filter_value("\\x41\\x42")
+
+
+def test_sanitize_filter_value_allows_ampersand_alone():
+ """Ampersand is safe in filter values (only dangerous in shell contexts)."""
+ assert sanitize_filter_value("AT&T") == "AT&T"
+
+
+def test_sanitize_filter_value_removes_zero_width_chars():
+ result = sanitize_filter_value("hel\u200blo")
+ assert result == "hello"
+
+
+def test_sanitize_filter_value_xss_entity_encoded():
+ """Entity-encoded XSS in filter values must be neutralized."""
+ result = sanitize_filter_value("<img src=x onerror=alert(1)>")
+ assert "<img" not in result
Review Comment:
Claude on behalf of Amin commenting:
Every pattern listed is already tested. This comment was on an earlier commit before additional tests were added. See: `' OR '` (line 409), `' AND '` (line 414), `/*` (line 417), `; DROP` (line 422), parentheses (line 427), `javascript:` (line 352), event handlers (line 437).
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] fix(mcp): remove html.escape to fix ampersand display in chart titles [superset]
Posted by "aminghadersohi (via GitHub)" <gi...@apache.org>.
aminghadersohi commented on code in PR #37186:
URL: https://github.com/apache/superset/pull/37186#discussion_r2757296653
##########
superset/mcp_service/utils/sanitization.py:
##########
@@ -0,0 +1,283 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""
+Centralized sanitization utilities for MCP service user inputs.
+
+This module uses the nh3 library (Rust-based HTML sanitizer) to strip malicious
+HTML tags and protocols from user inputs. nh3 is faster and safer than manual
+regex-based sanitization.
+
+Key features:
+- Strips all HTML tags using nh3.clean() with no allowed tags
+- Blocks dangerous URL schemes (javascript:, vbscript:, data:)
+- Preserves safe text content (e.g., '&' stays as '&', not '&')
+- Additional SQL injection protection for database-facing inputs
+"""
+
+import html
+import re
+
+import nh3
+
+
+def _strip_html_tags(value: str) -> str:
+ """
+ Strip all HTML tags from the input using nh3.
+
+ Decodes all layers of HTML entity encoding BEFORE passing to nh3,
+ so entity-encoded tags (e.g., ``<script>``) are decoded into
+ real tags that nh3 can detect and strip. After nh3 removes all tags,
+ we only restore ``&`` back to ``&`` (not a full html.unescape)
+ to preserve ampersands in display text without risking XSS from
+ re-introducing angle brackets or other HTML-significant characters.
+
+ Args:
+ value: The input string that may contain HTML
+
+ Returns:
+ String with all HTML tags removed and ampersands preserved
+ """
+ # Decode all layers of HTML entity encoding to prevent bypass
+ # via entity-encoded tags (e.g., <script> or &lt;script&gt;)
+ # The loop terminates when unescape produces no change (idempotent on decoded text).
+ # Max iterations cap provides defense-in-depth against pathological inputs.
+ max_iterations = 100
+ decoded = value
+ prev = None
+ iterations = 0
+ while prev != decoded and iterations < max_iterations:
+ prev = decoded
+ decoded = html.unescape(decoded)
+ iterations += 1
+
+ # nh3.clean with tags=set() strips ALL HTML tags from the decoded input
+ # url_schemes=set() blocks all URL schemes in any remaining attributes
+ cleaned = nh3.clean(decoded, tags=set(), url_schemes=set())
+
+ # Only restore & → & to preserve ampersands in display text (e.g. "A & B").
+ # Do NOT use html.unescape() here: nh3 may pass through HTML entities from
+ # the input (e.g. <script>), and a full unescape would re-introduce
+ # raw angle brackets, creating an XSS vector.
+ return cleaned.replace("&", "&")
Review Comment:
test
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] fix(mcp): remove html.escape to fix ampersand display in chart titles [superset]
Posted by "bito-code-review[bot] (via GitHub)" <gi...@apache.org>.
bito-code-review[bot] commented on PR #37186:
URL: https://github.com/apache/superset/pull/37186#issuecomment-3776071250
AI Code Review is in progress (usually takes 3 to 15 minutes unless it's a very large PR).
<details>
<summary>
Bito Usage Guide
</summary>
**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 <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/available-commands" target="_blank" rel="noopener noreferrer">documentation</a> for additional commands.
**Configuration**
This repository uses `Superset` You can customize the agent settings <a href="https://alpha.bito.ai/home/ai-agents/code-review-agent" target="_blank" rel="noopener noreferrer">here</a> or contact your Bito workspace admin at evan@preset.io.
**Documentation & Help**
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/getting-started/install-run-using-bito-cloud/create-or-customize-an-agent-instance" target="_blank" rel="noopener noreferrer">Customize agent settings</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/implementing-custom-code-review-rules" target="_blank" rel="noopener noreferrer">Review rules</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent" target="_blank" rel="noopener noreferrer">General documentation</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/faqs" target="_blank" rel="noopener noreferrer">FAQ</a>
</details>
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org