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
2090 lines
75 KiB
Python
2090 lines
75 KiB
Python
"""
|
|
SSRF Validator Tests
|
|
|
|
Tests for the SSRF (Server-Side Request Forgery) protection that validates URLs
|
|
before making outgoing HTTP requests.
|
|
|
|
Security model:
|
|
- By default, block all private/internal IPs (RFC1918, localhost, link-local, CGNAT)
|
|
- allow_localhost=True: Allow only loopback addresses (127.x.x.x, ::1)
|
|
- allow_private_ips=True: Allow all private/internal IPs + localhost:
|
|
- RFC1918: 10.x.x.x, 172.16-31.x.x, 192.168.x.x
|
|
- CGNAT: 100.64.x.x (used by Podman/rootless containers)
|
|
- Link-local: 169.254.x.x (except cloud metadata endpoints)
|
|
- IPv6 ULA: fc00::/7
|
|
- IPv6 Link-local: fe80::/10
|
|
- Cloud metadata endpoints (AWS IMDS / ECS, Azure, OCI, DigitalOcean,
|
|
AlibabaCloud, Tencent — see ALWAYS_BLOCKED_METADATA_IPS) are ALWAYS blocked
|
|
|
|
The allow_private_ips parameter is designed for trusted self-hosted services like
|
|
SearXNG or Ollama that may be running in containerized environments (Docker, Podman)
|
|
or on a different machine on the local network.
|
|
"""
|
|
|
|
import socket
|
|
|
|
import pytest
|
|
from unittest.mock import patch
|
|
|
|
from tests.test_utils import add_src_to_path
|
|
|
|
add_src_to_path()
|
|
|
|
|
|
class TestIsIpBlocked:
|
|
"""Test the is_ip_blocked function."""
|
|
|
|
def test_localhost_blocked_by_default(self):
|
|
"""Localhost should be blocked by default."""
|
|
from local_deep_research.security.ssrf_validator import (
|
|
is_ip_blocked,
|
|
)
|
|
|
|
assert is_ip_blocked("127.0.0.1") is True
|
|
assert is_ip_blocked("127.0.0.2") is True
|
|
|
|
def test_localhost_allowed_with_allow_localhost(self):
|
|
"""Localhost should be allowed with allow_localhost=True."""
|
|
from local_deep_research.security.ssrf_validator import (
|
|
is_ip_blocked,
|
|
)
|
|
|
|
assert is_ip_blocked("127.0.0.1", allow_localhost=True) is False
|
|
assert is_ip_blocked("127.0.0.2", allow_localhost=True) is False
|
|
|
|
def test_private_ip_blocked_with_allow_localhost(self):
|
|
"""Private IPs should still be blocked with allow_localhost=True."""
|
|
from local_deep_research.security.ssrf_validator import (
|
|
is_ip_blocked,
|
|
)
|
|
|
|
assert is_ip_blocked("192.168.1.100", allow_localhost=True) is True
|
|
assert is_ip_blocked("10.0.0.5", allow_localhost=True) is True
|
|
assert is_ip_blocked("172.16.0.1", allow_localhost=True) is True
|
|
|
|
def test_private_ip_allowed_with_allow_private_ips(self):
|
|
"""Private IPs should be allowed with allow_private_ips=True."""
|
|
from local_deep_research.security.ssrf_validator import (
|
|
is_ip_blocked,
|
|
)
|
|
|
|
# 192.168.x.x range
|
|
assert is_ip_blocked("192.168.1.100", allow_private_ips=True) is False
|
|
assert is_ip_blocked("192.168.0.1", allow_private_ips=True) is False
|
|
assert is_ip_blocked("192.168.255.255", allow_private_ips=True) is False
|
|
|
|
# 10.x.x.x range
|
|
assert is_ip_blocked("10.0.0.1", allow_private_ips=True) is False
|
|
assert is_ip_blocked("10.255.255.255", allow_private_ips=True) is False
|
|
|
|
# 172.16-31.x.x range
|
|
assert is_ip_blocked("172.16.0.1", allow_private_ips=True) is False
|
|
assert is_ip_blocked("172.31.255.255", allow_private_ips=True) is False
|
|
|
|
def test_localhost_also_allowed_with_allow_private_ips(self):
|
|
"""Localhost should also be allowed with allow_private_ips=True."""
|
|
from local_deep_research.security.ssrf_validator import (
|
|
is_ip_blocked,
|
|
)
|
|
|
|
assert is_ip_blocked("127.0.0.1", allow_private_ips=True) is False
|
|
assert is_ip_blocked("127.0.0.2", allow_private_ips=True) is False
|
|
|
|
def test_aws_metadata_always_blocked(self):
|
|
"""AWS metadata endpoint should ALWAYS be blocked, even with allow_private_ips."""
|
|
from local_deep_research.security.ssrf_validator import (
|
|
is_ip_blocked,
|
|
)
|
|
|
|
# Without any allowlist
|
|
assert is_ip_blocked("169.254.169.254") is True
|
|
|
|
# With allow_localhost
|
|
assert is_ip_blocked("169.254.169.254", allow_localhost=True) is True
|
|
|
|
# With allow_private_ips - CRITICAL: Must still be blocked!
|
|
assert is_ip_blocked("169.254.169.254", allow_private_ips=True) is True
|
|
|
|
def test_public_ip_not_blocked(self):
|
|
"""Public IPs should not be blocked."""
|
|
from local_deep_research.security.ssrf_validator import (
|
|
is_ip_blocked,
|
|
)
|
|
|
|
assert is_ip_blocked("8.8.8.8") is False
|
|
assert is_ip_blocked("1.1.1.1") is False
|
|
assert is_ip_blocked("142.250.185.206") is False # google.com
|
|
|
|
def test_link_local_blocked(self):
|
|
"""Link-local addresses should be blocked."""
|
|
from local_deep_research.security.ssrf_validator import (
|
|
is_ip_blocked,
|
|
)
|
|
|
|
assert is_ip_blocked("169.254.1.1") is True
|
|
assert is_ip_blocked("169.254.100.100") is True
|
|
|
|
def test_cgnat_blocked_by_default(self):
|
|
"""CGNAT addresses (100.64.x.x) should be blocked by default."""
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
is_ip_blocked,
|
|
)
|
|
|
|
assert is_ip_blocked("100.64.0.1") is True
|
|
assert is_ip_blocked("100.100.100.100") is True
|
|
assert is_ip_blocked("100.127.255.255") is True
|
|
|
|
def test_cgnat_allowed_with_allow_private_ips(self):
|
|
"""CGNAT addresses (100.64.x.x) should be allowed with allow_private_ips=True."""
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
is_ip_blocked,
|
|
)
|
|
|
|
# CGNAT range used by Podman rootless containers
|
|
assert is_ip_blocked("100.64.0.1", allow_private_ips=True) is False
|
|
assert is_ip_blocked("100.100.100.100", allow_private_ips=True) is False
|
|
assert is_ip_blocked("100.127.255.255", allow_private_ips=True) is False
|
|
|
|
def test_link_local_allowed_with_allow_private_ips(self):
|
|
"""Link-local addresses (169.254.x.x) should be allowed with allow_private_ips=True."""
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
is_ip_blocked,
|
|
)
|
|
|
|
# Non-AWS-metadata link-local addresses should be allowed
|
|
assert is_ip_blocked("169.254.1.1", allow_private_ips=True) is False
|
|
assert is_ip_blocked("169.254.100.100", allow_private_ips=True) is False
|
|
# AWS metadata endpoint MUST still be blocked
|
|
assert is_ip_blocked("169.254.169.254", allow_private_ips=True) is True
|
|
|
|
def test_ipv6_ula_blocked_by_default(self):
|
|
"""IPv6 Unique Local Addresses (fc00::/7) should be blocked by default."""
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
is_ip_blocked,
|
|
)
|
|
|
|
assert is_ip_blocked("fc00::1") is True
|
|
assert is_ip_blocked("fd00::1") is True
|
|
|
|
def test_ipv6_ula_allowed_with_allow_private_ips(self):
|
|
"""IPv6 ULA (fc00::/7) should be allowed with allow_private_ips=True."""
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
is_ip_blocked,
|
|
)
|
|
|
|
assert is_ip_blocked("fc00::1", allow_private_ips=True) is False
|
|
assert is_ip_blocked("fd00::1", allow_private_ips=True) is False
|
|
|
|
def test_ipv6_link_local_blocked_by_default(self):
|
|
"""IPv6 link-local addresses (fe80::/10) should be blocked by default."""
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
is_ip_blocked,
|
|
)
|
|
|
|
assert is_ip_blocked("fe80::1") is True
|
|
assert is_ip_blocked("fe80::1234:5678") is True
|
|
|
|
def test_ipv6_link_local_allowed_with_allow_private_ips(self):
|
|
"""IPv6 link-local (fe80::/10) should be allowed with allow_private_ips=True."""
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
is_ip_blocked,
|
|
)
|
|
|
|
assert is_ip_blocked("fe80::1", allow_private_ips=True) is False
|
|
assert is_ip_blocked("fe80::1234:5678", allow_private_ips=True) is False
|
|
|
|
|
|
class TestValidateUrl:
|
|
"""Test the validate_url function."""
|
|
|
|
def test_public_url_allowed(self):
|
|
"""Public URLs should be allowed."""
|
|
from local_deep_research.security.ssrf_validator import validate_url
|
|
|
|
with patch("socket.getaddrinfo") as mock_getaddrinfo:
|
|
# Mock DNS resolution to return a public IP
|
|
mock_getaddrinfo.return_value = [
|
|
(2, 1, 6, "", ("142.250.185.206", 0))
|
|
]
|
|
assert validate_url("https://google.com") is True
|
|
|
|
def test_localhost_url_blocked_by_default(self):
|
|
"""Localhost URLs should be blocked by default."""
|
|
from local_deep_research.security.ssrf_validator import validate_url
|
|
|
|
assert validate_url("http://127.0.0.1:8080") is False
|
|
assert validate_url("http://localhost:8080") is False
|
|
|
|
def test_localhost_url_allowed_with_allow_localhost(self):
|
|
"""Localhost URLs should be allowed with allow_localhost=True."""
|
|
from local_deep_research.security.ssrf_validator import validate_url
|
|
|
|
with patch("socket.getaddrinfo") as mock_getaddrinfo:
|
|
mock_getaddrinfo.return_value = [(2, 1, 6, "", ("127.0.0.1", 0))]
|
|
assert (
|
|
validate_url("http://localhost:8080", allow_localhost=True)
|
|
is True
|
|
)
|
|
|
|
assert (
|
|
validate_url("http://127.0.0.1:8080", allow_localhost=True) is True
|
|
)
|
|
|
|
def test_private_ip_url_blocked_with_allow_localhost(self):
|
|
"""Private IP URLs should still be blocked with allow_localhost=True."""
|
|
from local_deep_research.security.ssrf_validator import validate_url
|
|
|
|
assert (
|
|
validate_url("http://192.168.1.100:8080", allow_localhost=True)
|
|
is False
|
|
)
|
|
assert (
|
|
validate_url("http://10.0.0.5:8080", allow_localhost=True) is False
|
|
)
|
|
|
|
def test_private_ip_url_allowed_with_allow_private_ips(self):
|
|
"""Private IP URLs should be allowed with allow_private_ips=True."""
|
|
from local_deep_research.security.ssrf_validator import validate_url
|
|
|
|
# 192.168.x.x - typical home network
|
|
assert (
|
|
validate_url("http://192.168.1.100:8080", allow_private_ips=True)
|
|
is True
|
|
)
|
|
assert (
|
|
validate_url("http://192.168.0.1:80", allow_private_ips=True)
|
|
is True
|
|
)
|
|
|
|
# 10.x.x.x - typical corporate network
|
|
assert (
|
|
validate_url("http://10.0.0.5:8080", allow_private_ips=True) is True
|
|
)
|
|
assert (
|
|
validate_url("http://10.10.10.10:3000", allow_private_ips=True)
|
|
is True
|
|
)
|
|
|
|
# 172.16-31.x.x - Docker default network etc.
|
|
assert (
|
|
validate_url("http://172.16.0.1:8080", allow_private_ips=True)
|
|
is True
|
|
)
|
|
assert (
|
|
validate_url("http://172.20.0.2:5000", allow_private_ips=True)
|
|
is True
|
|
)
|
|
|
|
def test_aws_metadata_url_always_blocked(self):
|
|
"""AWS metadata URL should ALWAYS be blocked."""
|
|
from local_deep_research.security.ssrf_validator import validate_url
|
|
|
|
aws_metadata_url = "http://169.254.169.254/latest/meta-data"
|
|
|
|
# Without any allowlist
|
|
assert validate_url(aws_metadata_url) is False
|
|
|
|
# With allow_localhost
|
|
assert validate_url(aws_metadata_url, allow_localhost=True) is False
|
|
|
|
# With allow_private_ips - CRITICAL: Must still be blocked!
|
|
assert validate_url(aws_metadata_url, allow_private_ips=True) is False
|
|
|
|
def test_invalid_scheme_blocked(self):
|
|
"""Invalid schemes should be blocked."""
|
|
from local_deep_research.security.ssrf_validator import validate_url
|
|
|
|
assert validate_url("ftp://example.com") is False
|
|
assert validate_url("file:///etc/passwd") is False
|
|
assert validate_url("javascript:alert(1)") is False
|
|
|
|
def test_hostname_resolving_to_private_ip_blocked(self):
|
|
"""Hostnames that resolve to private IPs should be blocked."""
|
|
from local_deep_research.security.ssrf_validator import validate_url
|
|
|
|
with patch("socket.getaddrinfo") as mock_getaddrinfo:
|
|
# Simulate a hostname resolving to a private IP (DNS rebinding attack)
|
|
mock_getaddrinfo.return_value = [(2, 1, 6, "", ("192.168.1.1", 0))]
|
|
assert validate_url("http://evil.com") is False
|
|
|
|
def test_hostname_resolving_to_private_ip_allowed_with_allow_private_ips(
|
|
self,
|
|
):
|
|
"""Hostnames resolving to private IPs allowed with allow_private_ips=True."""
|
|
from local_deep_research.security.ssrf_validator import validate_url
|
|
|
|
with patch("socket.getaddrinfo") as mock_getaddrinfo:
|
|
mock_getaddrinfo.return_value = [
|
|
(2, 1, 6, "", ("192.168.1.100", 0))
|
|
]
|
|
assert (
|
|
validate_url("http://my-searxng.local", allow_private_ips=True)
|
|
is True
|
|
)
|
|
|
|
|
|
class TestSearXNGUseCase:
|
|
"""Test the specific SearXNG use case that motivated allow_private_ips."""
|
|
|
|
def test_searxng_on_localhost(self):
|
|
"""SearXNG on localhost should work with allow_private_ips."""
|
|
from local_deep_research.security.ssrf_validator import validate_url
|
|
|
|
with patch("socket.getaddrinfo") as mock_getaddrinfo:
|
|
mock_getaddrinfo.return_value = [(2, 1, 6, "", ("127.0.0.1", 0))]
|
|
assert (
|
|
validate_url("http://localhost:8080", allow_private_ips=True)
|
|
is True
|
|
)
|
|
|
|
assert (
|
|
validate_url("http://127.0.0.1:8080", allow_private_ips=True)
|
|
is True
|
|
)
|
|
|
|
def test_searxng_on_lan(self):
|
|
"""SearXNG on LAN should work with allow_private_ips."""
|
|
from local_deep_research.security.ssrf_validator import validate_url
|
|
|
|
# Home network
|
|
assert (
|
|
validate_url("http://192.168.1.100:8080", allow_private_ips=True)
|
|
is True
|
|
)
|
|
|
|
# NAS or server on network
|
|
assert (
|
|
validate_url("http://10.0.0.50:8888", allow_private_ips=True)
|
|
is True
|
|
)
|
|
|
|
# Docker network
|
|
assert (
|
|
validate_url("http://172.17.0.2:8080", allow_private_ips=True)
|
|
is True
|
|
)
|
|
|
|
def test_searxng_hostname_on_lan(self):
|
|
"""SearXNG with hostname on LAN should work with allow_private_ips."""
|
|
from local_deep_research.security.ssrf_validator import validate_url
|
|
|
|
with patch("socket.getaddrinfo") as mock_getaddrinfo:
|
|
# Simulate local DNS or /etc/hosts entry
|
|
mock_getaddrinfo.return_value = [(2, 1, 6, "", ("192.168.1.50", 0))]
|
|
assert (
|
|
validate_url(
|
|
"http://searxng.local:8080", allow_private_ips=True
|
|
)
|
|
is True
|
|
)
|
|
|
|
|
|
class TestContainerNetworking:
|
|
"""Test container networking scenarios (Podman, Docker, etc.)."""
|
|
|
|
def test_podman_host_containers_internal(self):
|
|
"""Podman's host.containers.internal (resolves to CGNAT) should work with allow_private_ips."""
|
|
from src.local_deep_research.security.ssrf_validator import validate_url
|
|
|
|
with patch("socket.getaddrinfo") as mock_getaddrinfo:
|
|
# Podman rootless containers typically resolve host.containers.internal to 100.64.x.x
|
|
mock_getaddrinfo.return_value = [(2, 1, 6, "", ("100.64.1.1", 0))]
|
|
assert (
|
|
validate_url(
|
|
"http://host.containers.internal:11434",
|
|
allow_private_ips=True,
|
|
)
|
|
is True
|
|
)
|
|
|
|
def test_ollama_in_podman(self):
|
|
"""Ollama running on host accessible via Podman's CGNAT should work."""
|
|
from src.local_deep_research.security.ssrf_validator import validate_url
|
|
|
|
with patch("socket.getaddrinfo") as mock_getaddrinfo:
|
|
# Ollama on host via Podman CGNAT
|
|
mock_getaddrinfo.return_value = [
|
|
(2, 1, 6, "", ("100.100.100.100", 0))
|
|
]
|
|
assert (
|
|
validate_url(
|
|
"http://host.containers.internal:11434/api/generate",
|
|
allow_private_ips=True,
|
|
)
|
|
is True
|
|
)
|
|
|
|
def test_searxng_in_podman(self):
|
|
"""SearXNG running on host accessible via Podman's CGNAT should work."""
|
|
from src.local_deep_research.security.ssrf_validator import validate_url
|
|
|
|
with patch("socket.getaddrinfo") as mock_getaddrinfo:
|
|
# SearXNG on host via Podman CGNAT
|
|
mock_getaddrinfo.return_value = [(2, 1, 6, "", ("100.64.0.1", 0))]
|
|
assert (
|
|
validate_url(
|
|
"http://host.containers.internal:8080/search",
|
|
allow_private_ips=True,
|
|
)
|
|
is True
|
|
)
|
|
|
|
def test_cgnat_url_blocked_by_default(self):
|
|
"""CGNAT URLs should be blocked by default (without allow_private_ips)."""
|
|
from src.local_deep_research.security.ssrf_validator import validate_url
|
|
|
|
# Direct CGNAT IP
|
|
assert validate_url("http://100.64.0.1:8080") is False
|
|
|
|
with patch("socket.getaddrinfo") as mock_getaddrinfo:
|
|
mock_getaddrinfo.return_value = [(2, 1, 6, "", ("100.64.1.1", 0))]
|
|
assert (
|
|
validate_url("http://host.containers.internal:11434") is False
|
|
)
|
|
|
|
def test_docker_bridge_network(self):
|
|
"""Docker bridge network IPs should work with allow_private_ips."""
|
|
from src.local_deep_research.security.ssrf_validator import validate_url
|
|
|
|
# Docker typically uses 172.17.x.x for bridge network
|
|
assert (
|
|
validate_url("http://172.17.0.2:8080", allow_private_ips=True)
|
|
is True
|
|
)
|
|
|
|
|
|
class TestParserDifferentialBypass:
|
|
"""
|
|
Tests for the parser-differential SSRF bypass (GHSA-g23j-2vwm-5c25).
|
|
|
|
Python's ``urllib.parse.urlparse`` and the ``requests``/``urllib3``
|
|
parser disagree on URLs that contain a backslash before the userinfo
|
|
``@``. ``urlparse`` treats ``\\`` as a literal char and ``@`` as the
|
|
userinfo separator (so it extracts the post-``@`` host); ``requests``
|
|
treats ``\\`` as a path delimiter and connects to the pre-``\\`` host.
|
|
|
|
A pre-fix ``validate_url`` based on ``urlparse(url).hostname`` would
|
|
pass URLs like ``http://127.0.0.1\\@1.1.1.1`` (it sees ``1.1.1.1``)
|
|
while ``requests.get(url)`` would actually connect to ``127.0.0.1``.
|
|
The fix combines a Layer-1 reject of RFC-illegal characters with a
|
|
Layer-2 swap to ``urllib3.util.parse_url`` for hostname extraction.
|
|
"""
|
|
|
|
def test_advisory_canonical_payload(self):
|
|
"""The exact PoC from the advisory must be rejected."""
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url("http://127.0.0.1:6666\\@1.1.1.1") is False
|
|
|
|
def test_backslash_no_port(self):
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url("http://127.0.0.1\\@1.1.1.1") is False
|
|
|
|
def test_double_backslash(self):
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url("http://127.0.0.1\\\\@1.1.1.1") is False
|
|
|
|
def test_slash_then_backslash(self):
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url("http://127.0.0.1/\\@1.1.1.1") is False
|
|
|
|
def test_tab_at_seam(self):
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url("http://127.0.0.1\t@1.1.1.1") is False
|
|
|
|
def test_carriage_return_at_seam(self):
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url("http://127.0.0.1\r@1.1.1.1") is False
|
|
|
|
def test_newline_at_seam(self):
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url("http://127.0.0.1\n@1.1.1.1") is False
|
|
|
|
def test_space_at_seam(self):
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url("http://127.0.0.1 @1.1.1.1") is False
|
|
|
|
def test_ipv6_loopback_with_backslash(self):
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url("http://[::1]\\@1.1.1.1") is False
|
|
|
|
def test_ipv4_mapped_ipv6_with_backslash(self):
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url("http://[::ffff:127.0.0.1]\\@1.1.1.1") is False
|
|
|
|
def test_backslash_with_trailing_port(self):
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url("http://127.0.0.1\\@1.1.1.1:80") is False
|
|
|
|
def test_trailing_dot_loopback_with_backslash(self):
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url("http://127.0.0.1.\\@1.1.1.1") is False
|
|
|
|
def test_null_byte_in_userinfo(self):
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url("http://127.0.0.1\x00@1.1.1.1") is False
|
|
|
|
def test_idn_unicode_host_rejected(self):
|
|
"""IDN/Unicode hosts are rejected by urllib3 / ASCII guard."""
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
# Circled-digit homoglyphs of '127' resolve via NFKC to '127' on
|
|
# some libcs. urllib3 currently rejects these via
|
|
# LocationParseError; the ASCII-printable guard backs that up.
|
|
assert validate_url("http://①②⑦.0.0.1/") is False
|
|
|
|
def test_octal_ip_resolves_to_loopback(self):
|
|
"""Octal IP form '0177.0.0.1' resolves to 127.0.0.1 via getaddrinfo."""
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
with patch(
|
|
"socket.getaddrinfo",
|
|
return_value=[(2, 1, 6, "", ("127.0.0.1", 0))],
|
|
):
|
|
assert validate_url("http://0177.0.0.1/") is False
|
|
|
|
def test_decimal_int_ip_resolves_to_loopback(self):
|
|
"""Decimal-int IP form '2130706433' resolves to 127.0.0.1."""
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
with patch(
|
|
"socket.getaddrinfo",
|
|
return_value=[(2, 1, 6, "", ("127.0.0.1", 0))],
|
|
):
|
|
assert validate_url("http://2130706433/") is False
|
|
|
|
def test_post_prepare_canonicalised_form(self):
|
|
"""
|
|
Layer-2 verification: when ``requests.PreparedRequest.url``
|
|
canonicalises ``\\`` to ``%5C``, the urllib3-based hostname
|
|
extraction still returns ``127.0.0.1`` so the IP check fires.
|
|
Layer 1 doesn't match ``%5C`` (it's three printable ASCII chars);
|
|
Layer 2 is the load-bearing defence on the SafeSession.send path.
|
|
"""
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url("http://127.0.0.1:6666/%5C@1.1.1.1") is False
|
|
|
|
def test_backslash_deep_in_path(self):
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url("http://example.com/path\\@1.1.1.1") is False
|
|
|
|
def test_backslash_in_userinfo_password(self):
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url("http://user:pass\\@127.0.0.1/") is False
|
|
|
|
def test_backslash_with_port_on_trailing_host(self):
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url("http://127.0.0.1\\@evil.com:8080") is False
|
|
|
|
def test_interior_whitespace_at_seam(self):
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url("http://127.0.0.1 \\@1.1.1.1") is False
|
|
|
|
def test_ipv6_unspecified_blocked(self):
|
|
"""``::`` is the IPv6 unspecified address — Linux routes
|
|
connections to ``[::]:port`` to a service bound on ``[::1]:port``,
|
|
so it must be blocked alongside ``0.0.0.0`` (the IPv4 equivalent,
|
|
already covered via 0.0.0.0/8 in BLOCKED_IP_RANGES)."""
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url("http://[::]/") is False
|
|
|
|
def test_ipv6_unspecified_zero_form_blocked(self):
|
|
"""Equivalent representation ``0::`` — must normalise to ``::``
|
|
before the IP-range check or this bypasses the literal-string
|
|
allow-list in notification_validator."""
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url("http://[0::]/") is False
|
|
|
|
def test_ipv6_unspecified_full_form_blocked(self):
|
|
"""Equivalent representation ``0:0:0:0:0:0:0:0``."""
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url("http://[0:0:0:0:0:0:0:0]/") is False
|
|
|
|
|
|
class TestDnsResolvedBypass:
|
|
"""
|
|
The validator's load-bearing path for hostnames (not IP literals) is:
|
|
1. ``ipaddress.ip_address(hostname)`` raises ``ValueError`` (not an IP)
|
|
2. ``socket.getaddrinfo(hostname, ...)`` resolves to one or more IPs
|
|
3. Each resolved IP is checked against ``BLOCKED_IP_RANGES``
|
|
|
|
These tests exercise step 3 directly by mocking ``getaddrinfo``.
|
|
"""
|
|
|
|
def test_hostname_resolving_to_loopback_blocked(self):
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
with patch(
|
|
"socket.getaddrinfo",
|
|
return_value=[(2, 1, 6, "", ("127.0.0.1", 0))],
|
|
):
|
|
assert validate_url("http://attacker.example.com/") is False
|
|
|
|
def test_hostname_resolving_to_rfc1918_blocked(self):
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
with patch(
|
|
"socket.getaddrinfo",
|
|
return_value=[(2, 1, 6, "", ("10.0.0.5", 0))],
|
|
):
|
|
assert validate_url("http://attacker.example.com/") is False
|
|
|
|
def test_hostname_resolving_to_link_local_blocked(self):
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
with patch(
|
|
"socket.getaddrinfo",
|
|
return_value=[(2, 1, 6, "", ("169.254.1.1", 0))],
|
|
):
|
|
assert validate_url("http://attacker.example.com/") is False
|
|
|
|
def test_hostname_resolving_to_aws_metadata_blocked(self):
|
|
"""Hardcoded AWS metadata block fires even with allow_private_ips."""
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
with patch(
|
|
"socket.getaddrinfo",
|
|
return_value=[(2, 1, 6, "", ("169.254.169.254", 0))],
|
|
):
|
|
# Even with the most permissive flag, AWS metadata stays blocked.
|
|
assert (
|
|
validate_url(
|
|
"http://attacker.example.com/", allow_private_ips=True
|
|
)
|
|
is False
|
|
)
|
|
|
|
def test_multiple_resolved_ips_one_private_blocks(self):
|
|
"""
|
|
DNS returning a public IP first then a private IP must still block.
|
|
Round-robin / multi-A-record DNS could otherwise be a bypass.
|
|
"""
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
with patch(
|
|
"socket.getaddrinfo",
|
|
return_value=[
|
|
(2, 1, 6, "", ("93.184.216.34", 0)), # public
|
|
(2, 1, 6, "", ("127.0.0.1", 0)), # private — must block
|
|
],
|
|
):
|
|
assert validate_url("http://attacker.example.com/") is False
|
|
|
|
def test_dns_resolution_failure_fails_closed(self):
|
|
"""``getaddrinfo`` raising ``gaierror`` must return False (not allow)."""
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
with patch("socket.getaddrinfo", side_effect=socket.gaierror()):
|
|
assert validate_url("http://nonexistent.invalid/") is False
|
|
|
|
def test_ipv6_dns_resolution_to_loopback_blocked(self):
|
|
"""Hostname resolving to IPv6 ``::1`` must be blocked."""
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
with patch(
|
|
"socket.getaddrinfo",
|
|
return_value=[(10, 1, 6, "", ("::1", 0, 0, 0))],
|
|
):
|
|
assert validate_url("http://attacker.example.com/") is False
|
|
|
|
def test_dns_resolves_to_ipv4_mapped_ipv6_loopback_blocked(self):
|
|
"""
|
|
Hostname resolving to ``::ffff:127.0.0.1`` (IPv4-mapped IPv6) must
|
|
be blocked — exercises the IPv4-mapped unwrap in is_ip_blocked.
|
|
"""
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
with patch(
|
|
"socket.getaddrinfo",
|
|
return_value=[(10, 1, 6, "", ("::ffff:127.0.0.1", 0, 0, 0))],
|
|
):
|
|
assert validate_url("http://attacker.example.com/") is False
|
|
|
|
|
|
class TestAlternateIpFormsBlocked:
|
|
"""
|
|
Alternate textual representations of private IPv4/IPv6 addresses.
|
|
On Linux ``getaddrinfo`` accepts most of these and resolves them to
|
|
the canonical form, which the IP check then catches.
|
|
"""
|
|
|
|
def test_octal_loopback_blocked(self):
|
|
"""``0177.0.0.1`` → ``127.0.0.1`` via getaddrinfo on Linux."""
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
with patch(
|
|
"socket.getaddrinfo",
|
|
return_value=[(2, 1, 6, "", ("127.0.0.1", 0))],
|
|
):
|
|
assert validate_url("http://0177.0.0.1/") is False
|
|
|
|
def test_decimal_int_loopback_blocked(self):
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
with patch(
|
|
"socket.getaddrinfo",
|
|
return_value=[(2, 1, 6, "", ("127.0.0.1", 0))],
|
|
):
|
|
assert validate_url("http://2130706433/") is False
|
|
|
|
def test_short_ipv4_form_loopback_blocked(self):
|
|
"""``127.1`` (short form) → ``127.0.0.1`` via getaddrinfo on Linux."""
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
with patch(
|
|
"socket.getaddrinfo",
|
|
return_value=[(2, 1, 6, "", ("127.0.0.1", 0))],
|
|
):
|
|
assert validate_url("http://127.1/") is False
|
|
|
|
def test_ipv4_mapped_ipv6_loopback_literal_blocked(self):
|
|
"""``[::ffff:127.0.0.1]`` is an IPv4-mapped IPv6 of loopback."""
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url("http://[::ffff:127.0.0.1]/") is False
|
|
|
|
def test_ipv4_mapped_ipv6_rfc1918_literal_blocked(self):
|
|
"""``[::ffff:10.0.0.1]`` — IPv4-mapped IPv6 of RFC1918."""
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url("http://[::ffff:10.0.0.1]/") is False
|
|
|
|
def test_ipv4_mapped_ipv6_aws_metadata_literal_blocked(self):
|
|
"""``[::ffff:169.254.169.254]`` — AWS metadata via mapped IPv6."""
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url("http://[::ffff:169.254.169.254]/") is False
|
|
|
|
|
|
class TestAllowFlagMatrix:
|
|
"""
|
|
Verify ``allow_localhost`` / ``allow_private_ips`` flag combinations
|
|
against the new ``::/128`` blocklist entry, and confirm the AWS
|
|
metadata hardcoded block holds under all flag combinations.
|
|
"""
|
|
|
|
def test_loopback_default_blocked(self):
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url("http://127.0.0.1/") is False
|
|
|
|
def test_loopback_with_allow_localhost(self):
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url("http://127.0.0.1/", allow_localhost=True) is True
|
|
|
|
def test_loopback_with_allow_private_ips(self):
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url("http://127.0.0.1/", allow_private_ips=True) is True
|
|
|
|
def test_ipv6_loopback_with_allow_localhost(self):
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url("http://[::1]/", allow_localhost=True) is True
|
|
|
|
def test_ipv6_unspecified_blocked_even_with_allow_localhost(self):
|
|
"""
|
|
``::`` is the unspecified address, NOT the loopback address.
|
|
Linux happens to route it to local services, but conceptually
|
|
``::`` is "any address" — distinct from ``::1``.
|
|
``allow_localhost`` is therefore conservatively scoped to
|
|
``::1`` and ``127.0.0.0/8`` and does NOT permit ``::``.
|
|
"""
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url("http://[::]/", allow_localhost=True) is False
|
|
|
|
def test_ipv6_unspecified_blocked_even_with_allow_private_ips(self):
|
|
"""Same reasoning: ``::`` is not in any allowed-range carve-out."""
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url("http://[::]/", allow_private_ips=True) is False
|
|
|
|
def test_aws_metadata_blocked_under_allow_localhost(self):
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert (
|
|
validate_url("http://169.254.169.254/", allow_localhost=True)
|
|
is False
|
|
)
|
|
|
|
def test_aws_metadata_blocked_under_allow_private_ips(self):
|
|
"""
|
|
Codebase comments call this out as ALWAYS blocked. Locks in that
|
|
the most permissive flag still doesn't reach AWS metadata.
|
|
"""
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert (
|
|
validate_url("http://169.254.169.254/", allow_private_ips=True)
|
|
is False
|
|
)
|
|
|
|
|
|
class TestAlwaysBlockedMetadataIPs:
|
|
"""Cloud-metadata IPs blocked under every flag combination."""
|
|
|
|
def test_metadata_ip_blocked_under_all_flags(self):
|
|
"""Every IP in the always-blocked set must be blocked under all
|
|
allow-flag combinations."""
|
|
from local_deep_research.security.ssrf_validator import (
|
|
ALWAYS_BLOCKED_METADATA_IPS,
|
|
is_ip_blocked,
|
|
)
|
|
|
|
for ip in sorted(ALWAYS_BLOCKED_METADATA_IPS):
|
|
assert is_ip_blocked(ip) is True
|
|
assert is_ip_blocked(ip, allow_localhost=True) is True
|
|
assert is_ip_blocked(ip, allow_private_ips=True) is True
|
|
|
|
def test_validate_url_blocks_all_metadata_ips_under_allow_private_ips(self):
|
|
"""Same coverage end-to-end through validate_url."""
|
|
from local_deep_research.security.ssrf_validator import (
|
|
ALWAYS_BLOCKED_METADATA_IPS,
|
|
validate_url,
|
|
)
|
|
|
|
for ip in sorted(ALWAYS_BLOCKED_METADATA_IPS):
|
|
assert (
|
|
validate_url(f"http://{ip}/", allow_private_ips=True) is False
|
|
)
|
|
|
|
def test_dns_resolution_to_metadata_ip_blocked(self):
|
|
"""A hostname that resolves to a metadata IP must also be blocked
|
|
even when allow_private_ips=True."""
|
|
from local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
with patch(
|
|
"socket.getaddrinfo",
|
|
return_value=[(2, 1, 6, "", ("169.254.170.2", 0))],
|
|
):
|
|
assert (
|
|
validate_url("http://attacker.example/", allow_private_ips=True)
|
|
is False
|
|
)
|
|
|
|
|
|
class TestRedactUrlForLog:
|
|
"""The redact_url_for_log helper used at all log sites."""
|
|
|
|
def test_strips_userinfo(self):
|
|
from local_deep_research.security.ssrf_validator import (
|
|
redact_url_for_log,
|
|
)
|
|
|
|
assert (
|
|
redact_url_for_log("http://user:secret@example.com/path?token=x")
|
|
== "http://example.com"
|
|
)
|
|
|
|
def test_strips_percent_encoded_password(self):
|
|
from local_deep_research.security.ssrf_validator import (
|
|
redact_url_for_log,
|
|
)
|
|
|
|
assert (
|
|
redact_url_for_log("http://u:p%40ss@example.com/")
|
|
== "http://example.com"
|
|
)
|
|
|
|
def test_keeps_port(self):
|
|
from local_deep_research.security.ssrf_validator import (
|
|
redact_url_for_log,
|
|
)
|
|
|
|
assert (
|
|
redact_url_for_log("http://example.com:8080/path")
|
|
== "http://example.com:8080"
|
|
)
|
|
|
|
def test_ipv6_host_keeps_brackets(self):
|
|
from local_deep_research.security.ssrf_validator import (
|
|
redact_url_for_log,
|
|
)
|
|
|
|
assert redact_url_for_log("http://[::1]:8080/") == "http://[::1]:8080"
|
|
|
|
def test_no_scheme_uses_question_mark(self):
|
|
"""Scheme-relative URLs use '?' as the scheme sentinel."""
|
|
from local_deep_research.security.ssrf_validator import (
|
|
redact_url_for_log,
|
|
)
|
|
|
|
# urllib3 may parse '//example.com/path' with scheme=None.
|
|
result = redact_url_for_log("//example.com/path")
|
|
assert result.startswith("?://") or result == "<unparseable>"
|
|
|
|
def test_unparseable_returns_sentinel(self):
|
|
"""urllib3 rejects malformed IPv6 brackets and out-of-range
|
|
ports; helper falls back to <unparseable>."""
|
|
from local_deep_research.security.ssrf_validator import (
|
|
redact_url_for_log,
|
|
)
|
|
|
|
assert redact_url_for_log("http://[::") == "<unparseable>"
|
|
assert redact_url_for_log("http://1.2.3.4:99999") == "<unparseable>"
|
|
|
|
def test_validate_url_log_does_not_leak_userinfo(self, loguru_caplog):
|
|
"""End-to-end: validate_url's rejection log must not contain the
|
|
password from the URL's userinfo. Also assert at least one log
|
|
record was emitted, otherwise the not-in assertion is vacuously
|
|
true and we'd have false confidence."""
|
|
from local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
# Mock DNS to a public IP so the URL passes Layer 1+2 and reaches
|
|
# the IP-block log site (which does log the URL).
|
|
with (
|
|
loguru_caplog.at_level("WARNING"),
|
|
patch(
|
|
"socket.getaddrinfo",
|
|
return_value=[(2, 1, 6, "", ("127.0.0.1", 0))],
|
|
),
|
|
):
|
|
validate_url("http://user:supersecret123@evilhost.example/")
|
|
assert "supersecret123" not in loguru_caplog.text, (
|
|
"Password leaked into log output"
|
|
)
|
|
# Anti-silent-pass: verify we actually did log (otherwise the
|
|
# not-in assertion above is trivially true on empty text).
|
|
assert len(loguru_caplog.records) > 0, "No log records emitted"
|
|
|
|
|
|
class TestSchemeRejection:
|
|
"""Non-http(s) schemes must be rejected outright (not just the host check)."""
|
|
|
|
def test_file_scheme_rejected(self):
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url("file:///etc/passwd") is False
|
|
|
|
def test_ftp_scheme_rejected(self):
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url("ftp://example.com/") is False
|
|
|
|
def test_gopher_scheme_rejected(self):
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url("gopher://example.com/") is False
|
|
|
|
def test_dict_scheme_rejected(self):
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url("dict://example.com:11211/stat") is False
|
|
|
|
def test_no_scheme_rejected(self):
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url("127.0.0.1") is False
|
|
|
|
def test_scheme_relative_url_rejected(self):
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url("//127.0.0.1/") is False
|
|
|
|
def test_uppercase_https_scheme_accepted(self):
|
|
"""Schemes are case-insensitive per RFC 3986."""
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
with patch(
|
|
"socket.getaddrinfo",
|
|
return_value=[(2, 1, 6, "", ("93.184.216.34", 0))],
|
|
):
|
|
assert validate_url("HTTPS://example.com/") is True
|
|
|
|
|
|
class TestNeverRaises:
|
|
"""
|
|
Property-based-style robustness: ``validate_url`` is a security
|
|
boundary that takes untrusted input. It must NEVER raise — only
|
|
return ``True``/``False``. A crash here is a DoS vector.
|
|
"""
|
|
|
|
@pytest.mark.parametrize(
|
|
"weird_input",
|
|
[
|
|
"",
|
|
" ",
|
|
"\x00",
|
|
"\x00" * 100,
|
|
":",
|
|
"::",
|
|
"://",
|
|
"http",
|
|
"http:",
|
|
"http:/",
|
|
"http://",
|
|
"http:// ",
|
|
"http://[",
|
|
"http://[::",
|
|
"http://]",
|
|
"http://@",
|
|
"http://@@@",
|
|
"http://:@",
|
|
"http://:80",
|
|
"http://:0",
|
|
"http://example.com:99999999", # overflow port
|
|
"http://example.com:-1", # negative port
|
|
"http://%00",
|
|
"http://%2F%2F",
|
|
"h" * 10_000,
|
|
"http://" + "a" * 100_000, # huge URL
|
|
"http://" + "[" * 100,
|
|
"http://." + ("a." * 1000) + "com",
|
|
"http://example.com/" + "?" * 1000,
|
|
"\udcff", # lone surrogate (Python str-only, raises on encode)
|
|
"http://\udcff/",
|
|
],
|
|
)
|
|
def test_pathological_input_returns_bool(self, weird_input):
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
result = validate_url(weird_input)
|
|
assert isinstance(result, bool)
|
|
|
|
|
|
class TestIPv6TransitionPrefixesBlocked:
|
|
"""IPv6 transition prefixes (6to4, NAT64, Teredo, discard) are now
|
|
blocked. On Linux hosts with kernel sit0/NAT64 routes configured,
|
|
these prefixes wrap private IPv4 destinations. Default Linux has no
|
|
such routes (so this isn't exploitable in the typical deployment),
|
|
but blocking them closes the gap for operators who do enable
|
|
transition tunnels."""
|
|
|
|
def test_6to4_wrapped_loopback_blocked(self):
|
|
"""``[2002:7f00:1::]`` — 6to4 wrap of 127.0.0.1."""
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url("http://[2002:7f00:1::]/") is False
|
|
|
|
def test_6to4_wrapped_rfc1918_blocked(self):
|
|
"""``[2002:c0a8:101::]`` — 6to4 wrap of 192.168.1.1."""
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url("http://[2002:c0a8:101::]/") is False
|
|
|
|
def test_nat64_wrapped_loopback_blocked(self):
|
|
"""``[64:ff9b::7f00:1]`` — NAT64 wrap of 127.0.0.1."""
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url("http://[64:ff9b::7f00:1]/") is False
|
|
|
|
def test_teredo_prefix_blocked(self):
|
|
"""Teredo (2001::/32) tunnels IPv6-over-UDP/IPv4."""
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url("http://[2001::1]/") is False
|
|
|
|
def test_ipv6_discard_prefix_blocked(self):
|
|
"""RFC 6666 discard prefix (100::/64) is reserved for sinkholes."""
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url("http://[100::1]/") is False
|
|
|
|
def test_6to4_wraps_aws_metadata_blocked(self):
|
|
"""[2002:a9fe:a9fe::] — 6to4 wrap of 169.254.169.254 (AWS IMDS).
|
|
Cloud metadata is the highest-value SSRF target; the 2002::/16
|
|
block is what catches this case (the IMDS hardcoded literal
|
|
check is on the IPv4 form only)."""
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url("http://[2002:a9fe:a9fe::]/") is False
|
|
|
|
def test_nat64_wraps_aws_metadata_blocked(self):
|
|
"""[64:ff9b::a9fe:a9fe] — NAT64 wrap of 169.254.169.254 (AWS IMDS)."""
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url("http://[64:ff9b::a9fe:a9fe]/") is False
|
|
|
|
def test_6to4_wraps_rfc1918_class_a_blocked(self):
|
|
"""[2002:0a00:1::] — 6to4 wrap of 10.0.0.1."""
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url("http://[2002:0a00:1::]/") is False
|
|
|
|
def test_6to4_wraps_rfc1918_class_b_blocked(self):
|
|
"""[2002:ac10:1::] — 6to4 wrap of 172.16.0.1."""
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url("http://[2002:ac10:1::]/") is False
|
|
|
|
def test_nat64_wraps_rfc1918_class_a_blocked(self):
|
|
"""[64:ff9b::a00:1] — NAT64 wrap of 10.0.0.1."""
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url("http://[64:ff9b::a00:1]/") is False
|
|
|
|
def test_nat64_local_use_prefix_blocked(self):
|
|
"""RFC 8215's 64:ff9b:1::/48 (NAT64 local-use) is the same SSRF
|
|
threat class as the well-known /96. On hosts configured to route
|
|
the local-use prefix, [64:ff9b:1::a9fe:a9fe] reaches AWS IMDS
|
|
identically to the WKP form. Missing this prefix earned a
|
|
HackerOne bounty against the Ruby ssrf_filter library."""
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url("http://[64:ff9b:1::1]/") is False
|
|
|
|
def test_nat64_local_use_wraps_aws_metadata_blocked(self):
|
|
"""[64:ff9b:1::a9fe:a9fe] — local-use NAT64 wrap of 169.254.169.254."""
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url("http://[64:ff9b:1::a9fe:a9fe]/") is False
|
|
|
|
def test_ipv4_compatible_imds_blocked(self):
|
|
"""[::169.254.169.254] — RFC 4291 IPv4-Compatible IPv6 form
|
|
(DEPRECATED 2006). On hosts with ::/96 routes this reaches IMDS
|
|
identically to the IPv4-mapped and NAT64-wrapped forms. Same
|
|
defense-in-depth class as the transition prefixes."""
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url("http://[::169.254.169.254]/") is False
|
|
|
|
def test_ipv4_compatible_imds_hex_form_blocked(self):
|
|
"""Same address, hex form: [::a9fe:a9fe]."""
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url("http://[::a9fe:a9fe]/") is False
|
|
|
|
def test_ipv4_compatible_rfc1918_blocked(self):
|
|
"""[::192.168.1.1] — IPv4-Compatible wrap of RFC1918."""
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url("http://[::192.168.1.1]/") is False
|
|
|
|
|
|
class TestIPv6TransitionPrefixesAllowFlagMatrix:
|
|
"""Lock in the design decision: ``allow_private_ips=True`` does NOT
|
|
bypass the IPv6 transition prefixes (2002::/16, 64:ff9b::/96,
|
|
2001::/32, 100::/64). The override carve-out only covers the local
|
|
LOOPBACK_RANGES + PRIVATE_RANGES lists in ssrf_validator.py; the
|
|
transition prefixes are intentionally excluded so that an attacker
|
|
cannot reach a private IPv4 destination by tunneling through 6to4
|
|
or NAT64 even when the operator has set ``allow_private_ips=True``
|
|
for a self-hosted service like Ollama.
|
|
|
|
If you ever need a self-hosted service reachable via 6to4 or
|
|
NAT64, that's a deliberate config decision and the design here
|
|
forces it to be made explicitly.
|
|
"""
|
|
|
|
def test_6to4_blocked_under_allow_localhost(self):
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
is_ip_blocked,
|
|
)
|
|
|
|
assert is_ip_blocked("2002:7f00:1::", allow_localhost=True) is True
|
|
|
|
def test_6to4_blocked_under_allow_private_ips(self):
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
is_ip_blocked,
|
|
)
|
|
|
|
assert is_ip_blocked("2002:c0a8:101::", allow_private_ips=True) is True
|
|
|
|
def test_nat64_blocked_under_allow_localhost(self):
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
is_ip_blocked,
|
|
)
|
|
|
|
assert is_ip_blocked("64:ff9b::7f00:1", allow_localhost=True) is True
|
|
|
|
def test_nat64_blocked_under_allow_private_ips(self):
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
is_ip_blocked,
|
|
)
|
|
|
|
assert is_ip_blocked("64:ff9b::a00:1", allow_private_ips=True) is True
|
|
|
|
def test_teredo_blocked_under_allow_private_ips(self):
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
is_ip_blocked,
|
|
)
|
|
|
|
assert is_ip_blocked("2001::1", allow_private_ips=True) is True
|
|
|
|
def test_discard_blocked_under_allow_private_ips(self):
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
is_ip_blocked,
|
|
)
|
|
|
|
assert is_ip_blocked("100::1", allow_private_ips=True) is True
|
|
|
|
def test_6to4_aws_metadata_blocked_under_allow_private_ips(self):
|
|
"""High-value: even with the most permissive flag, the 6to4 wrap
|
|
of AWS IMDS must remain blocked."""
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert (
|
|
validate_url("http://[2002:a9fe:a9fe::]/", allow_private_ips=True)
|
|
is False
|
|
)
|
|
|
|
def test_nat64_aws_metadata_blocked_under_allow_private_ips(self):
|
|
"""Same locking-in for NAT64 wrap of IMDS."""
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert (
|
|
validate_url("http://[64:ff9b::a9fe:a9fe]/", allow_private_ips=True)
|
|
is False
|
|
)
|
|
|
|
|
|
class TestIPv6TransitionPrefixesAntiCollision:
|
|
"""Anti-regression: legitimate IPv6 destinations adjacent to the new
|
|
transition prefixes must still pass validation. These tests guard
|
|
against accidental over-blocking if anyone widens a prefix later."""
|
|
|
|
def test_google_dns_v6_passes(self):
|
|
"""2001:4860:4860::8888 — Google Public DNS. Second hextet 0x4860
|
|
is outside the 2001::/32 Teredo block."""
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url("http://[2001:4860:4860::8888]/") is True
|
|
|
|
def test_cloudflare_dns_v6_passes(self):
|
|
"""2606:4700:4700::1111 — Cloudflare Public DNS, far from any
|
|
transition prefix."""
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url("http://[2606:4700:4700::1111]/") is True
|
|
|
|
def test_root_server_v6_passes(self):
|
|
"""2001:500::/30 root-server allocation — second hextet 0x0500
|
|
is outside Teredo."""
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url("http://[2001:500:88::1]/") is True
|
|
|
|
def test_he_tunnelbroker_v6_passes(self):
|
|
"""2001:470::/32 Hurricane Electric — second hextet 0x0470."""
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url("http://[2001:470:1f04::1]/") is True
|
|
|
|
def test_neighbor_above_6to4_passes(self):
|
|
"""2003::/16 sits adjacent to 2002::/16 but is not in it."""
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url("http://[2003::1]/") is True
|
|
|
|
def test_discard_prefix_neighbor_passes(self):
|
|
"""100:1::/16 sits outside the 100::/64 discard prefix
|
|
(second hextet 0x0001)."""
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url("http://[100:1::1]/") is True
|
|
|
|
|
|
class TestNat64EnvOptOut:
|
|
"""Operator escape hatch: ``LDR_SECURITY_ALLOW_NAT64=true`` opens the
|
|
two NAT64 prefixes for IPv6-only deployments using DNS64+NAT64.
|
|
|
|
Critical invariants:
|
|
- The carve-out is ONLY for the two NAT64 prefixes (well-known and
|
|
RFC 8215 local-use). 6to4, Teredo, discard remain blocked.
|
|
- The carve-out does NOT reopen the IPv4-form cloud-metadata block
|
|
(169.254.169.254 stays blocked).
|
|
- Reading the env var lazily (per-call, not at import) means
|
|
monkeypatching works in tests.
|
|
"""
|
|
|
|
def test_nat64_wkp_blocked_when_env_unset(self, monkeypatch):
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
monkeypatch.delenv("LDR_SECURITY_ALLOW_NAT64", raising=False)
|
|
assert validate_url("http://[64:ff9b::a00:1]/") is False
|
|
|
|
def test_nat64_wkp_allowed_when_env_true(self, monkeypatch):
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
monkeypatch.setenv("LDR_SECURITY_ALLOW_NAT64", "true")
|
|
# 64:ff9b::8.8.8.8 — NAT64 wrap of Google DNS, the canonical
|
|
# IPv6-only-deployment use case.
|
|
assert validate_url("http://[64:ff9b::808:808]/") is True
|
|
|
|
def test_nat64_local_use_allowed_when_env_true(self, monkeypatch):
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
monkeypatch.setenv("LDR_SECURITY_ALLOW_NAT64", "true")
|
|
assert validate_url("http://[64:ff9b:1::808:808]/") is True
|
|
|
|
def test_env_does_not_unblock_6to4(self, monkeypatch):
|
|
"""6to4 has no live legitimate use; the operator switch must
|
|
not extend to it."""
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
monkeypatch.setenv("LDR_SECURITY_ALLOW_NAT64", "true")
|
|
assert validate_url("http://[2002:c0a8:101::]/") is False
|
|
|
|
def test_env_does_not_unblock_teredo(self, monkeypatch):
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
monkeypatch.setenv("LDR_SECURITY_ALLOW_NAT64", "true")
|
|
assert validate_url("http://[2001::1]/") is False
|
|
|
|
def test_env_does_not_unblock_discard(self, monkeypatch):
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
monkeypatch.setenv("LDR_SECURITY_ALLOW_NAT64", "true")
|
|
assert validate_url("http://[100::1]/") is False
|
|
|
|
def test_env_does_not_unblock_imds_v4_literal(self, monkeypatch):
|
|
"""The IPv4-form metadata literal is in ALWAYS_BLOCKED_METADATA_IPS
|
|
and is checked BEFORE the prefix loop. The NAT64 carve-out
|
|
cannot reach it."""
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
monkeypatch.setenv("LDR_SECURITY_ALLOW_NAT64", "true")
|
|
assert validate_url("http://169.254.169.254/") is False
|
|
|
|
def test_env_does_not_unblock_imds_via_nat64_wkp_wrap(self, monkeypatch):
|
|
"""The IMDS embedded-IPv4 check fires before the NAT64 carve-out:
|
|
even with operator opt-in, [64:ff9b::a9fe:a9fe] (NAT64 WKP wrap
|
|
of 169.254.169.254) stays blocked. ALWAYS_BLOCKED_METADATA_IPS
|
|
is absolute by design."""
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
monkeypatch.setenv("LDR_SECURITY_ALLOW_NAT64", "true")
|
|
assert validate_url("http://[64:ff9b::a9fe:a9fe]/") is False
|
|
|
|
def test_env_does_not_unblock_imds_via_nat64_local_use_wrap(
|
|
self, monkeypatch
|
|
):
|
|
"""Same lock-in for the RFC 8215 local-use prefix wrap."""
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
monkeypatch.setenv("LDR_SECURITY_ALLOW_NAT64", "true")
|
|
assert validate_url("http://[64:ff9b:1::a9fe:a9fe]/") is False
|
|
|
|
def test_env_does_not_unblock_ecs_metadata_via_nat64_wrap(
|
|
self, monkeypatch
|
|
):
|
|
"""169.254.170.2 (AWS ECS task metadata v3) is also in the
|
|
always-blocked set; NAT64 wrap stays blocked under opt-in."""
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
monkeypatch.setenv("LDR_SECURITY_ALLOW_NAT64", "true")
|
|
# 169.254.170.2 = 0xa9feaa02
|
|
assert validate_url("http://[64:ff9b::a9fe:aa02]/") is False
|
|
|
|
def test_env_falsy_values_keep_blocked(self, monkeypatch):
|
|
"""'false', '0', and unset must all keep the block in place."""
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
for value in ("false", "0", "no", ""):
|
|
monkeypatch.setenv("LDR_SECURITY_ALLOW_NAT64", value)
|
|
assert validate_url("http://[64:ff9b::a00:1]/") is False, (
|
|
f"NAT64 must remain blocked for env value {value!r}"
|
|
)
|
|
|
|
def test_env_true_does_not_bypass_loopback_in_block_list(self, monkeypatch):
|
|
"""Sanity: opting into NAT64 must not accidentally unblock
|
|
non-NAT64 entries that share a prefix family."""
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
is_ip_blocked,
|
|
)
|
|
|
|
monkeypatch.setenv("LDR_SECURITY_ALLOW_NAT64", "true")
|
|
assert is_ip_blocked("127.0.0.1") is True
|
|
assert is_ip_blocked("::1") is True
|
|
|
|
def test_env_true_does_not_unblock_ipv6_ula(self, monkeypatch):
|
|
"""The carve-out's ``continue`` lives in the same loop that walks
|
|
ULA (fc00::/7) and link-local (fe80::/10). Pin that opting into
|
|
NAT64 does not accidentally unblock these adjacent IPv6 ranges."""
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
is_ip_blocked,
|
|
)
|
|
|
|
monkeypatch.setenv("LDR_SECURITY_ALLOW_NAT64", "true")
|
|
assert is_ip_blocked("fc00::1") is True
|
|
assert is_ip_blocked("fd12:3456:789a::1") is True
|
|
|
|
def test_env_true_does_not_unblock_ipv6_link_local(self, monkeypatch):
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
is_ip_blocked,
|
|
)
|
|
|
|
monkeypatch.setenv("LDR_SECURITY_ALLOW_NAT64", "true")
|
|
assert is_ip_blocked("fe80::1") is True
|
|
|
|
|
|
class TestIsNat64WrappedMetadataIp:
|
|
"""Direct unit tests for the shared helper. Both validators rely on
|
|
its IPv4 short-circuit and on the metadata-set membership check;
|
|
surface those contracts explicitly so a refactor of either branch
|
|
can't silently flip them."""
|
|
|
|
def test_returns_false_for_ipv4(self):
|
|
"""The helper must short-circuit for IPv4 inputs because it's
|
|
called after ``is_ip_blocked`` unwraps IPv4-mapped IPv6 — at
|
|
that point the address is no longer IPv6 and the NAT64 check
|
|
does not apply."""
|
|
import ipaddress
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
is_nat64_wrapped_metadata_ip,
|
|
)
|
|
|
|
assert (
|
|
is_nat64_wrapped_metadata_ip(
|
|
ipaddress.IPv4Address("169.254.169.254")
|
|
)
|
|
is False
|
|
)
|
|
|
|
def test_returns_false_for_non_nat64_ipv6(self):
|
|
"""Public IPv6 (Google DNS) is not in any NAT64 prefix."""
|
|
import ipaddress
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
is_nat64_wrapped_metadata_ip,
|
|
)
|
|
|
|
assert (
|
|
is_nat64_wrapped_metadata_ip(
|
|
ipaddress.IPv6Address("2001:4860:4860::8888")
|
|
)
|
|
is False
|
|
)
|
|
|
|
def test_returns_false_for_nat64_wrap_of_non_metadata(self):
|
|
"""[64:ff9b::a00:1] (NAT64 wrap of 10.0.0.1) is in a NAT64
|
|
prefix but the embedded IPv4 is not metadata — helper returns
|
|
False so the broader carve-out logic can apply."""
|
|
import ipaddress
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
is_nat64_wrapped_metadata_ip,
|
|
)
|
|
|
|
assert (
|
|
is_nat64_wrapped_metadata_ip(
|
|
ipaddress.IPv6Address("64:ff9b::a00:1")
|
|
)
|
|
is False
|
|
)
|
|
|
|
def test_returns_true_for_imds_via_wkp(self):
|
|
import ipaddress
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
is_nat64_wrapped_metadata_ip,
|
|
)
|
|
|
|
assert (
|
|
is_nat64_wrapped_metadata_ip(
|
|
ipaddress.IPv6Address("64:ff9b::a9fe:a9fe")
|
|
)
|
|
is True
|
|
)
|
|
|
|
def test_returns_true_for_imds_via_local_use(self):
|
|
import ipaddress
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
is_nat64_wrapped_metadata_ip,
|
|
)
|
|
|
|
assert (
|
|
is_nat64_wrapped_metadata_ip(
|
|
ipaddress.IPv6Address("64:ff9b:1::a9fe:a9fe")
|
|
)
|
|
is True
|
|
)
|
|
|
|
|
|
class TestValidateUrlEdgeCases:
|
|
"""Robustness: validate_url must never raise, only return bool."""
|
|
|
|
def test_empty_string_returns_false(self):
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url("") is False
|
|
|
|
def test_whitespace_only_returns_false(self):
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url(" ") is False
|
|
|
|
def test_tab_newline_only_returns_false(self):
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url("\t\n") is False
|
|
|
|
def test_none_returns_false(self):
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url(None) is False
|
|
|
|
def test_int_returns_false(self):
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url(123) is False
|
|
|
|
def test_malformed_ipv6_no_crash(self):
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url("http://[::") is False
|
|
|
|
def test_extremely_long_url_no_crash(self):
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
# Should reject (no DNS) but most importantly must not crash or
|
|
# consume excessive memory.
|
|
with patch(
|
|
"socket.getaddrinfo", side_effect=__import__("socket").gaierror()
|
|
):
|
|
assert validate_url("http://" + "a" * 100_000) is False
|
|
|
|
|
|
class TestLegitimateUrlsStillPass:
|
|
"""Anti-regression: ensure the fix doesn't reject RFC-legal URLs."""
|
|
|
|
@staticmethod
|
|
def _public_dns_mock():
|
|
# 93.184.216.34 is the documented IP for example.com (RFC-2606).
|
|
return patch(
|
|
"socket.getaddrinfo",
|
|
return_value=[(2, 1, 6, "", ("93.184.216.34", 0))],
|
|
)
|
|
|
|
def test_simple_http_url(self):
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
with self._public_dns_mock():
|
|
assert validate_url("http://example.com/") is True
|
|
|
|
def test_explicit_port(self):
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
with self._public_dns_mock():
|
|
assert validate_url("http://example.com:8080/") is True
|
|
|
|
def test_userinfo_is_rfc_legal(self):
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
with self._public_dns_mock():
|
|
assert validate_url("http://user:pass@example.com/") is True
|
|
|
|
def test_userinfo_with_port(self):
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
with self._public_dns_mock():
|
|
assert validate_url("http://user:pass@example.com:8080/") is True
|
|
|
|
def test_trailing_dot_hostname(self):
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
with self._public_dns_mock():
|
|
assert validate_url("http://example.com./") is True
|
|
|
|
def test_path_query_fragment(self):
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
with self._public_dns_mock():
|
|
assert validate_url("http://example.com/path?q=1#frag") is True
|
|
|
|
def test_plus_in_query_string(self):
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
with self._public_dns_mock():
|
|
assert validate_url("http://example.com/?q=foo+bar") is True
|
|
|
|
def test_encoded_backslash_in_path_is_rfc_legal(self):
|
|
"""%5C in a PATH (not a host bypass) is RFC-legal and must pass."""
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
with self._public_dns_mock():
|
|
assert validate_url("http://example.com/path%5Cfile") is True
|
|
|
|
def test_encoded_space_in_path(self):
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
with self._public_dns_mock():
|
|
assert (
|
|
validate_url("http://example.com/path%20with%20encoded%20space")
|
|
is True
|
|
)
|
|
|
|
def test_uppercase_hostname_case_folded(self):
|
|
"""Locks in case-folding parity between urlparse and urllib3."""
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
with self._public_dns_mock():
|
|
assert validate_url("http://EXAMPLE.COM/") is True
|
|
|
|
def test_ipv6_public(self):
|
|
"""IPv6 hosts unwrap from brackets and check correctly."""
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
# 2001:db8::1 is the documentation prefix; not in any blocked
|
|
# range, so this should pass.
|
|
assert validate_url("http://[2001:db8::1]/") is True
|
|
|
|
|
|
# Security-model documentation lives in the module docstring at the top of
|
|
# this file. The previous TestDocumentation class held a single skipped
|
|
# placeholder test with no assertions; removed for clarity.
|
|
|
|
|
|
# ===========================================================================
|
|
# Edge-case coverage — gaps identified by a systematic review of the
|
|
# validator's branches against the existing 7-file test suite. Each test
|
|
# documents which production line(s) it pins and how it would fail under
|
|
# the documented mutation.
|
|
# ===========================================================================
|
|
|
|
|
|
class TestUnspecifiedIPv4Blocked:
|
|
"""validate_url end-to-end coverage for the 0.0.0.0/8 entry of
|
|
PRIVATE_IP_RANGES (ip_ranges.py:24). Prior tests covered
|
|
``is_ip_blocked("0.0.0.0")`` but never the full URL path through the
|
|
parser, IP-literal branch (ssrf_validator.py:258-268), and
|
|
is_ip_blocked. Mutation: removing 0.0.0.0/8 from PRIVATE_IP_RANGES
|
|
flips all three parametrize cases to True.
|
|
"""
|
|
|
|
@pytest.mark.parametrize(
|
|
"url",
|
|
[
|
|
"http://0.0.0.0/",
|
|
"http://0.0.0.5/",
|
|
"http://0.255.255.254/",
|
|
],
|
|
)
|
|
def test_unspecified_ipv4_literal_blocked(self, url):
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url(url) is False
|
|
|
|
|
|
class TestDnsResolutionNonGaierror:
|
|
"""The DNS-resolution block in validate_url has two except handlers:
|
|
a specific ``socket.gaierror`` (ssrf_validator.py:307-309) and a
|
|
generic ``except Exception`` (lines 310-312). The latter is hit when
|
|
getaddrinfo raises anything that is not a gaierror (PermissionError
|
|
from a restricted environment, UnicodeError from idna encoding of
|
|
oversized labels, etc.). Prior tests covered only gaierror.
|
|
|
|
Mutation: deleting lines 310-312 lets the exception propagate out
|
|
of validate_url. pytest reports the test as an ERROR (uncaught
|
|
exception), which still counts as a failure for these assertions.
|
|
"""
|
|
|
|
@pytest.mark.parametrize(
|
|
"exc",
|
|
[PermissionError("eperm"), OSError("eio"), RuntimeError("boom")],
|
|
)
|
|
def test_non_gaierror_during_dns_fails_closed(self, exc, loguru_caplog):
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
with patch("socket.getaddrinfo", side_effect=exc):
|
|
with loguru_caplog.at_level("WARNING"):
|
|
result = validate_url("http://example.com/")
|
|
|
|
assert result is False
|
|
assert "Error during hostname resolution" in loguru_caplog.text
|
|
|
|
|
|
class TestRfcForbiddenControlChars:
|
|
"""RFC_FORBIDDEN_URL_CHARS_RE (ssrf_validator.py:63) is defined as
|
|
``[\\\\s\\x00-\\x1f\\x7f]``. Prior tests heavily covered backslash
|
|
and ``\\x00``; this class pins the rest of the control-byte range
|
|
(low-range \\x01, mid-range \\x1f, and high-range \\x7f / DEL).
|
|
|
|
Mutation: narrowing the character class (e.g. dropping \\x7f or the
|
|
\\x01-\\x1f run) lets the corresponding URL pass through to urllib3
|
|
parsing — which may then disagree with the eventual HTTP client on
|
|
where the boundary between userinfo and host falls, the exact bug
|
|
class GHSA-g23j-2vwm-5c25 closed.
|
|
"""
|
|
|
|
@pytest.mark.parametrize(
|
|
"ctrl_char",
|
|
["\x01", "\x1f", "\x7f"],
|
|
)
|
|
def test_control_byte_in_url_rejected(self, ctrl_char):
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
url = f"http://127.0.0.1{ctrl_char}@1.1.1.1"
|
|
assert validate_url(url) is False
|
|
|
|
|
|
class TestAlternateIpHexForm:
|
|
"""Single-DWORD hex notation (``0x7f000001``) is not parseable by
|
|
``ipaddress.ip_address`` so the IP-literal branch raises ValueError
|
|
and the validator falls through to DNS resolution
|
|
(ssrf_validator.py:269-271 → 287-289). On Linux glibc, getaddrinfo
|
|
resolves the hex form to the canonical IPv4, which the post-DNS
|
|
is_ip_blocked check then catches.
|
|
|
|
This pins the load-bearing assumption that the IP-parse failure
|
|
correctly falls through, rather than short-circuiting to a wrong
|
|
outcome (e.g. returning True because no IP was matched).
|
|
"""
|
|
|
|
def test_hex_dword_loopback_resolves_and_blocks(self):
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
with patch(
|
|
"socket.getaddrinfo",
|
|
return_value=[(2, 1, 6, "", ("127.0.0.1", 0))],
|
|
):
|
|
# 0x7f000001 == 127.0.0.1. glibc resolves this; non-glibc
|
|
# libc implementations may reject — the test mocks DNS to
|
|
# the canonical form so we exercise the post-DNS branch
|
|
# regardless of platform getaddrinfo behavior.
|
|
assert validate_url("http://0x7f000001/") is False
|
|
|
|
|
|
class TestPortEdgeCases:
|
|
"""Ports outside the legal range (>65535 and 0). 65536 trips
|
|
urllib3's ``parse_url`` and falls into the LocationParseError
|
|
handler (ssrf_validator.py:229-233). Port 0 parses successfully but
|
|
the host is still 127.0.0.1, which the IP-literal branch rejects.
|
|
"""
|
|
|
|
@pytest.mark.parametrize(
|
|
"url",
|
|
[
|
|
"http://127.0.0.1:65536/",
|
|
"http://127.0.0.1:0/",
|
|
],
|
|
)
|
|
def test_port_edge_case_blocked(self, url):
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url(url) is False
|
|
|
|
|
|
class TestMultipleAtSignsContract:
|
|
"""RFC 3986 says when a URL contains multiple ``@`` characters the
|
|
LAST one delimits userinfo from host. ``http://a@b@1.1.1.1/`` parses
|
|
to host=``1.1.1.1``. urllib3 implements this; urllib.parse.urlparse
|
|
has historically agreed.
|
|
|
|
Validator behavior: when urllib3 reports a public host, validate_url
|
|
allows the URL (and a request library subsequently connects to the
|
|
same host). This test pins that contract. If urllib3 ever changes
|
|
its multi-@ handling, the explicit parse-url assertion catches the
|
|
drift.
|
|
"""
|
|
|
|
def test_double_at_resolves_to_last_segment(self):
|
|
from urllib3.util import parse_url
|
|
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
url = "http://user:pass@127.0.0.1@1.1.1.1/"
|
|
# Contract check — if this fails, urllib3 changed its multi-@
|
|
# semantics and the validator's assumption is no longer safe.
|
|
assert parse_url(url).host == "1.1.1.1"
|
|
|
|
with patch(
|
|
"socket.getaddrinfo",
|
|
return_value=[(2, 1, 6, "", ("1.1.1.1", 0))],
|
|
):
|
|
assert validate_url(url) is True
|
|
|
|
|
|
class TestUserinfoContainsIpShape:
|
|
"""An IP-shaped string inside the userinfo portion of a URL is not
|
|
a bypass: urllib3 parses ``http://127.0.0.1@evil.com/`` with
|
|
host=``evil.com``, and ``requests`` connects to the same host. The
|
|
validator therefore (correctly) allows it.
|
|
|
|
This test pins that the validator and urllib3 agree on this case,
|
|
which is essential to the parser-differential defense documented
|
|
at ssrf_validator.py:224-228.
|
|
"""
|
|
|
|
def test_userinfo_ip_shape_is_not_a_bypass(self):
|
|
from urllib3.util import parse_url
|
|
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
url = "http://127.0.0.1@evil.com/"
|
|
# The IP-shaped string is userinfo, not host.
|
|
assert parse_url(url).host == "evil.com"
|
|
|
|
with patch(
|
|
"socket.getaddrinfo",
|
|
return_value=[(2, 1, 6, "", ("93.184.216.34", 0))],
|
|
):
|
|
assert validate_url(url) is True
|
|
|
|
|
|
class TestIpv6ZoneIdBlocked:
|
|
"""IPv6 link-local addresses with zone identifiers (RFC 6874 syntax)
|
|
must still be blocked because the host is in fe80::/10 regardless
|
|
of the trailing ``%zone`` part.
|
|
|
|
Python's ``ipaddress.ip_address`` accepts ``fe80::1%eth0`` since
|
|
3.9; the validator's IP-literal branch (ssrf_validator.py:258-268)
|
|
therefore handles this directly via is_ip_blocked without falling
|
|
through to DNS. The percent-encoded variant (%25eth0) is the
|
|
URI-safe form that some clients emit; urllib3 may parse-fail it,
|
|
in which case the LocationParseError branch also returns False.
|
|
"""
|
|
|
|
@pytest.mark.parametrize(
|
|
"url",
|
|
[
|
|
"http://[fe80::1%eth0]/",
|
|
"http://[fe80::1%25eth0]/",
|
|
],
|
|
)
|
|
def test_ipv6_zone_id_link_local_blocked(self, url):
|
|
from src.local_deep_research.security.ssrf_validator import (
|
|
validate_url,
|
|
)
|
|
|
|
assert validate_url(url) is False
|