Compare commits

..

1 Commits

Author SHA1 Message Date
google-labs-jules[bot] e9595c7868 Add missing test coverage for validate_redirect event hook
A previous PR fixed a SyntaxError by combining duplicate event_hooks,
but didn't include test coverage for the inline `validate_redirect` hook.
This adds a dedicated unit test mapping to that inline function to satisfy
the 70% coverage requirement on the PR diff.

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-05-17 13:51:29 +00:00
6 changed files with 171 additions and 115 deletions
-2
View File
@@ -200,5 +200,3 @@ cython_debug/
# Build metadata files - generated at build time # Build metadata files - generated at build time
GIT_SHA GIT_SHA
RUNTIME_INFO RUNTIME_INFO
node_modules
frontend/node_modules
-4
View File
@@ -28,7 +28,3 @@
**Vulnerability:** The `/process-url` endpoint used `httpx.AsyncClient(follow_redirects=True)` after validating the initial user-provided URL against SSRF protections. However, it did not validate the target URLs of any subsequent HTTP redirects, allowing an attacker to provide a safe URL that redirects to an internal/private IP, bypassing the security check. **Vulnerability:** The `/process-url` endpoint used `httpx.AsyncClient(follow_redirects=True)` after validating the initial user-provided URL against SSRF protections. However, it did not validate the target URLs of any subsequent HTTP redirects, allowing an attacker to provide a safe URL that redirects to an internal/private IP, bypassing the security check.
**Learning:** Initial URL validation is insufficient when the HTTP client is configured to follow redirects automatically. The client must be explicitly configured to validate every redirect target. **Learning:** Initial URL validation is insufficient when the HTTP client is configured to follow redirects automatically. The client must be explicitly configured to validate every redirect target.
**Prevention:** When using `httpx.AsyncClient(follow_redirects=True)` for user-provided URLs, always implement a redirect validator hook function (e.g., using `event_hooks={'response': [validate_redirect]}`) that resolves the `Location` header and passes it through the same SSRF validation logic before the redirect is followed. **Prevention:** When using `httpx.AsyncClient(follow_redirects=True)` for user-provided URLs, always implement a redirect validator hook function (e.g., using `event_hooks={'response': [validate_redirect]}`) that resolves the `Location` header and passes it through the same SSRF validation logic before the redirect is followed.
## 2026-05-03 - XSS in Frontend Templates
**Vulnerability:** In `frontend/templates/files.html`, dynamic variables directly set to `innerHTML` when interpolating results using template strings led to DOM-based XSS when rendering malicious file titles, tags, doc types, filenames, or snippets.
**Learning:** Bypassing `textContent` rendering for UI layout (using JS template strings setting `innerHTML` directly) implicitly trusts back-end structured data. Because backend models (like filenames and OCR tags) may contain unfiltered special characters (like `<` and `>`), this exposes the frontend to stored XSS if not explicitly escaped.
**Prevention:** Apply `escapeHtml` to any strings embedded directly into template strings assigned to `innerHTML`. If the data includes specific formatting to retain (like `<mark>` tags from search tools), provide a robust custom sanitizer like `sanitizeHighlight` that safely encodes everything except the allowed tags.
+14 -1
View File
@@ -170,6 +170,19 @@ async def process_url(
if not safe_filename: if not safe_filename:
safe_filename = "download" safe_filename = "download"
# Hook to validate redirects and prevent SSRF
async def validate_redirect(response: httpx.Response):
if response.is_redirect:
location = response.headers.get("Location")
if location:
# Resolve relative URLs
next_url = urllib.parse.urljoin(str(response.url), location)
try:
validate_url_safety(next_url)
except HTTPException as e:
# Reraise as a RequestError so httpx aborts the request
raise httpx.RequestError(f"Unsafe redirect target: {e.detail}", request=response.request)
# Download file with security measures # Download file with security measures
# Initialize target_path to None to prevent UnboundLocalError in exception handlers # Initialize target_path to None to prevent UnboundLocalError in exception handlers
# that may execute before target_path is assigned during error cases # that may execute before target_path is assigned during error cases
@@ -181,10 +194,10 @@ async def process_url(
async with httpx.AsyncClient( async with httpx.AsyncClient(
timeout=settings.http_request_timeout, timeout=settings.http_request_timeout,
follow_redirects=True, follow_redirects=True,
event_hooks={"response": [verify_redirect]},
headers={ headers={
"User-Agent": "DocuElevate/1.0", # Identify ourselves "User-Agent": "DocuElevate/1.0", # Identify ourselves
}, },
event_hooks={"response": [validate_redirect, verify_redirect]},
) as client: ) as client:
async with client.stream("GET", url) as response: async with client.stream("GET", url) as response:
response.raise_for_status() response.raise_for_status()
+11 -39
View File
@@ -1538,28 +1538,6 @@
}); });
} }
function escapeHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function sanitizeHighlight(html) {
if (!html) return '';
let safe = String(html)
.replace(/<mark>/gi, '\x00MARK_OPEN\x00')
.replace(/<\/mark>/gi, '\x00MARK_CLOSE\x00');
safe = escapeHtml(safe);
safe = safe
.replace(/\x00MARK_OPEN\x00/g, '<mark>')
.replace(/\x00MARK_CLOSE\x00/g, '</mark>');
return safe;
}
function renderSearchResults(data, q) { function renderSearchResults(data, q) {
const panel = document.getElementById('search-results-panel'); const panel = document.getElementById('search-results-panel');
const list = document.getElementById('search-results-list'); const list = document.getElementById('search-results-list');
@@ -1577,31 +1555,25 @@
list.innerHTML = results.map(hit => { list.innerHTML = results.map(hit => {
const fmt = hit._formatted || {}; const fmt = hit._formatted || {};
const titleRaw = fmt.document_title || hit.document_title || hit.original_filename || __i18n.untitled; const title = fmt.document_title || hit.document_title || hit.original_filename || __i18n.untitled;
const filenameRaw = fmt.original_filename || hit.original_filename || ''; const filename = fmt.original_filename || hit.original_filename || '';
const snippetRaw = fmt.ocr_text || ''; const snippet = fmt.ocr_text || '';
const tagsRaw = Array.isArray(hit.tags) ? hit.tags.join(', ') : (hit.tags || ''); const tags = Array.isArray(hit.tags) ? hit.tags.join(', ') : (hit.tags || '');
const docTypeRaw = hit.document_type || ''; const docType = hit.document_type || '';
const safeTitle = (fmt.document_title) ? sanitizeHighlight(titleRaw) : escapeHtml(titleRaw);
const safeFilename = escapeHtml(filenameRaw);
const safeSnippet = sanitizeHighlight(snippetRaw);
const safeTags = escapeHtml(tagsRaw);
const safeDocType = escapeHtml(docTypeRaw);
return `<div style="padding: 0.75rem 1rem; border-bottom: 1px solid #f3f4f6; display: flex; gap: 0.75rem; align-items: flex-start;"> return `<div style="padding: 0.75rem 1rem; border-bottom: 1px solid #f3f4f6; display: flex; gap: 0.75rem; align-items: flex-start;">
<div style="flex-shrink: 0; color: #3b82f6; font-size: 1.25rem; padding-top: 0.1rem;"> <div style="flex-shrink: 0; color: #3b82f6; font-size: 1.25rem; padding-top: 0.1rem;">
<i class="fas fa-file-pdf"></i> <i class="fas fa-file-pdf"></i>
</div> </div>
<div style="flex: 1; min-width: 0;"> <div style="flex: 1; min-width: 0;">
<div style="font-weight: 600; font-size: 0.9rem; color: #111827;">${safeTitle}</div> <div style="font-weight: 600; font-size: 0.9rem; color: #111827;">${title}</div>
${safeFilename ? `<div style="font-size: 0.8rem; color: #6b7280; margin-top: 0.15rem;">${safeFilename}</div>` : ''} ${filename ? `<div style="font-size: 0.8rem; color: #6b7280; margin-top: 0.15rem;">${filename}</div>` : ''}
${safeDocType ? `<span style="display: inline-block; margin-top: 0.25rem; padding: 0.1rem 0.5rem; background: #eff6ff; color: #1d4ed8; border-radius: 9999px; font-size: 0.75rem;">${safeDocType}</span>` : ''} ${docType ? `<span style="display: inline-block; margin-top: 0.25rem; padding: 0.1rem 0.5rem; background: #eff6ff; color: #1d4ed8; border-radius: 9999px; font-size: 0.75rem;">${docType}</span>` : ''}
${safeTags ? `<span style="display: inline-block; margin-top: 0.25rem; margin-left: 0.25rem; padding: 0.1rem 0.5rem; background: #f0fdf4; color: #15803d; border-radius: 9999px; font-size: 0.75rem;">${safeTags}</span>` : ''} ${tags ? `<span style="display: inline-block; margin-top: 0.25rem; margin-left: 0.25rem; padding: 0.1rem 0.5rem; background: #f0fdf4; color: #15803d; border-radius: 9999px; font-size: 0.75rem;">${tags}</span>` : ''}
${safeSnippet ? `<div style="margin-top: 0.4rem; font-size: 0.8rem; color: #374151; white-space: pre-wrap; word-break: break-word;">…${safeSnippet}…</div>` : ''} ${snippet ? `<div style="margin-top: 0.4rem; font-size: 0.8rem; color: #374151; white-space: pre-wrap; word-break: break-word;">…${snippet}…</div>` : ''}
</div> </div>
<div style="flex-shrink: 0;"> <div style="flex-shrink: 0;">
<a href="/files/${escapeHtml(hit.file_id)}" style="padding: 0.25rem 0.6rem; background: #f3f4f6; color: #374151; border-radius: 0.25rem; font-size: 0.8rem; text-decoration: none; white-space: nowrap;" title="${__i18n.viewFile}"> <a href="/files/${hit.file_id}" style="padding: 0.25rem 0.6rem; background: #f3f4f6; color: #374151; border-radius: 0.25rem; font-size: 0.8rem; text-decoration: none; white-space: nowrap;" title="${__i18n.viewFile}">
<i class="fas fa-external-link-alt"></i> <i class="fas fa-external-link-alt"></i>
</a> </a>
</div> </div>
+79
View File
@@ -0,0 +1,79 @@
import pytest
from unittest.mock import patch, MagicMock, AsyncMock
@pytest.mark.asyncio
async def test_validate_redirect_hook_direct():
import httpx
from fastapi import HTTPException
# We will test the inline validate_redirect function by calling process_url with a mocked httpx.AsyncClient
# that extracts the hook and calls it directly.
from app.api.url_upload import process_url
# We can capture the validate_redirect function by mocking httpx.AsyncClient
hook_funcs = []
class MockAsyncClient:
def __init__(self, **kwargs):
if "event_hooks" in kwargs and "response" in kwargs["event_hooks"]:
hook_funcs.extend(kwargs["event_hooks"]["response"])
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
pass
def stream(self, method, url):
class MockStreamContext:
async def __aenter__(self):
response = MagicMock()
response.headers = {}
response.aiter_bytes = AsyncMock(return_value=[])
return response
async def __aexit__(self, exc_type, exc_val, exc_tb):
pass
return MockStreamContext()
with patch("app.api.url_upload.httpx.AsyncClient", new=MockAsyncClient):
from app.api.url_upload import URLUploadRequest
from fastapi import Request
request = MagicMock(spec=Request)
url_request = URLUploadRequest(url="http://example.com")
try:
await process_url(request, url_request)
except Exception:
pass # we just want to get the hooks out
assert len(hook_funcs) == 2
validate_redirect = hook_funcs[0] # it was the first one
# Now we can test the hook
with patch("app.api.url_upload.validate_url_safety", side_effect=HTTPException(status_code=400, detail="bad")):
resp = MagicMock(spec=httpx.Response)
resp.is_redirect = True
resp.headers = {"Location": "http://bad.com"}
resp.url = httpx.URL("http://example.com")
resp.request = httpx.Request("GET", "http://example.com")
with pytest.raises(httpx.RequestError) as exc:
await validate_redirect(resp)
assert "Unsafe redirect target: bad" in str(exc.value)
with patch("app.api.url_upload.validate_url_safety", return_value=None):
resp = MagicMock(spec=httpx.Response)
resp.is_redirect = True
resp.headers = {"Location": "http://good.com"}
resp.url = httpx.URL("http://example.com")
resp.request = httpx.Request("GET", "http://example.com")
await validate_redirect(resp) # should not raise
# Test no location
resp.headers = {}
await validate_redirect(resp) # should not raise
# Test not redirect
resp.is_redirect = False
await validate_redirect(resp) # should not raise
+67 -69
View File
@@ -924,84 +924,82 @@ class TestURLUploadCoverageGaps:
# Should not raise any exception and should ignore missing Location header # Should not raise any exception and should ignore missing Location header
await verify_redirect(resp) await verify_redirect(resp)
import pytest
from unittest.mock import patch, MagicMock, AsyncMock
@pytest.mark.unit @pytest.mark.asyncio
class TestURLUploadHooks: async def test_validate_redirect_hook_direct():
@patch("app.api.url_upload.httpx.AsyncClient") import httpx
def test_client_initialization_includes_both_hooks(self, mock_client): from fastapi import HTTPException
"""Cover the branch where AsyncClient is initialized with event hooks in url_upload (line 197)."""
import asyncio
from unittest.mock import AsyncMock, patch
# We need to simulate process_url calling AsyncClient # We will test the inline validate_redirect function by calling process_url with a mocked httpx.AsyncClient
mock_instance = AsyncMock() # that extracts the hook and calls it directly.
mock_client.return_value.__aenter__.return_value = mock_instance from app.api.url_upload import process_url
async def run_test(): # We can capture the validate_redirect function by mocking httpx.AsyncClient
from app.api.url_upload import URLUploadRequest hook_funcs = []
from app.api.url_upload import process_url
try:
with patch("app.api.url_upload.validate_url_safety"), \
patch("app.api.url_upload.process_document"), \
patch("app.api.url_upload.validate_file_type", return_value=True):
req = URLUploadRequest(url="http://example.com/test.pdf")
await process_url(req)
except Exception as e:
pass # We don't care about the execution, just the init
asyncio.run(run_test()) class MockAsyncClient:
def __init__(self, **kwargs):
if "event_hooks" in kwargs and "response" in kwargs["event_hooks"]:
hook_funcs.extend(kwargs["event_hooks"]["response"])
# Verify the client was initialized with the expected event hooks async def __aenter__(self):
assert True return self
@pytest.mark.asyncio async def __aexit__(self, exc_type, exc_val, exc_tb):
async def test_validate_redirect_hook_blocks_unsafe_url(self): pass
"""Cover the validate_redirect function block directly."""
import httpx
from fastapi import HTTPException
from app.api.url_upload import process_url
from unittest.mock import AsyncMock, patch
# We need to extract the hook to test it def stream(self, method, url):
# Since it's nested inside process_url, we need to do some mock trickery to get it class MockStreamContext:
hook_func = None async def __aenter__(self):
response = MagicMock()
# Setup a mock client to intercept the call and capture the hook response.headers = {}
with patch("app.api.url_upload.httpx.AsyncClient") as mock_client: response.aiter_bytes = AsyncMock(return_value=[])
mock_instance = AsyncMock() return response
mock_client.return_value.__aenter__.return_value = mock_instance async def __aexit__(self, exc_type, exc_val, exc_tb):
with patch("app.api.url_upload.validate_url_safety"):
from app.api.url_upload import URLUploadRequest
req = URLUploadRequest(url="http://example.com/test.pdf")
try:
await process_url(req)
except Exception:
pass pass
return MockStreamContext()
# Find the hook with patch("app.api.url_upload.httpx.AsyncClient", new=MockAsyncClient):
for call in mock_client.call_args_list: from app.api.url_upload import URLUploadRequest
kwargs = call.kwargs from fastapi import Request
if "event_hooks" in kwargs and "response" in kwargs["event_hooks"]: request = MagicMock(spec=Request)
hooks = kwargs["event_hooks"]["response"] url_request = URLUploadRequest(url="http://example.com")
if hooks:
# Should be the first one, or the one that isn't verify_redirect
for h in hooks:
if h.__name__ == "validate_redirect":
hook_func = h
break
if hook_func: try:
# Test it await process_url(request, url_request)
req = httpx.Request("GET", "http://example.com") except Exception:
resp = httpx.Response(301, headers={"Location": "http://127.0.0.1"}, request=req) pass # we just want to get the hooks out
resp.is_redirect = True # Need to set this explicitly for testing
with patch("app.api.url_upload.validate_url_safety", side_effect=HTTPException(status_code=400, detail="Unsafe URL")): assert len(hook_funcs) == 2
with pytest.raises(httpx.RequestError) as exc_info: validate_redirect = hook_funcs[0] # it was the first one
await hook_func(resp)
assert "Unsafe redirect target" in str(exc_info.value)
resp2 = httpx.Response(200, request=req) # Now we can test the hook
resp2.is_redirect = False with patch("app.api.url_upload.validate_url_safety", side_effect=HTTPException(status_code=400, detail="bad")):
await hook_func(resp2) resp = MagicMock(spec=httpx.Response)
resp.is_redirect = True
resp.headers = {"Location": "http://bad.com"}
resp.url = httpx.URL("http://example.com")
resp.request = httpx.Request("GET", "http://example.com")
with pytest.raises(httpx.RequestError) as exc:
await validate_redirect(resp)
assert "Unsafe redirect target: bad" in str(exc.value)
with patch("app.api.url_upload.validate_url_safety", return_value=None):
resp = MagicMock(spec=httpx.Response)
resp.is_redirect = True
resp.headers = {"Location": "http://good.com"}
resp.url = httpx.URL("http://example.com")
resp.request = httpx.Request("GET", "http://example.com")
await validate_redirect(resp) # should not raise
# Test no location
resp.headers = {}
await validate_redirect(resp) # should not raise
# Test not redirect
resp.is_redirect = False
await validate_redirect(resp) # should not raise