907bdaf043
TestWLEDSchemeInference in test_devices_routes covers the POST/PUT create-and-update flow with a stubbed WLED provider so the infer_http_scheme integration hop has end-to-end coverage instead of just the unit tests. test_url_scheme grows public IPv6 (Cloudflare / Google / Quad9 DNS), bracketed-form, and ULA cases. Adds an explicit pin for the Python ipaddress documentation-prefix quirk (2001:db8::/32 is is_private, so it routes to http:// even though some audits colloquially call it "public").
189 lines
5.7 KiB
Python
189 lines
5.7 KiB
Python
"""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")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# IPv6 literal regression — pre-hardening, the bare-label fallback split on
|
|
# the first ``:`` (``2606:4700:4700::1111`` → host="2606" → single label →
|
|
# treated as local mDNS-style) and quietly coerced public IPv6 LED targets
|
|
# to ``http://``. The bracketless ipaddress probe in ``_extract_hostname``
|
|
# fixes that; pin both the public and ULA branches so a future helper
|
|
# refactor can't regress either side.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"raw",
|
|
[
|
|
"2606:4700:4700::1111", # Cloudflare public DNS
|
|
"2001:4860:4860::8888", # Google public DNS
|
|
"2620:fe::fe", # Quad9
|
|
],
|
|
)
|
|
def test_public_ipv6_literal_gets_https(raw):
|
|
assert infer_http_scheme(raw) == f"https://{raw}"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"raw",
|
|
[
|
|
"[2606:4700:4700::1111]",
|
|
"[2606:4700:4700::1111]:443",
|
|
"[2001:4860:4860::8888]:8080",
|
|
],
|
|
)
|
|
def test_bracketed_public_ipv6_gets_https(raw):
|
|
assert infer_http_scheme(raw) == f"https://{raw}"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"raw",
|
|
[
|
|
"fc00::1", # ULA — RFC 4193
|
|
"fd12:3456:789a::1",
|
|
"fdc7::42",
|
|
],
|
|
)
|
|
def test_ula_ipv6_literal_gets_http(raw):
|
|
assert infer_http_scheme(raw) == f"http://{raw}"
|
|
|
|
|
|
def test_documentation_prefix_ipv6_treated_as_private():
|
|
"""Python's :mod:`ipaddress` classifies ``2001:db8::/32`` as private
|
|
(RFC 3849 documentation range) so the scheme inferrer reaches for the
|
|
LAN-default ``http://``. Test pins the actual behaviour rather than
|
|
the colloquial "public" label the address is sometimes given."""
|
|
assert infer_http_scheme("2001:db8::1") == "http://2001:db8::1"
|