fix(utils): commit url_scheme + net_classify dependencies

The DDP commit (8f1140a) added imports of infer_http_scheme into
api/routes/devices.py but missed bringing in the module itself --
url_scheme.py and its net_classify.py dependency were in the working
tree as untracked files only. On a clean checkout the FastAPI app
fails to start with ModuleNotFoundError.

Caught by the pre-merge code review. The 1358 passing tests only
worked because the local working tree happens to have the files.

This commit adds:
- ledgrab.utils.url_scheme: infer_http_scheme() for LAN-vs-public WLED
  URL scheme inference
- ledgrab.utils.net_classify: HostCategory enum + classify_ip() +
  is_blocked_for_ssrf() + is_local_for_http_default() + is_loopback().
  Single source of truth for IP categorisation used by safe_source
  (SSRF), url_scheme (LAN), and auth (loopback exemption).
- 107 unit tests (test_url_scheme.py + test_net_classify.py).

net_classify.is_blocked_for_ssrf is the primitive the device-driver
validate_device methods will use in the next commit to close HIGH #4
from the review.
This commit is contained in:
2026-05-16 10:46:45 +03:00
parent 390d2b472c
commit 7736bc6f58
4 changed files with 557 additions and 0 deletions
+137
View File
@@ -0,0 +1,137 @@
"""Tests for ledgrab.utils.net_classify."""
import pytest
from ledgrab.utils.net_classify import (
HostCategory,
classify_ip,
is_blocked_for_ssrf,
is_local_for_http_default,
is_loopback,
)
@pytest.mark.parametrize(
"host,expected",
[
("127.0.0.1", HostCategory.LOOPBACK),
("::1", HostCategory.LOOPBACK),
("10.0.0.5", HostCategory.PRIVATE),
("192.168.1.1", HostCategory.PRIVATE),
("172.16.0.5", HostCategory.PRIVATE),
("fd00::1", HostCategory.PRIVATE), # ULA
("169.254.1.1", HostCategory.LINK_LOCAL),
("fe80::1", HostCategory.LINK_LOCAL),
("0.0.0.0", HostCategory.UNSPECIFIED),
("::", HostCategory.UNSPECIFIED),
("224.0.0.1", HostCategory.MULTICAST),
("ff00::1", HostCategory.MULTICAST),
# ``240.0.0.0/4`` (class E) is labelled both ``is_private`` and
# ``is_reserved`` by Python; we keep PRIVATE first which is the
# stricter SSRF policy.
("240.0.0.1", HostCategory.PRIVATE),
("8.8.8.8", HostCategory.PUBLIC),
("1.1.1.1", HostCategory.PUBLIC),
("2606:4700:4700::1111", HostCategory.PUBLIC),
("not-an-ip", HostCategory.UNPARSEABLE),
("", HostCategory.UNPARSEABLE),
("example.com", HostCategory.UNPARSEABLE),
],
)
def test_classify_ip(host: str, expected: HostCategory) -> None:
assert classify_ip(host) is expected
# ---------------------------------------------------------------------------
# SSRF block list — must include EVERY non-public category, including
# unparseable inputs. Regression guard: if anyone narrows this set we lose
# SSRF protection.
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"host",
[
"127.0.0.1",
"::1",
"10.0.0.5",
"192.168.1.1",
"172.16.0.5",
"fd00::1",
"169.254.1.1",
"fe80::1",
"0.0.0.0",
"::",
"224.0.0.1",
"ff00::1",
"240.0.0.1",
"not-an-ip", # unparseable → blocked
"", # unparseable → blocked
],
)
def test_is_blocked_for_ssrf_blocks_non_public(host: str) -> None:
assert is_blocked_for_ssrf(host) is True
@pytest.mark.parametrize(
"host",
["8.8.8.8", "1.1.1.1", "2606:4700:4700::1111"],
)
def test_is_blocked_for_ssrf_allows_public(host: str) -> None:
assert is_blocked_for_ssrf(host) is False
# ---------------------------------------------------------------------------
# LAN-default policy — narrower than SSRF: we infer ``http://`` for loopback
# / private / link-local / unspecified, NOT for multicast / reserved /
# unparseable (those should fall through to ``https://`` or to caller-side
# heuristics like mDNS suffix matching).
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"host",
["127.0.0.1", "::1", "10.0.0.5", "192.168.1.1", "fe80::1", "0.0.0.0"],
)
def test_is_local_for_http_default_true(host: str) -> None:
assert is_local_for_http_default(host) is True
@pytest.mark.parametrize(
"host",
["8.8.8.8", "224.0.0.1", "not-an-ip", ""],
)
def test_is_local_for_http_default_false(host: str) -> None:
assert is_local_for_http_default(host) is False
# ---------------------------------------------------------------------------
# Loopback predicate — accepts both literals and the auth module's textual
# placeholders (localhost, testclient).
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"host",
[
"127.0.0.1",
"127.0.0.5",
"::1",
"localhost",
"LOCALHOST",
"testclient",
"[::1]",
"fe80::1%eth0", # ← link-local with zone — must NOT match
],
)
def test_is_loopback_recognises_loopback(host: str) -> None:
expected = host != "fe80::1%eth0"
assert is_loopback(host) is expected
@pytest.mark.parametrize(
"host",
["8.8.8.8", "10.0.0.5", "example.com", "", None],
)
def test_is_loopback_rejects_other(host) -> None:
assert is_loopback(host) is False
+134
View File
@@ -0,0 +1,134 @@
"""Tests for ledgrab.utils.url_scheme.infer_http_scheme."""
import pytest
from ledgrab.utils.url_scheme import infer_http_scheme
@pytest.mark.parametrize(
"url",
[
"http://192.168.1.10",
"https://wled.example.com",
"HTTP://wled.local",
"HTTPS://example.com/api",
"ws://device.local",
"openrgb://localhost:6742/0",
],
)
def test_preserves_existing_scheme(url):
assert infer_http_scheme(url) == url
@pytest.mark.parametrize(
"raw",
[
"192.168.1.10",
"192.168.1.10:8080",
"10.0.0.5",
"172.16.1.1",
"127.0.0.1",
"localhost",
"localhost:8080",
"wled-desk.local",
"wled-desk.local:80",
"wled", # bare label ⇒ mDNS-style
"kitchen-strip",
"[::1]",
"[fe80::1]:80",
"fe80::1", # bracketless link-local IPv6
"device.lan",
"rack.home",
"service.internal",
],
)
def test_local_targets_get_http(raw):
assert infer_http_scheme(raw) == f"http://{raw}"
@pytest.mark.parametrize(
"raw",
[
"example.com",
"wled.example.com",
"wled.example.com:443",
"wled.example.com/api",
"1.2.3.4", # public IPv4
"8.8.8.8:80",
"my-host.io/path?x=1",
],
)
def test_external_targets_get_https(raw):
assert infer_http_scheme(raw) == f"https://{raw}"
def test_trims_whitespace_before_inference():
assert infer_http_scheme(" 192.168.0.1 ") == "http://192.168.0.1"
assert infer_http_scheme(" example.com ") == "https://example.com"
def test_empty_string_returns_unchanged():
assert infer_http_scheme("") == ""
def test_none_returns_unchanged():
# Callers occasionally hand us None; preserve it so the validator can complain.
assert infer_http_scheme(None) is None
def test_whitespace_only_collapses_to_empty():
# Whitespace alone has no host to infer a scheme for — trim and bail out.
assert infer_http_scheme(" ") == ""
# ---------------------------------------------------------------------------
# Malicious / hostile inputs — must round-trip *unchanged* (no scheme
# coerced onto them) so the downstream validator surfaces a clean error
# rather than letting a coerced scheme slip past as a "valid" URL.
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"raw",
[
"javascript:alert(1)", # already has a scheme — must pass through
"data:text/html,<script>",
"file:///etc/passwd",
"gopher://internal/path",
"ftp://files.example.com",
],
)
def test_non_http_schemes_pass_through_untouched(raw):
"""A scheme other than http/https is the caller's problem to validate."""
assert infer_http_scheme(raw) == raw
@pytest.mark.parametrize(
"raw",
[
"evil.com@192.168.1.1", # bare-host userinfo trick
"evil.com@192.168.1.1/path",
"wled\x00.local", # NUL byte
"wled\r\nHost: x", # CRLF injection inside host
"wled\x07.local", # BEL
"host\x1f.local", # US (control char)
"host\x7f.local", # DEL
],
)
def test_forbidden_characters_leave_input_unchanged(raw):
"""Inputs containing ``@`` or control chars must NOT receive a scheme.
Returning them unchanged forces the downstream validator (httpx /
WLED provider) to reject them with a precise error rather than us
silently prefixing ``http://``/``https://`` and producing a request
against an attacker-controlled target.
"""
# The result should NOT carry an inferred http(s):// prefix.
result = infer_http_scheme(raw)
assert not result.startswith(("http://", "https://"))
def test_userinfo_does_not_get_https():
"""``evil.com@192.168.1.1`` must not be coerced to ``https://`` (which
would send credentials ``evil.com`` to ``192.168.1.1``)."""
assert "://" not in infer_http_scheme("evil.com@192.168.1.1")