- Moved app.py, db.py, gateway.py, templates.py, embeddings.py, rns_client.py, and handlers/ into src/tinyweb/ - Created root app.py shim (adds src/ to sys.path, imports main) - Created pyproject.toml with setuptools config (where = ["src"]) - Added src/tinyweb/__init__.py - Updated all internal imports to use tinyweb. prefix (73 occurrences) - Removed sys.path.insert hack from conftest.py - Updated Dockerfile: pip install -e /app before running - Updated gateway.py usage message: python -m tinyweb.gateway - Updated README.md gateway usage instructions
64 lines
2 KiB
Python
64 lines
2 KiB
Python
"""Tests for `_validate_url_target` — SSRF prevention.
|
|
|
|
Any URL the app fetches must resolve to a public IP; private/internal/
|
|
loopback addresses must be rejected so attacker-controlled URLs cannot
|
|
reach internal services via our HTTP client.
|
|
"""
|
|
import socket
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from tinyweb.db import _validate_url_target
|
|
|
|
|
|
def _mock_getaddrinfo(address):
|
|
"""Return a function suitable as a socket.getaddrinfo replacement."""
|
|
def f(host, port, *args, **kwargs):
|
|
family = socket.AF_INET6 if ":" in address else socket.AF_INET
|
|
return [(family, socket.SOCK_STREAM, 0, "", (address, port or 80))]
|
|
return f
|
|
|
|
|
|
@pytest.mark.parametrize("blocked_ip", [
|
|
"127.0.0.1",
|
|
"127.1.2.3",
|
|
"10.0.0.1",
|
|
"10.255.255.255",
|
|
"172.16.0.1",
|
|
"172.31.255.255",
|
|
"192.168.0.1",
|
|
"192.168.255.255",
|
|
"169.254.169.254",
|
|
"0.0.0.0",
|
|
"::1",
|
|
"fc00::1",
|
|
"fe80::1",
|
|
])
|
|
def test_blocks_private_and_loopback(monkeypatch, blocked_ip):
|
|
monkeypatch.setattr(socket, "getaddrinfo", _mock_getaddrinfo(blocked_ip))
|
|
with pytest.raises(ValueError, match="blocked"):
|
|
_validate_url_target("https://evil.example.com/internal")
|
|
|
|
|
|
def test_allows_public_ipv4(monkeypatch):
|
|
monkeypatch.setattr(socket, "getaddrinfo", _mock_getaddrinfo("8.8.8.8"))
|
|
_validate_url_target("https://dns.example.com/") # does not raise
|
|
|
|
|
|
def test_allows_public_ipv6(monkeypatch):
|
|
monkeypatch.setattr(socket, "getaddrinfo", _mock_getaddrinfo("2001:4860:4860::8888"))
|
|
_validate_url_target("https://v6.example.com/") # does not raise
|
|
|
|
|
|
def test_rejects_unresolvable_hostname(monkeypatch):
|
|
def boom(*args, **kwargs):
|
|
raise socket.gaierror("no such host")
|
|
monkeypatch.setattr(socket, "getaddrinfo", boom)
|
|
with pytest.raises(ValueError, match="Cannot resolve"):
|
|
_validate_url_target("https://does-not-exist.example.com/")
|
|
|
|
|
|
def test_rejects_missing_hostname():
|
|
with pytest.raises(ValueError, match="No hostname"):
|
|
_validate_url_target("http:///path-only")
|