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
329 lines
12 KiB
Python
329 lines
12 KiB
Python
"""
|
|
CSRF (Cross-Site Request Forgery) Protection Tests
|
|
|
|
Tests that verify CSRF protection is properly implemented
|
|
using Flask-WTF CSRF tokens for state-changing operations.
|
|
"""
|
|
|
|
import pytest
|
|
from tests.test_utils import add_src_to_path
|
|
|
|
add_src_to_path()
|
|
|
|
|
|
class TestCSRFProtection:
|
|
"""Test CSRF protection in web forms and API endpoints."""
|
|
|
|
@pytest.fixture
|
|
def app(self):
|
|
"""Create a test Flask app instance."""
|
|
from local_deep_research.web.app import create_app
|
|
|
|
app, _ = create_app() # Unpack tuple (app, socket_service)
|
|
app.config["TESTING"] = True
|
|
# Enable CSRF for these tests
|
|
app.config["WTF_CSRF_ENABLED"] = True
|
|
app.config["WTF_CSRF_CHECK_DEFAULT"] = True
|
|
app.config["SECRET_KEY"] = "test-secret-key-for-csrf"
|
|
return app
|
|
|
|
@pytest.fixture
|
|
def client(self, app):
|
|
"""Create a test client."""
|
|
return app.test_client()
|
|
|
|
@pytest.fixture
|
|
def client_no_csrf(self):
|
|
"""Create a test client with CSRF disabled for comparison."""
|
|
from local_deep_research.web.app import create_app
|
|
|
|
app, _ = create_app() # Unpack tuple (app, socket_service)
|
|
app.config["TESTING"] = True
|
|
app.config["WTF_CSRF_ENABLED"] = False
|
|
return app.test_client()
|
|
|
|
def test_csrf_token_endpoint_exists(self, client):
|
|
"""Test that CSRF token endpoint is available."""
|
|
response = client.get("/auth/csrf-token")
|
|
assert response.status_code == 200
|
|
data = response.get_json()
|
|
assert "csrf_token" in data
|
|
assert len(data["csrf_token"]) > 0
|
|
|
|
def test_csrf_token_is_unique_per_session(self, app):
|
|
"""Test that each session gets a unique CSRF token."""
|
|
with app.test_client() as client1:
|
|
response1 = client1.get("/auth/csrf-token")
|
|
token1 = response1.get_json()["csrf_token"]
|
|
|
|
with app.test_client() as client2:
|
|
response2 = client2.get("/auth/csrf-token")
|
|
token2 = response2.get_json()["csrf_token"]
|
|
|
|
# Different sessions should have different tokens
|
|
assert token1 != token2
|
|
|
|
def test_post_request_without_csrf_token_rejected(self, client):
|
|
"""Test that POST requests without CSRF token are rejected when CSRF is enabled."""
|
|
# Try to submit a form without CSRF token
|
|
response = client.post(
|
|
"/auth/login",
|
|
data={"username": "testuser", "password": "testpass"},
|
|
follow_redirects=False,
|
|
)
|
|
|
|
# Should be rejected (400 Bad Request or redirect with error)
|
|
# Flask-WTF typically returns 400 for missing CSRF token
|
|
assert response.status_code == 400, response.status_code
|
|
|
|
def test_post_request_with_invalid_csrf_token_rejected(self, client):
|
|
"""Test that POST requests with invalid CSRF token are rejected."""
|
|
# Try to submit with fake/invalid CSRF token
|
|
response = client.post(
|
|
"/auth/login",
|
|
data={
|
|
"username": "testuser",
|
|
"password": "testpass",
|
|
"csrf_token": "invalid-fake-token-12345",
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
|
|
# Should be rejected
|
|
assert response.status_code == 400, response.status_code
|
|
|
|
def test_post_request_with_valid_csrf_token_accepted(self, client):
|
|
"""Test that POST requests with valid CSRF token are processed."""
|
|
# Get a valid CSRF token
|
|
csrf_response = client.get("/auth/csrf-token")
|
|
csrf_token = csrf_response.get_json()["csrf_token"]
|
|
|
|
# Submit request with valid CSRF token
|
|
response = client.post(
|
|
"/auth/login",
|
|
data={
|
|
"username": "testuser",
|
|
"password": "testpass",
|
|
"csrf_token": csrf_token,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
|
|
# Should not be rejected due to CSRF (which would be 400 with CSRF error message)
|
|
# May return other status codes for invalid credentials, but not CSRF-related 400
|
|
# If it's 400, check it's not a CSRF error
|
|
if response.status_code == 400:
|
|
# Check if it's a CSRF error specifically
|
|
response_data = response.get_data(as_text=True)
|
|
# CSRF errors typically contain "CSRF" in the response
|
|
assert "csrf" not in response_data.lower(), (
|
|
f"CSRF validation failed even with valid token: {response_data}"
|
|
)
|
|
|
|
def test_csrf_token_in_json_requests(self, client):
|
|
"""Test CSRF protection for JSON API requests."""
|
|
# Get CSRF token
|
|
# audit: PUNCHLIST reviewed 2026-05 — issue resolved by prior PR (recommendation: REWRITE).
|
|
csrf_response = client.get("/auth/csrf-token")
|
|
csrf_token = csrf_response.get_json()["csrf_token"]
|
|
|
|
# Try POST request without CSRF token in headers
|
|
client.post(
|
|
"/api/v1/research",
|
|
json={"query": "test query"},
|
|
content_type="application/json",
|
|
)
|
|
|
|
# Try POST request with CSRF token in headers
|
|
client.post(
|
|
"/api/v1/research",
|
|
json={"query": "test query"},
|
|
headers={"X-CSRFToken": csrf_token},
|
|
content_type="application/json",
|
|
)
|
|
|
|
# Without token might be rejected (depending on implementation)
|
|
# With token should be processed (may have other validation)
|
|
# This documents expected behavior
|
|
|
|
# Note: Some APIs may exempt certain endpoints from CSRF
|
|
# (e.g., if using token-based auth instead of cookies)
|
|
|
|
def test_csrf_token_changes_on_regeneration(self, client):
|
|
"""Test that CSRF tokens can be regenerated."""
|
|
# Get first token
|
|
response1 = client.get("/auth/csrf-token")
|
|
token1 = response1.get_json()["csrf_token"]
|
|
|
|
# Get second token (same session)
|
|
response2 = client.get("/auth/csrf-token")
|
|
token2 = response2.get_json()["csrf_token"]
|
|
|
|
# Tokens should be stable within same session
|
|
# Or may regenerate on each request (implementation dependent)
|
|
assert isinstance(token1, str)
|
|
assert isinstance(token2, str)
|
|
|
|
def test_csrf_protection_on_state_changing_operations(self, client_no_csrf):
|
|
"""Test that state-changing operations require CSRF protection."""
|
|
# State-changing operations that should require CSRF:
|
|
# - Login/Logout
|
|
# - Creating research
|
|
# - Deleting research
|
|
# - Updating settings
|
|
# - Any POST, PUT, DELETE, PATCH requests
|
|
|
|
# Safe operations (no CSRF needed):
|
|
# - GET requests (should be idempotent)
|
|
# - HEAD, OPTIONS requests
|
|
|
|
# Test that GET requests don't require CSRF
|
|
get_response = client_no_csrf.get("/")
|
|
assert get_response.status_code in [200, 302, 404] # Should work
|
|
|
|
# POST should ideally require CSRF (tested above)
|
|
# This is a documentation test
|
|
assert True
|
|
|
|
@pytest.mark.skip(reason="documentation/placeholder test - not implemented")
|
|
def test_csrf_token_not_leaked_in_logs_or_urls(self):
|
|
"""Test that CSRF tokens are not leaked in logs or URLs."""
|
|
# CSRF tokens should:
|
|
# 1. Not appear in URL query parameters (use POST body or headers)
|
|
# 2. Not be logged to console or log files
|
|
# 3. Not be exposed in error messages
|
|
# 4. Be transmitted over HTTPS only in production
|
|
|
|
# This is a security best practice documentation test
|
|
|
|
# CSRF tokens should be in:
|
|
# - Hidden form fields
|
|
# - Request headers (X-CSRFToken)
|
|
# - Request body (for form submissions)
|
|
|
|
# CSRF tokens should NOT be in:
|
|
# - URL query parameters (e.g., ?csrf=token)
|
|
# - Referer headers
|
|
# - Log files
|
|
|
|
assert True # Documentation test
|
|
|
|
def test_double_submit_cookie_pattern(self, client):
|
|
"""Test double-submit cookie CSRF protection pattern (if implemented)."""
|
|
# Double-submit cookie pattern:
|
|
# 1. Server sets CSRF token in cookie
|
|
# 2. Client must include same token in request header/body
|
|
# 3. Server verifies cookie matches header/body
|
|
|
|
# Flask-WTF uses session-based CSRF tokens by default
|
|
# This test documents the CSRF protection mechanism
|
|
|
|
# Get CSRF token
|
|
response = client.get("/auth/csrf-token")
|
|
token = response.get_json()["csrf_token"]
|
|
|
|
# Token should be associated with session
|
|
assert token is not None
|
|
assert len(token) > 0
|
|
|
|
def test_csrf_protection_exempt_endpoints(self, client):
|
|
"""Test that some endpoints may be exempt from CSRF protection."""
|
|
# Some endpoints may be intentionally exempt from CSRF:
|
|
# - Health check endpoint
|
|
# - Webhook endpoints (verified by other means)
|
|
# - Public read-only APIs
|
|
|
|
# Health check should work without CSRF
|
|
response = client.get("/api/v1/health")
|
|
assert response.status_code == 200
|
|
|
|
# This documents that certain endpoints don't need CSRF
|
|
assert True
|
|
|
|
|
|
class TestCSRFProtectionDocumentation:
|
|
"""Documentation tests for CSRF protection strategy."""
|
|
|
|
@pytest.mark.skip(reason="documentation/placeholder test - not implemented")
|
|
def test_csrf_protection_strategy_documentation(self):
|
|
"""
|
|
Document CSRF protection strategy for LDR.
|
|
|
|
CSRF Protection Mechanisms:
|
|
1. Flask-WTF CSRF tokens for web forms
|
|
2. Token validation on all state-changing operations (POST/PUT/DELETE)
|
|
3. CSRF token available via /auth/csrf-token endpoint for API clients
|
|
4. Tokens tied to user session
|
|
|
|
How CSRF Works:
|
|
1. Attacker tricks victim into visiting malicious site
|
|
2. Malicious site sends forged request to legitimate site
|
|
3. Request uses victim's cookies (auto-sent by browser)
|
|
4. Without CSRF protection, legitimate site executes unwanted action
|
|
|
|
CSRF Protection:
|
|
- Require CSRF token with each state-changing request
|
|
- Token is tied to user's session
|
|
- Attacker cannot obtain victim's token (same-origin policy)
|
|
- Forged requests without valid token are rejected
|
|
|
|
LDR-Specific Considerations:
|
|
- Local/self-hosted tool: CSRF risk is lower than multi-user SaaS
|
|
- Still important for web interface security
|
|
- API clients must obtain CSRF token before making requests
|
|
|
|
Protected Operations:
|
|
- User authentication (login/logout)
|
|
- Research creation/deletion
|
|
- Settings updates
|
|
- Any database modifications
|
|
|
|
Exempt Operations:
|
|
- Read-only GET requests
|
|
- Public API endpoints (if using API key auth instead)
|
|
- Health checks
|
|
"""
|
|
assert True # Documentation test
|
|
|
|
@pytest.mark.skip(reason="documentation/placeholder test - not implemented")
|
|
def test_csrf_vs_cors_clarification(self):
|
|
"""
|
|
Clarify difference between CSRF and CORS.
|
|
|
|
CSRF (Cross-Site Request Forgery):
|
|
- Attack: Malicious site makes unauthorized requests on behalf of user
|
|
- Protection: CSRF tokens, SameSite cookies
|
|
- Scope: Protects against forged state-changing requests
|
|
|
|
CORS (Cross-Origin Resource Sharing):
|
|
- Feature: Allows controlled access to resources from different origins
|
|
- Protection: Controls which external sites can make requests
|
|
- Scope: Browser security policy for cross-origin requests
|
|
|
|
Both are needed for comprehensive web security.
|
|
"""
|
|
assert True # Documentation test
|
|
|
|
|
|
@pytest.mark.skip(reason="documentation/placeholder test - not implemented")
|
|
def test_csrf_integration_with_authentication():
|
|
"""
|
|
Test that CSRF protection works correctly with authentication.
|
|
|
|
CSRF and Authentication:
|
|
- CSRF protects authenticated users from forged requests
|
|
- Attacker cannot forge requests even with victim's session cookie
|
|
- CSRF token is separate from authentication token/session
|
|
- Both are required for state-changing authenticated operations
|
|
|
|
Authentication Flow with CSRF:
|
|
1. User authenticates (gets session cookie)
|
|
2. User obtains CSRF token
|
|
3. User makes authenticated request with both session and CSRF token
|
|
4. Server validates both authentication and CSRF token
|
|
5. Only then is request processed
|
|
|
|
This provides defense-in-depth security.
|
|
"""
|
|
assert True # Documentation test
|