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
452 lines
16 KiB
Python
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: <script>alert('XSS')</script></p>
|
|
<p>Special characters: &amp; "quotes" 'apostrophes' 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 "<script>" 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
|