Files
saas_backend/tests/test_ssrf.py
T
2026-08-31 20:39:41 -04:00

105 lines
3.3 KiB
Python

"""Refusing to fetch a URL that points back inside the network.
An identity provider is configured by a workspace administrator, who supplies an
issuer URL that the *server* then fetches. That is a request the platform makes
on a customer's instruction, to wherever the customer says, from inside the
network — server-side request forgery unless something checks.
"""
from __future__ import annotations
import pytest
from app.core.ssrf import (
PrivateAddressError,
pin_url_to_address,
resolve_public_address,
url_destination_error,
)
@pytest.mark.parametrize(
"host",
[
"127.0.0.1",
"localhost",
"0.0.0.0",
"10.0.0.5",
"192.168.1.1",
"172.16.0.1",
"169.254.169.254",
"::1",
"fd00::1",
],
)
def test_an_address_inside_the_network_is_refused(host):
with pytest.raises(PrivateAddressError):
resolve_public_address(host)
def test_a_public_address_is_allowed():
assert resolve_public_address("8.8.8.8") == "8.8.8.8"
def test_a_name_that_cannot_be_resolved_is_refused():
"""Refusing is the safe direction: an unresolvable name cannot be shown to
be public, and 'we could not check' is not 'it is fine'."""
with pytest.raises(PrivateAddressError):
resolve_public_address("no-such-host.invalid")
@pytest.mark.parametrize(
"url, fragment",
[
("ftp://example.com/x", "scheme"),
("http://example.com/x", "https"),
("https:///x", "host"),
("https://user:pw@example.com/x", "credentials"),
("https://127.0.0.1/x", "private"),
("https://169.254.169.254/latest/meta-data/", "private"),
],
)
def test_a_url_that_cannot_be_fetched_says_why(url, fragment):
error = url_destination_error(url)
assert error is not None
assert fragment in error.lower()
def test_credentials_in_a_url_are_refused_on_sight():
"""`https://evil@internal/` — the part before the @ is not the host, and a
reader skimming a configuration screen will believe it is."""
assert "credentials" in url_destination_error("https://evil@8.8.8.8/").lower()
def test_plain_http_is_allowed_only_when_asked_for():
assert url_destination_error("http://8.8.8.8/x", require_https=True) is not None
assert url_destination_error("http://8.8.8.8/x", require_https=False) is None
def test_the_connection_is_pinned_to_the_address_that_was_checked():
"""Closes the gap between checking a name and using it.
Resolving and then handing the *hostname* to the HTTP client lets the name
resolve again, to something else, in between — which is DNS rebinding, and
it defeats a check that only looks at the name.
"""
assert pin_url_to_address(
"https://idp.example.com/.well-known/openid-configuration", "93.184.216.34"
) == "https://93.184.216.34/.well-known/openid-configuration"
def test_pinning_keeps_the_port_and_the_query():
assert pin_url_to_address("https://idp.example.com:8443/token?x=1", "1.2.3.4") == (
"https://1.2.3.4:8443/token?x=1"
)
def test_pinning_brackets_an_ipv6_address():
assert pin_url_to_address("https://idp.example.com/x", "2606:4700::1111") == (
"https://[2606:4700::1111]/x"
)
def test_a_reachable_public_url_passes():
assert url_destination_error("https://8.8.8.8/.well-known/openid-configuration") is None