Skip to content

Commit 420a4d1

Browse files
committed
Reject short-form loopback on pairing encode paths.
1 parent 84e06fa commit 420a4d1

5 files changed

Lines changed: 68 additions & 10 deletions

File tree

app/pairing.py

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -287,20 +287,22 @@ def _from_ifconfig(self) -> None:
287287
def is_phone_reachable_gateway_url(url: str) -> bool:
288288
"""True when a phone on LAN/VPN can open this gateway base URL.
289289
290-
Rejects empty hosts, ``localhost``, IPv4/IPv6 loopback, link-local,
291-
unspecified, and multicast. Non-IP hostnames (MagicDNS, custom DNS) pass.
290+
Rejects empty hosts, ``localhost`` / ``localhost.*``, abbreviated IPv4
291+
loopback (``127.1``), IPv4/IPv6 loopback, link-local, unspecified, and
292+
multicast. Other non-IP hostnames (MagicDNS, custom DNS) pass without DNS
293+
lookup.
292294
"""
293295
host = urlparse(url).hostname
294296
if not host:
295297
return False
296-
if host.lower() == "localhost":
298+
lowered = host.lower()
299+
if lowered == "localhost" or lowered.startswith("localhost."):
297300
return False
298-
try:
299-
ip_address = ipaddress.ip_address(host)
300-
except ValueError:
301+
ip_address = _parse_gateway_host_ip(host)
302+
if ip_address is None:
301303
return True
302304
if isinstance(ip_address, ipaddress.IPv4Address):
303-
return _Ipv4Policy.is_reachable(host)
305+
return _Ipv4Policy.is_reachable(str(ip_address))
304306
blocked = (
305307
ip_address.is_loopback
306308
or ip_address.is_link_local
@@ -310,6 +312,21 @@ def is_phone_reachable_gateway_url(url: str) -> bool:
310312
return not blocked
311313

312314

315+
def _parse_gateway_host_ip(host: str) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None:
316+
"""Parse a URL host as an IP, including short-form IPv4 that ``ip_address`` rejects."""
317+
try:
318+
return ipaddress.ip_address(host)
319+
except ValueError:
320+
return _parse_short_ipv4_host(host)
321+
322+
323+
def _parse_short_ipv4_host(host: str) -> ipaddress.IPv4Address | None:
324+
try:
325+
return ipaddress.IPv4Address(socket.inet_aton(host))
326+
except OSError:
327+
return None
328+
329+
313330
def unreachable_pairing_override() -> tuple[str, str] | None:
314331
"""Return ``(env_key, raw_value)`` when a pairing override is set but unusable.
315332
@@ -350,6 +367,8 @@ def default_url(cls, port: int, *, saved_pairing_url: str | None = None) -> str
350367

351368
@classmethod
352369
def _keep_saved(cls, port: int, saved_pairing_url: str) -> bool:
370+
if not is_phone_reachable_gateway_url(saved_pairing_url):
371+
return False
353372
if not is_ambient_lan_address(saved_pairing_url):
354373
return True
355374
return saved_pairing_url in cls.discover(port)

app/pairing_view.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
discover_gateway_base_urls,
1212
encode_pairing_payload,
1313
is_ambient_lan_address,
14+
is_phone_reachable_gateway_url,
1415
normalize_gateway_input,
1516
primary_gateway_base_url,
1617
qr_svg_for_payload,
@@ -187,9 +188,16 @@ def resolve_pairing_url(ctx: GatewayContext, url: str | None) -> str:
187188
candidates = discover_gateway_base_urls(ctx.settings.port)
188189
if url:
189190
try:
190-
return normalize_gateway_input(url, ctx.settings.port)
191+
normalized = normalize_gateway_input(url, ctx.settings.port)
191192
except ValueError as error:
192193
raise APIProblem(HTTP_400_BAD_REQUEST, "invalid_pairing_url", str(error)) from error
194+
if not is_phone_reachable_gateway_url(normalized):
195+
raise APIProblem(
196+
HTTP_400_BAD_REQUEST,
197+
"invalid_pairing_url",
198+
"Pairing URL must be phone-reachable (not loopback or link-local).",
199+
)
200+
return normalized
193201
if not candidates:
194202
detail = "No phone-reachable gateway address was detected."
195203
unreachable = unreachable_pairing_override()

docs/desktop-embed.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,10 @@ decode `payload` to recover `{v,url,token}`. Optional
3838

3939
`VOCAGATEWAY_PUBLIC_URL` (alias `VOCAGATEWAY_PAIRING_URL`) must be a
4040
phone-reachable, non-loopback base URL. The gateway drops loopback and
41-
link-local overrides from discovery, and the QR never encodes loopback. Set
41+
link-local overrides from discovery (including short-form loopback like
42+
`127.1` and `localhost.*`). Pairing encode paths (`/v1/admin/pairing`,
43+
`qr.svg`, and explicit `?url=`) reject the same hosts, so the QR never
44+
encodes loopback. Set
4245
the override whenever auto-discovery would otherwise advertise an address the
4346
phone cannot open (Docker bridge, reverse proxy, Tailscale Serve hostname).
4447

tests/test_pairing.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,21 @@ def test_is_phone_reachable_gateway_url() -> None:
4141
assert is_phone_reachable_gateway_url("http://homelab.example:8765") is True
4242
assert is_phone_reachable_gateway_url("http://100.101.102.103:8765") is True
4343
assert is_phone_reachable_gateway_url("http://127.0.0.1:8765") is False
44+
assert is_phone_reachable_gateway_url("http://127.1:8765") is False
4445
assert is_phone_reachable_gateway_url("http://LOCALHOST:8765") is False
46+
assert is_phone_reachable_gateway_url("http://localhost.localdomain:8765") is False
4547
assert is_phone_reachable_gateway_url("http://[::1]:8765") is False
48+
assert is_phone_reachable_gateway_url("http://[::ffff:127.0.0.1]:8765") is False
4649
assert is_phone_reachable_gateway_url("http://[fe80::1]:8765") is False
4750
assert is_phone_reachable_gateway_url("http://169.254.1.1:8765") is False
4851

4952

53+
def test_discover_rejects_short_loopback_public_url(monkeypatch: pytest.MonkeyPatch) -> None:
54+
monkeypatch.setenv(PUBLIC_URL_ENVIRONMENT_VARIABLE, "http://127.1:8765")
55+
assert "http://127.1:8765" not in discover_gateway_base_urls(DEFAULT_GATEWAY_PORT)
56+
assert "http://127.0.0.1:8765" not in discover_gateway_base_urls(DEFAULT_GATEWAY_PORT)
57+
58+
5059
def test_discover_rejects_loopback_public_url(monkeypatch: pytest.MonkeyPatch) -> None:
5160
monkeypatch.setenv(PUBLIC_URL_ENVIRONMENT_VARIABLE, "http://127.0.0.1:8765")
5261
assert "http://127.0.0.1:8765" not in discover_gateway_base_urls(DEFAULT_GATEWAY_PORT)

tests/test_pairing_api.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@
77
import httpx
88
import pytest
99
from conftest import TOKEN, FakeEngine, FakeNormalizer
10-
from starlette.status import HTTP_200_OK, HTTP_401_UNAUTHORIZED, HTTP_503_SERVICE_UNAVAILABLE
10+
from starlette.status import (
11+
HTTP_200_OK,
12+
HTTP_400_BAD_REQUEST,
13+
HTTP_401_UNAUTHORIZED,
14+
HTTP_503_SERVICE_UNAVAILABLE,
15+
)
1116

1217
from app.config import Settings
1318
from app.main import create_app
@@ -93,6 +98,20 @@ async def _assert_network_switch_pairing(
9398
IDENTIFIER_KEY = "id"
9499

95100

101+
102+
@pytest.mark.asyncio
103+
async def test_pairing_rejects_loopback_query_url(
104+
client: httpx.AsyncClient,
105+
authorization: dict[str, str],
106+
) -> None:
107+
response = await client.get(
108+
PAIRING_API_PATH,
109+
headers=authorization,
110+
params={URL_KEY: "http://127.1:8765"},
111+
)
112+
assert response.status_code == HTTP_400_BAD_REQUEST
113+
114+
96115
@pytest.mark.asyncio
97116
async def test_pairing_rejects_loopback_public_url(
98117
client: httpx.AsyncClient,

0 commit comments

Comments
 (0)