fix(proxy): add regression tests for #20441 - <script> tags in messages (#20573)

* fix: empty guardrails/policies arrays should not trigger enterprise license check (#20304)

The UI sends empty arrays for enterprise-only fields (guardrails, policies,
logging) even when the user has not configured these features. The backend
`is not None` check treated `[]` as a truthy intent to use the feature,
falsely requiring an enterprise license for basic team operations.

Backend: Add `and updated_kv[field] != [] and updated_kv[field] != {}`
guards in `_update_metadata_fields` so empty collections are skipped.

UI: Conditionally omit guardrails, logging, and policies from the
payload when empty instead of defaulting to `[]`.

Fixes #20304

* fix: allow clearing fields with empty collections while skipping enterprise check

Address PR review feedback:

1. Move the empty-collection guard into _update_metadata_field (singular)
   so that empty lists/dicts skip only the premium license check but still
   get written into metadata. This lets users intentionally clear a
   previously-set field (e.g. guardrails: []) without being blocked, while
   the UI's default empty arrays still don't trigger a false enterprise
   error.

2. Remove sys.path hack from test file; use standard imports that work
   with pytest discovery.

3. Add tests verifying that empty collections are moved into metadata
   (field clearing works) even though they bypass the premium check.

Fixes #20304

* fix(proxy): add regression tests for #20441 - ensure <script> tags in LLM messages are not blocked

The 403 Forbidden error when sending messages containing `<script>` is caused
by external WAF/reverse proxy infrastructure (confirmed by the standard nginx
HTML 403 response format), not by LiteLLM's own content filtering. However,
these regression tests ensure that:

1. The content filter guardrail's built-in patterns do not match HTML tags
2. Messages containing <script> and other HTML tags pass through the content
   filter unchanged when no explicit HTML-blocking rules are configured
3. The HTTP request body parser correctly handles JSON payloads containing
   HTML content without modification

These tests guard against accidentally introducing HTML/XSS filtering that
would break legitimate LLM API usage (e.g., discussing HTML/JavaScript code).

Closes #20441

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Varun Chawla 2026-02-06 23:21:00 -08:00 committed by Sameer Kankute
parent 8a5feb18e3
commit e587370f67
2 changed files with 207 additions and 0 deletions

View file

@ -697,3 +697,67 @@ def test_populate_request_with_path_params_does_not_overwrite_existing_values():
assert result["organization_id"] == "org-existing" # Should keep original, not "org-query-param"
# Verify other data is preserved
assert result["messages"] == [{"role": "user", "content": "Hello"}]
@pytest.mark.asyncio
async def test_request_body_with_html_script_tags():
"""
Test that JSON request bodies containing HTML tags like <script> are
parsed correctly without being blocked or modified.
Regression test for GitHub issue #20441:
https://github.com/BerriAI/litellm/issues/20441
LLM message content frequently contains HTML/code snippets.
The HTTP parsing layer must not interfere with such content.
"""
test_messages = [
{
"role": "user",
"content": "<script>alert('hello')</script>",
},
{
"role": "user",
"content": "<script> test </script>",
},
{
"role": "user",
"content": "Can you explain what <script> tags do in HTML?",
},
{
"role": "user",
"content": "Here is code: <div><script src='app.js'></script></div>",
},
{
"role": "user",
"content": "<img onerror='alert(1)' src='x'>",
},
{
"role": "user",
"content": "<iframe src='https://example.com'></iframe>",
},
]
for msg in test_messages:
test_payload = {
"model": "gpt-4o",
"messages": [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "Hello! How can I help?"},
msg,
],
}
mock_request = MagicMock()
mock_request.body = AsyncMock(return_value=orjson.dumps(test_payload))
mock_request.headers = {"content-type": "application/json"}
mock_request.scope = {}
result = await _read_request_body(mock_request)
assert result["model"] == "gpt-4o"
assert len(result["messages"]) == 3
assert result["messages"][2]["content"] == msg["content"], (
f"Message content with HTML was modified during parsing: "
f"expected={msg['content']!r}, got={result['messages'][2]['content']!r}"
)

View file

