chore: import upstream snapshot with attribution
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Backend release / release (push) Has been cancelled
Bandit Security Scan / bandit_scan (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / manifest (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / manifest (push) Has been cancelled
Python linting / ruff (push) Has been cancelled
Run python tests with pytest / Run tests and count coverage (3.12) (push) Has been cancelled
React Widget Build / build (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Backend release / release (push) Has been cancelled
Bandit Security Scan / bandit_scan (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / manifest (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / manifest (push) Has been cancelled
Python linting / ruff (push) Has been cancelled
Run python tests with pytest / Run tests and count coverage (3.12) (push) Has been cancelled
React Widget Build / build (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
import base64
|
||||
|
||||
import pytest
|
||||
from application.security import encryption
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||
|
||||
|
||||
def _fake_os_urandom_factory(values):
|
||||
values_iter = iter(values)
|
||||
|
||||
def _fake(length):
|
||||
value = next(values_iter)
|
||||
assert len(value) == length
|
||||
return value
|
||||
|
||||
return _fake
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_derive_key_uses_secret_and_user(monkeypatch):
|
||||
monkeypatch.setattr(encryption.settings, "ENCRYPTION_SECRET_KEY", "test-secret")
|
||||
salt = bytes(range(16))
|
||||
|
||||
expected_kdf = PBKDF2HMAC(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=32,
|
||||
salt=salt,
|
||||
iterations=100000,
|
||||
backend=default_backend(),
|
||||
)
|
||||
expected_key = expected_kdf.derive(b"test-secret#user-123")
|
||||
|
||||
derived = encryption._derive_key("user-123", salt)
|
||||
|
||||
assert derived == expected_key
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_encrypt_and_decrypt_round_trip(monkeypatch):
|
||||
monkeypatch.setattr(encryption.settings, "ENCRYPTION_SECRET_KEY", "test-secret")
|
||||
salt = bytes(range(16))
|
||||
iv = bytes(range(16, 32))
|
||||
monkeypatch.setattr(encryption.os, "urandom", _fake_os_urandom_factory([salt, iv]))
|
||||
|
||||
credentials = {"token": "abc123", "refresh": "xyz789"}
|
||||
|
||||
encrypted = encryption.encrypt_credentials(credentials, "user-123")
|
||||
|
||||
decoded = base64.b64decode(encrypted)
|
||||
assert decoded[:16] == salt
|
||||
assert decoded[16:32] == iv
|
||||
|
||||
decrypted = encryption.decrypt_credentials(encrypted, "user-123")
|
||||
|
||||
assert decrypted == credentials
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_encrypt_credentials_returns_empty_for_empty_input(monkeypatch):
|
||||
monkeypatch.setattr(encryption.settings, "ENCRYPTION_SECRET_KEY", "test-secret")
|
||||
|
||||
assert encryption.encrypt_credentials({}, "user-123") == ""
|
||||
assert encryption.encrypt_credentials(None, "user-123") == ""
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_encrypt_credentials_returns_empty_on_serialization_error(monkeypatch):
|
||||
monkeypatch.setattr(encryption.settings, "ENCRYPTION_SECRET_KEY", "test-secret")
|
||||
monkeypatch.setattr(encryption.os, "urandom", lambda length: b"\x00" * length)
|
||||
|
||||
class NonSerializable:
|
||||
pass
|
||||
|
||||
credentials = {"bad": NonSerializable()}
|
||||
|
||||
assert encryption.encrypt_credentials(credentials, "user-123") == ""
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_decrypt_credentials_returns_empty_for_invalid_input(monkeypatch):
|
||||
monkeypatch.setattr(encryption.settings, "ENCRYPTION_SECRET_KEY", "test-secret")
|
||||
|
||||
assert encryption.decrypt_credentials("", "user-123") == {}
|
||||
assert encryption.decrypt_credentials("not-base64", "user-123") == {}
|
||||
|
||||
invalid_payload = base64.b64encode(b"short").decode()
|
||||
assert encryption.decrypt_credentials(invalid_payload, "user-123") == {}
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_pad_and_unpad_are_inverse():
|
||||
original = b"secret-data"
|
||||
|
||||
padded = encryption._pad_data(original)
|
||||
|
||||
assert len(padded) % 16 == 0
|
||||
assert encryption._unpad_data(padded) == original
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_pad_data_exact_block_size():
|
||||
# When input is exactly 16 bytes, a full block of padding is added
|
||||
original = b"0123456789abcdef"
|
||||
assert len(original) == 16
|
||||
|
||||
padded = encryption._pad_data(original)
|
||||
|
||||
# Should be 32 bytes (16 + 16 padding)
|
||||
assert len(padded) == 32
|
||||
assert encryption._unpad_data(padded) == original
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_pad_data_various_sizes():
|
||||
for size in range(1, 33):
|
||||
data = b"x" * size
|
||||
padded = encryption._pad_data(data)
|
||||
assert len(padded) % 16 == 0
|
||||
assert encryption._unpad_data(padded) == data
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_encrypt_decrypt_complex_credentials(monkeypatch):
|
||||
monkeypatch.setattr(encryption.settings, "ENCRYPTION_SECRET_KEY", "complex-secret")
|
||||
|
||||
credentials = {
|
||||
"token": "abc123",
|
||||
"refresh": "xyz789",
|
||||
"nested": {"key": "value"},
|
||||
"list_field": [1, 2, 3],
|
||||
"unicode": "\u4f60\u597d\u4e16\u754c",
|
||||
}
|
||||
|
||||
encrypted = encryption.encrypt_credentials(credentials, "user-456")
|
||||
decrypted = encryption.decrypt_credentials(encrypted, "user-456")
|
||||
|
||||
assert decrypted == credentials
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_decrypt_with_wrong_user_returns_empty(monkeypatch):
|
||||
monkeypatch.setattr(encryption.settings, "ENCRYPTION_SECRET_KEY", "test-secret")
|
||||
|
||||
credentials = {"token": "abc123"}
|
||||
encrypted = encryption.encrypt_credentials(credentials, "user-1")
|
||||
|
||||
# Decrypting with wrong user should fail gracefully
|
||||
result = encryption.decrypt_credentials(encrypted, "user-2")
|
||||
assert result == {}
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_decrypt_with_wrong_secret_returns_empty(monkeypatch):
|
||||
monkeypatch.setattr(encryption.settings, "ENCRYPTION_SECRET_KEY", "secret-1")
|
||||
credentials = {"token": "abc123"}
|
||||
encrypted = encryption.encrypt_credentials(credentials, "user-1")
|
||||
|
||||
# Change the secret key
|
||||
monkeypatch.setattr(encryption.settings, "ENCRYPTION_SECRET_KEY", "secret-2")
|
||||
result = encryption.decrypt_credentials(encrypted, "user-1")
|
||||
assert result == {}
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_encrypt_credentials_empty_dict(monkeypatch):
|
||||
monkeypatch.setattr(encryption.settings, "ENCRYPTION_SECRET_KEY", "test-secret")
|
||||
assert encryption.encrypt_credentials({}, "user-1") == ""
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_decrypt_credentials_truncated_payload(monkeypatch):
|
||||
monkeypatch.setattr(encryption.settings, "ENCRYPTION_SECRET_KEY", "test-secret")
|
||||
# base64 of only 10 bytes - not enough for salt+iv
|
||||
import base64
|
||||
|
||||
short = base64.b64encode(b"0123456789").decode()
|
||||
assert encryption.decrypt_credentials(short, "user-1") == {}
|
||||
@@ -0,0 +1,727 @@
|
||||
"""Unit tests for ``application.security.safe_url``.
|
||||
|
||||
These tests must run offline, so every "valid public host" case mocks
|
||||
``socket.getaddrinfo`` to return a known public IP. Cases that test
|
||||
IP-literal validation do not need DNS at all and rely on the
|
||||
short-circuit path inside ``validate_user_base_url``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from application.security.safe_url import (
|
||||
UnsafeUserUrlError,
|
||||
_PinnedHTTPSTransport,
|
||||
pinned_httpx_client,
|
||||
pinned_post,
|
||||
validate_user_base_url,
|
||||
)
|
||||
|
||||
|
||||
def _addrinfo(*ips: str) -> list[tuple]:
|
||||
"""Build a fake ``socket.getaddrinfo`` return value for ``ips``."""
|
||||
|
||||
out: list[tuple] = []
|
||||
for ip in ips:
|
||||
family = socket.AF_INET6 if ":" in ip else socket.AF_INET
|
||||
port_tuple = (ip, 0, 0, 0) if family == socket.AF_INET6 else (ip, 0)
|
||||
out.append((family, socket.SOCK_STREAM, 0, "", port_tuple))
|
||||
return out
|
||||
|
||||
|
||||
# Valid URLs (DNS mocked to a known-public IP)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_allows_openai_api():
|
||||
with mock.patch("socket.getaddrinfo", return_value=_addrinfo("104.18.6.192")):
|
||||
assert validate_user_base_url("https://api.openai.com/v1") is None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_allows_mistral_api():
|
||||
with mock.patch("socket.getaddrinfo", return_value=_addrinfo("172.67.144.116")):
|
||||
assert validate_user_base_url("https://api.mistral.ai/v1") is None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_allows_http_with_port():
|
||||
with mock.patch("socket.getaddrinfo", return_value=_addrinfo("93.184.216.34")):
|
||||
assert validate_user_base_url("http://example.com:8080/v1") is None
|
||||
|
||||
|
||||
# Scheme rejection
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_rejects_file_scheme():
|
||||
with pytest.raises(UnsafeUserUrlError, match="scheme"):
|
||||
validate_user_base_url("file:///etc/passwd")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_rejects_gopher_scheme():
|
||||
with pytest.raises(UnsafeUserUrlError, match="scheme"):
|
||||
validate_user_base_url("gopher://example.com")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_rejects_ftp_scheme():
|
||||
with pytest.raises(UnsafeUserUrlError, match="scheme"):
|
||||
validate_user_base_url("ftp://example.com")
|
||||
|
||||
|
||||
# Hostname-string blocklist
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_rejects_localhost_hostname():
|
||||
with pytest.raises(UnsafeUserUrlError, match="not allowed"):
|
||||
validate_user_base_url("https://localhost/v1")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_rejects_localhost_localdomain():
|
||||
with pytest.raises(UnsafeUserUrlError, match="not allowed"):
|
||||
validate_user_base_url("https://localhost.localdomain/v1")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_rejects_localhost_uppercase():
|
||||
# Hostname check must be case-insensitive.
|
||||
with pytest.raises(UnsafeUserUrlError, match="not allowed"):
|
||||
validate_user_base_url("https://LocalHost/v1")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_rejects_ip6_localhost_alias():
|
||||
with pytest.raises(UnsafeUserUrlError, match="not allowed"):
|
||||
validate_user_base_url("https://ip6-localhost/v1")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_rejects_gcp_metadata_hostname():
|
||||
with pytest.raises(UnsafeUserUrlError, match="not allowed"):
|
||||
validate_user_base_url("https://metadata.google.internal/computeMetadata/v1/")
|
||||
|
||||
|
||||
# IP-literal rejection (no DNS hit needed; covered by short-circuit)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_rejects_loopback_ipv4_literal():
|
||||
with pytest.raises(UnsafeUserUrlError, match="blocked address"):
|
||||
validate_user_base_url("https://127.0.0.1/v1")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_rejects_loopback_ipv6_literal():
|
||||
with pytest.raises(UnsafeUserUrlError, match="not allowed|blocked"):
|
||||
validate_user_base_url("https://[::1]/v1")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_rejects_private_10_8():
|
||||
with pytest.raises(UnsafeUserUrlError, match="blocked address"):
|
||||
validate_user_base_url("https://10.0.0.5/v1")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_rejects_private_172_16_low_boundary():
|
||||
with pytest.raises(UnsafeUserUrlError, match="blocked address"):
|
||||
validate_user_base_url("https://172.16.0.5/v1")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_rejects_private_172_16_high_boundary():
|
||||
with pytest.raises(UnsafeUserUrlError, match="blocked address"):
|
||||
validate_user_base_url("https://172.31.0.5/v1")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_rejects_private_192_168():
|
||||
with pytest.raises(UnsafeUserUrlError, match="blocked address"):
|
||||
validate_user_base_url("https://192.168.1.1/v1")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_rejects_aws_metadata_link_local():
|
||||
with pytest.raises(UnsafeUserUrlError, match="blocked address"):
|
||||
validate_user_base_url("https://169.254.169.254/latest/meta-data/")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_rejects_unique_local_ipv6():
|
||||
with pytest.raises(UnsafeUserUrlError, match="blocked address"):
|
||||
validate_user_base_url("https://[fc00::1]/v1")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_rejects_link_local_ipv6():
|
||||
with pytest.raises(UnsafeUserUrlError, match="blocked address"):
|
||||
validate_user_base_url("https://[fe80::1]/v1")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_rejects_multicast_ipv4():
|
||||
with pytest.raises(UnsafeUserUrlError, match="blocked address"):
|
||||
validate_user_base_url("https://224.0.0.1/v1")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_rejects_unspecified_zero_address():
|
||||
# 0.0.0.0 is in the literal hostname blocklist AND also caught as
|
||||
# unspecified; either error message is acceptable.
|
||||
with pytest.raises(UnsafeUserUrlError, match="not allowed|blocked"):
|
||||
validate_user_base_url("https://0.0.0.0/v1")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_rejects_carrier_grade_nat():
|
||||
# 100.64.0.0/10 is NOT covered by ``ipaddress.is_private``.
|
||||
with pytest.raises(UnsafeUserUrlError, match="blocked address"):
|
||||
validate_user_base_url("https://100.64.0.1/v1")
|
||||
|
||||
|
||||
# Parse / structural failures
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_rejects_garbage_string():
|
||||
with pytest.raises(UnsafeUserUrlError):
|
||||
validate_user_base_url("not a url")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_rejects_empty_string():
|
||||
with pytest.raises(UnsafeUserUrlError, match="non-empty"):
|
||||
validate_user_base_url("")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_rejects_whitespace_only_string():
|
||||
with pytest.raises(UnsafeUserUrlError, match="non-empty"):
|
||||
validate_user_base_url(" ")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_rejects_url_without_hostname():
|
||||
with pytest.raises(UnsafeUserUrlError):
|
||||
validate_user_base_url("https:///v1")
|
||||
|
||||
|
||||
# DNS-mocking tests for hostnames (rebinding-style scenarios)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_rejects_hostname_resolving_only_to_private():
|
||||
with mock.patch("socket.getaddrinfo", return_value=_addrinfo("10.0.0.5")):
|
||||
with pytest.raises(UnsafeUserUrlError, match="blocked address"):
|
||||
validate_user_base_url("https://internal.example.com/v1")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_rejects_hostname_with_mixed_public_and_private():
|
||||
# Public IP first, private second — must still reject because ANY
|
||||
# blocked address in the answer set is enough.
|
||||
with mock.patch(
|
||||
"socket.getaddrinfo",
|
||||
return_value=_addrinfo("93.184.216.34", "10.0.0.5"),
|
||||
):
|
||||
with pytest.raises(UnsafeUserUrlError, match="blocked address"):
|
||||
validate_user_base_url("https://rebinding.example.com/v1")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_rejects_hostname_when_dns_fails():
|
||||
with mock.patch(
|
||||
"socket.getaddrinfo",
|
||||
side_effect=socket.gaierror("nodename nor servname provided"),
|
||||
):
|
||||
with pytest.raises(UnsafeUserUrlError, match="could not resolve"):
|
||||
validate_user_base_url("https://nonexistent.invalid/v1")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_rejects_hostname_resolving_to_metadata_ip_via_dns():
|
||||
# Even if a hostname looks innocent, if DNS hands us 169.254.169.254
|
||||
# we must refuse — defense-in-depth at dispatch time.
|
||||
with mock.patch("socket.getaddrinfo", return_value=_addrinfo("169.254.169.254")):
|
||||
with pytest.raises(UnsafeUserUrlError, match="blocked address"):
|
||||
validate_user_base_url("https://innocent.example.com/v1")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_rejects_hostname_resolving_to_ipv6_loopback_via_dns():
|
||||
with mock.patch("socket.getaddrinfo", return_value=_addrinfo("::1")):
|
||||
with pytest.raises(UnsafeUserUrlError, match="blocked address"):
|
||||
validate_user_base_url("https://aaaa-only.example.com/v1")
|
||||
|
||||
|
||||
# pinned_post — single-resolve, IP-pinned outbound HTTP
|
||||
|
||||
|
||||
class _StubResponse:
|
||||
"""Drop-in for a ``requests.Response`` that ``Session.send`` returns."""
|
||||
|
||||
def __init__(self, status_code: int = 200) -> None:
|
||||
self.status_code = status_code
|
||||
self.text = ""
|
||||
self.headers = {"Content-Type": "application/json"}
|
||||
|
||||
|
||||
def _capture_send(monkeypatch):
|
||||
"""Replace ``requests.Session.send`` with a capturing stub.
|
||||
|
||||
Returns a dict that fills with ``prepared`` (the
|
||||
``PreparedRequest``) and ``send_kwargs`` once a request is issued,
|
||||
plus ``adapters`` so callers can inspect what was mounted on the
|
||||
session.
|
||||
"""
|
||||
captured: dict = {}
|
||||
|
||||
def _send(self, prepared, **kwargs):
|
||||
captured["prepared"] = prepared
|
||||
captured["send_kwargs"] = kwargs
|
||||
captured["adapters"] = dict(self.adapters)
|
||||
return _StubResponse()
|
||||
|
||||
monkeypatch.setattr(requests.Session, "send", _send)
|
||||
return captured
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_pinned_post_rewrites_host_to_resolved_ipv4(monkeypatch):
|
||||
captured = _capture_send(monkeypatch)
|
||||
with mock.patch("socket.getaddrinfo", return_value=_addrinfo("104.18.6.192")):
|
||||
pinned_post(
|
||||
"https://api.openai.com/v1/chat/completions",
|
||||
json={"hi": True},
|
||||
headers={"Authorization": "Bearer sk-x"},
|
||||
timeout=5,
|
||||
allow_redirects=False,
|
||||
)
|
||||
|
||||
prepared = captured["prepared"]
|
||||
assert prepared.url == "https://104.18.6.192/v1/chat/completions"
|
||||
# Host header carries the original hostname so vhost-routing and
|
||||
# SNI/cert verification target the right server.
|
||||
assert prepared.headers["Host"] == "api.openai.com"
|
||||
# Caller-supplied headers are preserved.
|
||||
assert prepared.headers["Authorization"] == "Bearer sk-x"
|
||||
# The body was sent as JSON.
|
||||
assert prepared.body == b'{"hi": true}'
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_pinned_post_brackets_ipv6_in_url(monkeypatch):
|
||||
captured = _capture_send(monkeypatch)
|
||||
with mock.patch(
|
||||
"socket.getaddrinfo", return_value=_addrinfo("2606:4700::6810:1234")
|
||||
):
|
||||
pinned_post(
|
||||
"https://example.com/v1/x",
|
||||
json={},
|
||||
headers={},
|
||||
timeout=5,
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert (
|
||||
captured["prepared"].url == "https://[2606:4700::6810:1234]/v1/x"
|
||||
)
|
||||
assert captured["prepared"].headers["Host"] == "example.com"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_pinned_post_preserves_explicit_port(monkeypatch):
|
||||
captured = _capture_send(monkeypatch)
|
||||
with mock.patch("socket.getaddrinfo", return_value=_addrinfo("93.184.216.34")):
|
||||
pinned_post(
|
||||
"https://api.example.com:8443/v1/test",
|
||||
json={},
|
||||
headers={},
|
||||
timeout=5,
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert captured["prepared"].url == "https://93.184.216.34:8443/v1/test"
|
||||
# Host header keeps the original :port — proxies and vhost routers
|
||||
# rely on this, and SNI conventionally carries the bare hostname.
|
||||
assert captured["prepared"].headers["Host"] == "api.example.com:8443"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_pinned_post_preserves_url_userinfo(monkeypatch):
|
||||
captured = _capture_send(monkeypatch)
|
||||
with mock.patch("socket.getaddrinfo", return_value=_addrinfo("93.184.216.34")):
|
||||
pinned_post(
|
||||
"https://user:pass@api.example.com:8443/v1/test",
|
||||
json={},
|
||||
headers={},
|
||||
timeout=5,
|
||||
allow_redirects=False,
|
||||
)
|
||||
prepared = captured["prepared"]
|
||||
assert prepared.url == "https://user:pass@93.184.216.34:8443/v1/test"
|
||||
assert prepared.headers["Host"] == "api.example.com:8443"
|
||||
assert prepared.headers["Authorization"] == "Basic dXNlcjpwYXNz"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_pinned_post_handles_ip_literal_url_without_dns(monkeypatch):
|
||||
"""If the URL already has an IP literal, no DNS lookup happens."""
|
||||
|
||||
captured = _capture_send(monkeypatch)
|
||||
with mock.patch("socket.getaddrinfo") as gai:
|
||||
pinned_post(
|
||||
"https://93.184.216.34/v1/x",
|
||||
json={},
|
||||
headers={},
|
||||
timeout=5,
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert gai.call_count == 0
|
||||
assert captured["prepared"].url == "https://93.184.216.34/v1/x"
|
||||
assert captured["prepared"].headers["Host"] == "93.184.216.34"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_pinned_post_resolves_dns_exactly_once(monkeypatch):
|
||||
"""The whole point of the helper: one resolution, one connection.
|
||||
|
||||
A DNS-rebinding attacker wins by getting a second ``getaddrinfo``
|
||||
call after the first one was validated. If this assertion ever
|
||||
fails, the SSRF guard has regressed.
|
||||
"""
|
||||
|
||||
captured = _capture_send(monkeypatch)
|
||||
with mock.patch(
|
||||
"socket.getaddrinfo", return_value=_addrinfo("104.18.6.192")
|
||||
) as gai:
|
||||
pinned_post(
|
||||
"https://api.openai.com/v1/chat/completions",
|
||||
json={},
|
||||
headers={},
|
||||
timeout=5,
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert gai.call_count == 1
|
||||
assert captured["prepared"].url.startswith("https://104.18.6.192/")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_pinned_post_mounts_pinned_adapter_for_https(monkeypatch):
|
||||
"""For HTTPS, a custom adapter must be mounted that overrides
|
||||
``server_hostname`` / ``assert_hostname`` so SNI and cert
|
||||
verification target the original hostname even though we connect
|
||||
to an IP literal.
|
||||
"""
|
||||
|
||||
captured = _capture_send(monkeypatch)
|
||||
with mock.patch("socket.getaddrinfo", return_value=_addrinfo("104.18.6.192")):
|
||||
pinned_post(
|
||||
"https://api.openai.com/v1/x",
|
||||
json={},
|
||||
headers={},
|
||||
timeout=5,
|
||||
allow_redirects=False,
|
||||
)
|
||||
|
||||
https_adapter = captured["adapters"]["https://"]
|
||||
# _PinnedHostAdapter is the symbol we expect; also check it carries
|
||||
# the original hostname so SNI/cert verification line up.
|
||||
assert type(https_adapter).__name__ == "_PinnedHostAdapter"
|
||||
assert https_adapter._server_hostname == "api.openai.com"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_pinned_post_does_not_mount_https_adapter_for_http(monkeypatch):
|
||||
"""For HTTP, no SNI/cert logic is needed — the default adapter
|
||||
should remain in place; only the URL rewrite + Host header matter."""
|
||||
|
||||
captured = _capture_send(monkeypatch)
|
||||
with mock.patch("socket.getaddrinfo", return_value=_addrinfo("93.184.216.34")):
|
||||
pinned_post(
|
||||
"http://example.com/v1/x",
|
||||
json={},
|
||||
headers={},
|
||||
timeout=5,
|
||||
allow_redirects=False,
|
||||
)
|
||||
https_adapter = captured["adapters"]["https://"]
|
||||
assert type(https_adapter).__name__ == "HTTPAdapter"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_pinned_post_raises_for_blocked_dns_result(monkeypatch):
|
||||
"""A hostname that resolves to a private IP must be rejected
|
||||
*before* any HTTP request is dispatched."""
|
||||
|
||||
captured = _capture_send(monkeypatch)
|
||||
with mock.patch("socket.getaddrinfo", return_value=_addrinfo("10.0.0.5")):
|
||||
with pytest.raises(UnsafeUserUrlError, match="blocked address"):
|
||||
pinned_post(
|
||||
"https://internal.example.com/v1/x",
|
||||
json={},
|
||||
headers={},
|
||||
timeout=5,
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert "prepared" not in captured
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_pinned_post_raises_for_loopback_ip_literal(monkeypatch):
|
||||
captured = _capture_send(monkeypatch)
|
||||
with pytest.raises(UnsafeUserUrlError, match="blocked address"):
|
||||
pinned_post(
|
||||
"https://127.0.0.1/v1/x",
|
||||
json={},
|
||||
headers={},
|
||||
timeout=5,
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert "prepared" not in captured
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_pinned_post_raises_for_disallowed_scheme(monkeypatch):
|
||||
captured = _capture_send(monkeypatch)
|
||||
with pytest.raises(UnsafeUserUrlError, match="scheme"):
|
||||
pinned_post(
|
||||
"ftp://example.com/v1/x",
|
||||
json={},
|
||||
headers={},
|
||||
timeout=5,
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert "prepared" not in captured
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_pinned_post_overrides_caller_supplied_host_header(monkeypatch):
|
||||
"""If the caller passes their own Host header, the helper must
|
||||
still set it to the resolved hostname so the in-flight request
|
||||
doesn't disagree with what was validated."""
|
||||
|
||||
captured = _capture_send(monkeypatch)
|
||||
with mock.patch("socket.getaddrinfo", return_value=_addrinfo("104.18.6.192")):
|
||||
pinned_post(
|
||||
"https://api.openai.com/v1/x",
|
||||
json={},
|
||||
headers={"Host": "evil.example.com"},
|
||||
timeout=5,
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert captured["prepared"].headers["Host"] == "api.openai.com"
|
||||
|
||||
|
||||
# pinned_httpx_client — DNS-rebinding-safe httpx transport for SDK use
|
||||
|
||||
|
||||
def _capture_httpx_handle_request(monkeypatch):
|
||||
"""Patch ``httpx.HTTPTransport.handle_request`` to record the
|
||||
request reaching the parent transport, and return a fake response.
|
||||
|
||||
The pinned transport's ``handle_request`` rewrites
|
||||
``request.url.host`` and sets ``sni_hostname`` *before* delegating
|
||||
to ``super().handle_request``. Capturing what the parent sees
|
||||
gives us the actual values that would feed httpcore's connect
|
||||
(and thus what TCP would dial / SNI would advertise) without
|
||||
opening a real socket.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
def fake_handle(self, request):
|
||||
captured["url"] = request.url
|
||||
captured["sni"] = request.extensions.get("sni_hostname")
|
||||
captured["host_header"] = request.headers.get("host")
|
||||
return httpx.Response(200, content=b"ok")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"httpx.HTTPTransport.handle_request", fake_handle
|
||||
)
|
||||
return captured
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_pinned_httpx_client_returns_pinned_transport():
|
||||
"""The factory must wire its transport in unchanged and bind it
|
||||
to the validated host and IP."""
|
||||
|
||||
with mock.patch(
|
||||
"socket.getaddrinfo", return_value=_addrinfo("104.18.6.192")
|
||||
):
|
||||
client = pinned_httpx_client("https://api.example.com/v1")
|
||||
try:
|
||||
assert isinstance(client._transport, _PinnedHTTPSTransport)
|
||||
assert client._transport._host == "api.example.com"
|
||||
assert client._transport._ip_netloc == "104.18.6.192"
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_pinned_httpx_client_disables_redirects():
|
||||
"""SSRF guard only inspects the supplied URL — following 3xx would
|
||||
let a hostile upstream bounce the in-network request to an
|
||||
internal address."""
|
||||
|
||||
with mock.patch(
|
||||
"socket.getaddrinfo", return_value=_addrinfo("104.18.6.192")
|
||||
):
|
||||
client = pinned_httpx_client("https://api.example.com/v1")
|
||||
try:
|
||||
assert client.follow_redirects is False
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_pinned_httpx_transport_rewrites_url_to_validated_ip(monkeypatch):
|
||||
"""The core invariant: every request reaching httpcore has its URL
|
||||
host pointed at the IP literal we validated, so TCP dials that IP
|
||||
rather than triggering a fresh DNS resolution."""
|
||||
|
||||
captured = _capture_httpx_handle_request(monkeypatch)
|
||||
|
||||
with mock.patch(
|
||||
"socket.getaddrinfo", return_value=_addrinfo("104.18.6.192")
|
||||
):
|
||||
client = pinned_httpx_client("https://api.example.com/v1")
|
||||
try:
|
||||
client.get("https://api.example.com/v1/test")
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
assert captured["url"].host == "104.18.6.192"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_pinned_httpx_transport_sets_sni_for_original_hostname(monkeypatch):
|
||||
"""TLS SNI / cert verification must use the original hostname; the
|
||||
transport sets it via the ``sni_hostname`` extension that
|
||||
httpcore forwards to ``start_tls``'s ``server_hostname``."""
|
||||
|
||||
captured = _capture_httpx_handle_request(monkeypatch)
|
||||
|
||||
with mock.patch(
|
||||
"socket.getaddrinfo", return_value=_addrinfo("104.18.6.192")
|
||||
):
|
||||
client = pinned_httpx_client("https://api.example.com/v1")
|
||||
try:
|
||||
client.get("https://api.example.com/v1/test")
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
assert captured["sni"] == b"api.example.com"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_pinned_httpx_transport_preserves_host_header(monkeypatch):
|
||||
"""``Host`` is auto-set by ``httpx.Request._prepare`` from the URL
|
||||
netloc *before* our transport rewrites the URL host. The header
|
||||
must still carry the original hostname."""
|
||||
|
||||
captured = _capture_httpx_handle_request(monkeypatch)
|
||||
|
||||
with mock.patch(
|
||||
"socket.getaddrinfo", return_value=_addrinfo("104.18.6.192")
|
||||
):
|
||||
client = pinned_httpx_client("https://api.example.com/v1")
|
||||
try:
|
||||
client.get("https://api.example.com/v1/test")
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
assert captured["host_header"] == "api.example.com"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_pinned_httpx_client_closes_dns_rebinding_window(monkeypatch):
|
||||
"""The TOCTOU lock-in test: validate against a public IP, then
|
||||
have DNS rebind to a private (loopback) IP, then send a request.
|
||||
The transport must dial the *first* validated IP — not the
|
||||
rebound one — guaranteeing no second DNS lookup interferes."""
|
||||
|
||||
captured = _capture_httpx_handle_request(monkeypatch)
|
||||
|
||||
# First lookup (at validation time) returns a public IP.
|
||||
public = _addrinfo("104.18.6.192")
|
||||
# Subsequent lookups (which the transport must NEVER trigger)
|
||||
# would return loopback if a hostile resolver flipped them.
|
||||
private = _addrinfo("127.0.0.1")
|
||||
|
||||
getaddrinfo_calls: list = []
|
||||
|
||||
def fake_getaddrinfo(*args, **kwargs):
|
||||
getaddrinfo_calls.append((args, kwargs))
|
||||
# First call = validation; everything after = post-rebind.
|
||||
return public if len(getaddrinfo_calls) == 1 else private
|
||||
|
||||
monkeypatch.setattr("socket.getaddrinfo", fake_getaddrinfo)
|
||||
|
||||
client = pinned_httpx_client("https://api.attacker.example/v1")
|
||||
try:
|
||||
client.get("https://api.attacker.example/v1/test")
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
# Whatever happens below the transport, the URL handed to
|
||||
# httpcore must be the IP from the *first* getaddrinfo call.
|
||||
assert captured["url"].host == "104.18.6.192", (
|
||||
"pinned transport must dial the IP validated at construction, "
|
||||
"not whatever DNS returns at request time"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_pinned_httpx_client_rejects_blocked_dns_result():
|
||||
"""If the validation lookup itself returns a private IP, the
|
||||
factory must refuse to construct a client at all."""
|
||||
|
||||
with mock.patch(
|
||||
"socket.getaddrinfo", return_value=_addrinfo("169.254.169.254")
|
||||
):
|
||||
with pytest.raises(UnsafeUserUrlError, match="link-local"):
|
||||
pinned_httpx_client("https://api.attacker.example/v1")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_pinned_httpx_client_rejects_loopback_literal():
|
||||
"""An IP literal in the supplied URL goes through the same guard
|
||||
even when DNS isn't called."""
|
||||
|
||||
with pytest.raises(UnsafeUserUrlError):
|
||||
pinned_httpx_client("http://127.0.0.1:8080/v1")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_pinned_httpx_transport_refuses_unexpected_host(monkeypatch):
|
||||
"""Defense in depth: if the SDK ever rewrites the request URL to a
|
||||
different host between Request construction and dial, the
|
||||
transport refuses rather than silently dialing the validated IP
|
||||
with a different host's credentials."""
|
||||
|
||||
import httpx
|
||||
|
||||
with mock.patch(
|
||||
"socket.getaddrinfo", return_value=_addrinfo("104.18.6.192")
|
||||
):
|
||||
client = pinned_httpx_client("https://api.example.com/v1")
|
||||
try:
|
||||
with pytest.raises(UnsafeUserUrlError, match="refused request"):
|
||||
client.get("https://other.example.com/v1/test")
|
||||
except httpx.RequestError:
|
||||
# Some httpx versions may wrap the transport error; accept
|
||||
# either path so long as the request didn't succeed.
|
||||
pass
|
||||
finally:
|
||||
client.close()
|
||||
Reference in New Issue
Block a user