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:
@@ -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
|
||||
Reference in New Issue
Block a user