@ -986,3 +986,146 @@ class TestContentFilterGuardrail:
assert detail.get("category") == "harm_toxic_abuse"
else:
assert "harm_toxic_abuse" in str(detail)
async def test_html_tags_in_messages_not_blocked(self):
"""
Test that HTML tags like <script> in LLM message content are NOT blocked
by the content filter guardrail.
Regression test for GitHub issue #20441:
https://github.com/BerriAI/litellm/issues/20441
LLM message content is not rendered as HTML, so HTML tags should be
treated as plain text and should pass through without being blocked.
"""
# Set up a guardrail with all prebuilt patterns enabled as BLOCK
patterns = [
ContentFilterPattern(
pattern_type="prebuilt",
pattern_name="us_ssn",
action=ContentFilterAction.BLOCK,
),
ContentFilterPattern(
pattern_type="prebuilt",
pattern_name="email",
action=ContentFilterAction.BLOCK,
),
ContentFilterPattern(
pattern_type="prebuilt",
pattern_name="credit_card",
action=ContentFilterAction.BLOCK,
),
]
guardrail = ContentFilterGuardrail(
guardrail_name="test-html-tags",
patterns=patterns,
)
# Messages containing <script> and other HTML tags should NOT be blocked
html_messages = [
"<script>alert('hello')</script>",
"<script> test </script>",
"Can you explain what <script> tags do in HTML?",
"Here is some code: <div><script src='app.js'></script></div>",
"<img onerror='alert(1)' src='x'>",
"<iframe src='https://example.com'></iframe>",
"The <style> and <script> elements are important in HTML",
"<a href='javascript:void(0)'>click me</a>",
]
for message in html_messages:
# Should NOT raise HTTPException
result = await guardrail.apply_guardrail(
inputs={"texts": [message]},
request_data={},
input_type="request",
)
processed_texts = result.get("texts", [])
assert len(processed_texts) == 1
# Content should pass through unchanged (no HTML tags are patterns)
assert processed_texts[0] == message, (
f"Message containing HTML was unexpectedly modified: "
f"input={message!r}, output={processed_texts[0]!r}"
)
@pytest.mark.asyncio
async def test_script_tag_not_blocked_with_blocked_words(self):
"""
Test that <script> tags are not accidentally caught by blocked words
unless explicitly configured.
Regression test for GitHub issue #20441.
"""
blocked_words = [
BlockedWord(
keyword="confidential",
action=ContentFilterAction.BLOCK,
),
BlockedWord(
keyword="secret_project",
action=ContentFilterAction.BLOCK,
),
]
guardrail = ContentFilterGuardrail(
guardrail_name="test-script-not-blocked",
blocked_words=blocked_words,
)
# <script> should not be caught by unrelated blocked words
script_messages = [
"<script>alert('test')</script>",
"How do I use <script> tags in HTML?",
"<script src='app.js'></script>",
]
for message in script_messages:
result = await guardrail.apply_guardrail(
inputs={"texts": [message]},
request_data={},
input_type="request",
)
processed_texts = result.get("texts", [])
assert len(processed_texts) == 1
assert processed_texts[0] == message
def test_no_builtin_pattern_matches_script_tag(self):
"""
Test that NONE of the prebuilt patterns in patterns.json match
the string '<script>' or common HTML tags.
This is a safeguard to ensure that future pattern additions
do not accidentally block legitimate LLM content containing
HTML/code snippets.
Regression test for GitHub issue #20441.
"""
from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.patterns import (
PREBUILT_PATTERNS,
get_compiled_pattern,
)
html_test_strings = [
"<script>alert('xss')</script>",
"<script> test </script>",
"<script src='app.js'></script>",
"<img onerror='alert(1)' src='x'>",
"<iframe src='https://example.com'></iframe>",
"<style>body { color: red; }</style>",
"<div onclick='alert(1)'>click</div>",
]
for pattern_name in PREBUILT_PATTERNS:
compiled = get_compiled_pattern(pattern_name)
for test_string in html_test_strings:
match = compiled.search(test_string)
if match:
# Some patterns may legitimately match substrings
# (e.g., URL pattern matching src='https://...')
# but they should not match the script/HTML tag itself
matched_text = match.group()
assert "<script" not in matched_text.lower(), (
f"Pattern '{pattern_name}' matched '<script>' in "
f"test string: {test_string!r}. "
f"LLM message content should not be blocked for HTML tags."
)