tinyweb/tests/test_ssrf.py
blankie 3e30678e21 src layout: move core code into src/tinyweb/ package
- 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
2026-06-17 05:03:23 +00:00

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")