From 980388651e59772d391d106d8c01e76aa3b8af11 Mon Sep 17 00:00:00 2001 From: RaresKeY <158580472+RaresKeY@users.noreply.github.com> Date: Mon, 20 Jul 2026 08:10:25 +0000 Subject: [PATCH 1/3] fix(companion): honor configured pairing origin --- .env.example | 6 ++ companion/pairing.py | 55 +++++++++++++- companion/routes.py | 22 ++++-- docker-compose.gpu-amd.yml | 1 + docker-compose.gpu-nvidia.yml | 1 + docker-compose.yml | 1 + tests/test_companion_pairing.py | 100 ++++++++++++++++++++++++++ tests/test_docker_devops_hardening.py | 5 ++ 8 files changed, 184 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index d23276eb8..9c223d11a 100644 --- a/.env.example +++ b/.env.example @@ -76,6 +76,12 @@ SEARXNG_INSTANCE=http://localhost:8080 # Change this if another local service already uses 7000 (macOS AirPlay often does). # APP_PORT=7000 +# Optional trusted origin advertised in companion/mobile pairing codes. Set this +# when Docker, a VPN, or a reverse proxy would otherwise advertise a container +# address or loopback. Use only an exact http(s) origin: no credentials, path, +# query, or fragment. HTTPS origins default to port 443; HTTP defaults to 80. +# COMPANION_BASE_URL=https://odysseus.example + # Development-only auth bypass for loopback requests. # Keep false for Docker, LAN, reverse proxy, and any shared deployment. # LOCALHOST_BYPASS=false diff --git a/companion/pairing.py b/companion/pairing.py index c4ea62345..06aff7466 100644 --- a/companion/pairing.py +++ b/companion/pairing.py @@ -11,6 +11,7 @@ import os import secrets import socket import uuid +from urllib.parse import urlsplit import bcrypt @@ -20,6 +21,53 @@ PAIRING_VERSION = 1 COMPANION_SCOPE = "chat" +def parse_companion_base_url(value: str) -> tuple[str, str, int]: + """Validate a trusted companion origin and return (origin, host, port). + + Pairing credentials are sent to this origin, so accept only a canonical + HTTP(S) origin. Paths, credentials, and other URL components are rejected + instead of being silently discarded. + """ + if not isinstance(value, str) or not value: + raise ValueError("COMPANION_BASE_URL must be an HTTP(S) origin") + if any(ord(char) <= 32 or ord(char) == 127 or char == "\\" for char in value): + raise ValueError( + "COMPANION_BASE_URL must not contain whitespace or control characters" + ) + + try: + parsed = urlsplit(value) + port = parsed.port + except ValueError as exc: + raise ValueError("COMPANION_BASE_URL must be a valid HTTP(S) origin") from exc + + scheme = parsed.scheme.lower() + host = parsed.hostname + if scheme not in {"http", "https"} or not parsed.netloc or not host: + raise ValueError("COMPANION_BASE_URL must be an HTTP(S) origin") + if parsed.username is not None or parsed.password is not None: + raise ValueError("COMPANION_BASE_URL must not contain credentials") + if parsed.path or parsed.query or parsed.fragment: + raise ValueError("COMPANION_BASE_URL must not contain a path, query, or fragment") + if port is not None and not 1 <= port <= 65535: + raise ValueError("COMPANION_BASE_URL port must be between 1 and 65535") + + display_host = f"[{host}]" if ":" in host else host + netloc = f"{display_host}:{port}" if port is not None else display_host + origin = f"{scheme}://{netloc}" + if value != origin: + raise ValueError("COMPANION_BASE_URL must be a canonical HTTP(S) origin") + return origin, host, port or (443 if scheme == "https" else 80) + + +def configured_companion_origin() -> tuple[str, str, int] | None: + """Return the validated operator-configured pairing origin, if any.""" + value = os.environ.get("COMPANION_BASE_URL") + if value is None or value == "": + return None + return parse_companion_base_url(value) + + def default_port() -> int: """Best guess at the port the server is reachable on. Callers that know the real request port should pass it explicitly.""" @@ -106,9 +154,12 @@ def mint_token(owner: str, name: str = "companion") -> tuple[str, str]: return token_id, raw_token -def pairing_payload(host: str, port: int, token: str) -> dict: +def pairing_payload(host: str, port: int, token: str, *, base_url: str | None = None) -> dict: """The exact JSON a client scans / accepts. Keep keys stable.""" - return {"v": PAIRING_VERSION, "host": host, "port": port, "token": token} + payload = {"v": PAIRING_VERSION, "host": host, "port": port, "token": token} + if base_url: + payload["base_url"] = base_url + return payload def pairing_qr_png_data_uri(payload: dict) -> str | None: diff --git a/companion/routes.py b/companion/routes.py index 0191640ef..e43b7d19e 100644 --- a/companion/routes.py +++ b/companion/routes.py @@ -194,19 +194,28 @@ def setup_companion_routes() -> APIRouter: the code works immediately, no restart. `?format=json` returns the payload for an in-app pairing screen.""" require_admin(request) + try: + configured_origin = _pairing.configured_companion_origin() + except ValueError as exc: + raise HTTPException(500, str(exc)) from None owner = get_current_user(request) invalidate = getattr(request.app.state, "invalidate_token_cache", None) token_id, raw_token = mint_pairing_token(owner, invalidate) - hosts = _pairing.lan_ip_candidates() - host = hosts[0] if hosts else "127.0.0.1" - port = request.url.port or _pairing.default_port() - payload = _pairing.pairing_payload(host, port, raw_token) + if configured_origin: + base_url, host, port = configured_origin + hosts = [host] + else: + base_url = None + hosts = _pairing.lan_ip_candidates() + host = hosts[0] if hosts else "127.0.0.1" + port = request.url.port or _pairing.default_port() + payload = _pairing.pairing_payload(host, port, raw_token, base_url=base_url) qr = _pairing.pairing_qr_png_data_uri(payload) qr_ok = bool(qr and qr.startswith("data:image/png;base64,")) if (request.query_params.get("format") or "").lower() == "json": - return { + response = { "host": host, "port": port, "token": raw_token, @@ -215,6 +224,9 @@ def setup_companion_routes() -> APIRouter: "payload": payload, "qr": qr if qr_ok else None, } + if base_url: + response["base_url"] = base_url + return response import json as _json payload_json = _json.dumps(payload, separators=(",", ":")) diff --git a/docker-compose.gpu-amd.yml b/docker-compose.gpu-amd.yml index 91e223e05..89038eaba 100644 --- a/docker-compose.gpu-amd.yml +++ b/docker-compose.gpu-amd.yml @@ -46,6 +46,7 @@ services: - DATABASE_URL=${DATABASE_URL:-sqlite:///./data/app.db} - AUTH_ENABLED=${AUTH_ENABLED:-true} - LOCALHOST_BYPASS=${LOCALHOST_BYPASS:-false} + - COMPANION_BASE_URL=${COMPANION_BASE_URL:-} - ODYSSEUS_ADMIN_USER=${ODYSSEUS_ADMIN_USER:-admin} - ODYSSEUS_ADMIN_PASSWORD=${ODYSSEUS_ADMIN_PASSWORD:-} - ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost,http://127.0.0.1} diff --git a/docker-compose.gpu-nvidia.yml b/docker-compose.gpu-nvidia.yml index e8c2fd032..127bbca89 100644 --- a/docker-compose.gpu-nvidia.yml +++ b/docker-compose.gpu-nvidia.yml @@ -45,6 +45,7 @@ services: - DATABASE_URL=${DATABASE_URL:-sqlite:///./data/app.db} - AUTH_ENABLED=${AUTH_ENABLED:-true} - LOCALHOST_BYPASS=${LOCALHOST_BYPASS:-false} + - COMPANION_BASE_URL=${COMPANION_BASE_URL:-} - ODYSSEUS_ADMIN_USER=${ODYSSEUS_ADMIN_USER:-admin} - ODYSSEUS_ADMIN_PASSWORD=${ODYSSEUS_ADMIN_PASSWORD:-} - ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost,http://127.0.0.1} diff --git a/docker-compose.yml b/docker-compose.yml index b1f2c37ee..f862fe92a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -34,6 +34,7 @@ services: - DATABASE_URL=${DATABASE_URL:-sqlite:///./data/app.db} - AUTH_ENABLED=${AUTH_ENABLED:-true} - LOCALHOST_BYPASS=${LOCALHOST_BYPASS:-false} + - COMPANION_BASE_URL=${COMPANION_BASE_URL:-} - ODYSSEUS_ADMIN_USER=${ODYSSEUS_ADMIN_USER:-admin} - ODYSSEUS_ADMIN_PASSWORD=${ODYSSEUS_ADMIN_PASSWORD:-} - ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost,http://127.0.0.1} diff --git a/tests/test_companion_pairing.py b/tests/test_companion_pairing.py index 8121ee76f..3357f5165 100644 --- a/tests/test_companion_pairing.py +++ b/tests/test_companion_pairing.py @@ -55,6 +55,7 @@ _db.ApiToken = _ApiToken @pytest.fixture(autouse=True) def _companion_pairing_stubs(monkeypatch): + monkeypatch.delenv("COMPANION_BASE_URL", raising=False) monkeypatch.setitem(sys.modules, "core.database", _db) for _name, _attrs in { "core.auth": {"AuthManager": MagicMock()}, @@ -116,6 +117,59 @@ def test_pairing_payload_shape(): assert p == {"v": 1, "host": "192.168.1.9", "port": 7000, "token": "ody_x"} +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("http://odysseus.local", ("http://odysseus.local", "odysseus.local", 80)), + ("https://odysseus.example", ("https://odysseus.example", "odysseus.example", 443)), + ("https://192.168.1.9:7443", ("https://192.168.1.9:7443", "192.168.1.9", 7443)), + ("http://[fd00::1]:7000", ("http://[fd00::1]:7000", "fd00::1", 7000)), + ], +) +def test_parse_companion_base_url_accepts_canonical_origins(value, expected): + assert P.parse_companion_base_url(value) == expected + + +@pytest.mark.parametrize( + "value", + [ + "", + "odysseus.example", + "ftp://odysseus.example", + "https://user:password@odysseus.example", + "https://odysseus.example/", + "https://odysseus.example/path", + "https://odysseus.example?query=1", + "https://odysseus.example#fragment", + "https://odysseus.example:not-a-port", + "https://odysseus.example:0", + "https://odysseus.example:65536", + " https://odysseus.example", + "https://odysseus.example ", + "https://odysseus\\example", + ], +) +def test_parse_companion_base_url_rejects_non_origins(value): + with pytest.raises(ValueError): + P.parse_companion_base_url(value) + + +def test_pairing_payload_can_include_configured_base_url(): + p = P.pairing_payload( + "odysseus.example", + 443, + "ody_x", + base_url="https://odysseus.example", + ) + assert p == { + "v": 1, + "host": "odysseus.example", + "port": 443, + "token": "ody_x", + "base_url": "https://odysseus.example", + } + + @pytest.mark.parametrize("payload", ["[]", '{"users": []}']) def test_find_admin_user_ignores_invalid_auth_shape(tmp_path, monkeypatch, payload): auth_file = tmp_path / "auth.json" @@ -255,6 +309,52 @@ def test_pair_post_json_returns_pairing_payload(monkeypatch): assert secret_key not in response["payload"] +def test_pair_post_json_prefers_configured_origin(monkeypatch): + monkeypatch.setenv("COMPANION_BASE_URL", "https://odysseus.example") + mint = MagicMock(return_value=("tok123", "ody_raw")) + discovery = MagicMock(side_effect=AssertionError("configured origin must skip LAN discovery")) + monkeypatch.setattr(R, "require_admin", lambda request: None, raising=False) + monkeypatch.setattr(R, "get_current_user", lambda request: "alice") + monkeypatch.setattr(R, "mint_pairing_token", mint) + monkeypatch.setattr(R._pairing, "lan_ip_candidates", discovery) + monkeypatch.setattr(R._pairing, "pairing_qr_png_data_uri", lambda payload: None) + + request = _fake_pair_request(format="json", port=7000) + response = _pair_route("POST")(request) + + assert response["host"] == "odysseus.example" + assert response["port"] == 443 + assert response["base_url"] == "https://odysseus.example" + assert response["hosts"] == ["odysseus.example"] + assert response["payload"] == { + "v": 1, + "host": "odysseus.example", + "port": 443, + "token": "ody_raw", + "base_url": "https://odysseus.example", + } + discovery.assert_not_called() + + +def test_pair_post_rejects_invalid_config_before_mint_without_echoing_it(monkeypatch): + configured_secret = "secret-password" + monkeypatch.setenv( + "COMPANION_BASE_URL", + f"https://admin:{configured_secret}@odysseus.example", + ) + mint = MagicMock(side_effect=AssertionError("invalid config must not mint a token")) + monkeypatch.setattr(R, "require_admin", lambda request: None, raising=False) + monkeypatch.setattr(R, "mint_pairing_token", mint) + + with pytest.raises(HTTPException) as exc: + _pair_route("POST")(_fake_pair_request(format="json")) + + assert exc.value.status_code == 500 + assert "COMPANION_BASE_URL" in exc.value.detail + assert configured_secret not in exc.value.detail + mint.assert_not_called() + + def test_pair_post_json_qr_failure_returns_null_qr(monkeypatch): monkeypatch.setattr(R, "require_admin", lambda request: None, raising=False) monkeypatch.setattr(R, "get_current_user", lambda request: "alice") diff --git a/tests/test_docker_devops_hardening.py b/tests/test_docker_devops_hardening.py index 29d5c9955..2c4530e9c 100644 --- a/tests/test_docker_devops_hardening.py +++ b/tests/test_docker_devops_hardening.py @@ -55,6 +55,11 @@ def test_compose_files_forward_every_upload_limit_env_var(): assert expected <= _compose_env_names(path), path.name +def test_compose_files_forward_companion_base_url(): + for path in COMPOSE_FILES: + assert "COMPANION_BASE_URL" in _compose_env_names(path), path.name + + def test_default_compose_files_do_not_mount_host_docker_socket(): for path in COMPOSE_FILES: text = path.read_text(encoding="utf-8") From b7b418ce3625b336d9f206789bc99d47b16bab04 Mon Sep 17 00:00:00 2001 From: RaresKeY <158580472+RaresKeY@users.noreply.github.com> Date: Mon, 20 Jul 2026 08:19:31 +0000 Subject: [PATCH 2/3] fix(companion): keep configured pairing on v1 LAN contract --- .env.example | 12 +-- companion/pairing.py | 86 +++++++++++++++------ companion/routes.py | 7 +- tests/test_companion_pairing.py | 130 +++++++++++++++++++++----------- 4 files changed, 159 insertions(+), 76 deletions(-) diff --git a/.env.example b/.env.example index 9c223d11a..38e24c947 100644 --- a/.env.example +++ b/.env.example @@ -76,11 +76,13 @@ SEARXNG_INSTANCE=http://localhost:8080 # Change this if another local service already uses 7000 (macOS AirPlay often does). # APP_PORT=7000 -# Optional trusted origin advertised in companion/mobile pairing codes. Set this -# when Docker, a VPN, or a reverse proxy would otherwise advertise a container -# address or loopback. Use only an exact http(s) origin: no credentials, path, -# query, or fragment. HTTPS origins default to port 443; HTTP defaults to 80. -# COMPANION_BASE_URL=https://odysseus.example +# Optional HTTP address advertised in companion/mobile pairing codes. Set this +# when Docker would otherwise advertise a container address or loopback. Use a +# LAN or Tailscale IPv4 address, a single-label hostname, or an mDNS *.local +# name that the phone can reach. HTTPS and public hostnames are not supported +# by the current companion client. Do not include credentials, a path, query, +# or fragment. +# COMPANION_BASE_URL=http://192.168.1.50:7000 # Development-only auth bypass for loopback requests. # Keep false for Docker, LAN, reverse proxy, and any shared deployment. diff --git a/companion/pairing.py b/companion/pairing.py index 06aff7466..047a9c543 100644 --- a/companion/pairing.py +++ b/companion/pairing.py @@ -6,8 +6,10 @@ units so the route layer stays thin and the logic is directly testable. from __future__ import annotations +import ipaddress import json import os +import re import secrets import socket import uuid @@ -21,47 +23,86 @@ PAIRING_VERSION = 1 COMPANION_SCOPE = "chat" -def parse_companion_base_url(value: str) -> tuple[str, str, int]: - """Validate a trusted companion origin and return (origin, host, port). +_COMPANION_IPV4_NETWORKS = tuple( + ipaddress.ip_network(cidr) + for cidr in ( + "10.0.0.0/8", + "100.64.0.0/10", + "127.0.0.0/8", + "169.254.0.0/16", + "172.16.0.0/12", + "192.168.0.0/16", + ) +) +_DNS_LABEL_RE = re.compile(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\Z") - Pairing credentials are sent to this origin, so accept only a canonical - HTTP(S) origin. Paths, credentials, and other URL components are rejected - instead of being silently discarded. + +def _valid_companion_client_host(host: str) -> bool: + """Match the host forms supported by the current v1 Expo client.""" + if not host or len(host) > 253 or not host.isascii() or "%" in host: + return False + + try: + address = ipaddress.ip_address(host) + except ValueError: + labels = host.split(".") + if any(not _DNS_LABEL_RE.fullmatch(label) for label in labels): + return False + if any(label.startswith("xn--") for label in labels): + return False + return len(labels) == 1 or (len(labels) >= 2 and labels[-1] == "local") + + return isinstance(address, ipaddress.IPv4Address) and any( + address in network for network in _COMPANION_IPV4_NETWORKS + ) + + +def parse_companion_base_url(value: str) -> tuple[str, int]: + """Validate a v1 companion address and return its legacy (host, port). + + The deployed client understands only HTTP plus a LAN-style host and port. + Reject anything outside that exact contract instead of advertising a URL + the client would reject, downgrade, or interpret differently. """ if not isinstance(value, str) or not value: - raise ValueError("COMPANION_BASE_URL must be an HTTP(S) origin") - if any(ord(char) <= 32 or ord(char) == 127 or char == "\\" for char in value): + raise ValueError("COMPANION_BASE_URL must be a canonical HTTP LAN origin") + if not value.isascii(): + raise ValueError("COMPANION_BASE_URL must contain only ASCII characters") + if any( + ord(char) <= 32 or ord(char) == 127 or char in {"\\", "%"} + for char in value + ): raise ValueError( - "COMPANION_BASE_URL must not contain whitespace or control characters" + "COMPANION_BASE_URL contains a forbidden character" ) try: parsed = urlsplit(value) port = parsed.port except ValueError as exc: - raise ValueError("COMPANION_BASE_URL must be a valid HTTP(S) origin") from exc + raise ValueError("COMPANION_BASE_URL must be a valid HTTP LAN origin") from exc - scheme = parsed.scheme.lower() host = parsed.hostname - if scheme not in {"http", "https"} or not parsed.netloc or not host: - raise ValueError("COMPANION_BASE_URL must be an HTTP(S) origin") + if parsed.scheme.lower() != "http" or not parsed.netloc or not host: + raise ValueError("COMPANION_BASE_URL must be a canonical HTTP LAN origin") if parsed.username is not None or parsed.password is not None: raise ValueError("COMPANION_BASE_URL must not contain credentials") if parsed.path or parsed.query or parsed.fragment: raise ValueError("COMPANION_BASE_URL must not contain a path, query, or fragment") if port is not None and not 1 <= port <= 65535: raise ValueError("COMPANION_BASE_URL port must be between 1 and 65535") + if not _valid_companion_client_host(host): + raise ValueError("COMPANION_BASE_URL host is not supported by companion v1") - display_host = f"[{host}]" if ":" in host else host - netloc = f"{display_host}:{port}" if port is not None else display_host - origin = f"{scheme}://{netloc}" + netloc = f"{host}:{port}" if port is not None else host + origin = f"http://{netloc}" if value != origin: - raise ValueError("COMPANION_BASE_URL must be a canonical HTTP(S) origin") - return origin, host, port or (443 if scheme == "https" else 80) + raise ValueError("COMPANION_BASE_URL must be a canonical HTTP LAN origin") + return host, port or 80 -def configured_companion_origin() -> tuple[str, str, int] | None: - """Return the validated operator-configured pairing origin, if any.""" +def configured_companion_origin() -> tuple[str, int] | None: + """Return the validated operator-configured v1 address, if any.""" value = os.environ.get("COMPANION_BASE_URL") if value is None or value == "": return None @@ -154,12 +195,9 @@ def mint_token(owner: str, name: str = "companion") -> tuple[str, str]: return token_id, raw_token -def pairing_payload(host: str, port: int, token: str, *, base_url: str | None = None) -> dict: +def pairing_payload(host: str, port: int, token: str) -> dict: """The exact JSON a client scans / accepts. Keep keys stable.""" - payload = {"v": PAIRING_VERSION, "host": host, "port": port, "token": token} - if base_url: - payload["base_url"] = base_url - return payload + return {"v": PAIRING_VERSION, "host": host, "port": port, "token": token} def pairing_qr_png_data_uri(payload: dict) -> str | None: diff --git a/companion/routes.py b/companion/routes.py index e43b7d19e..469c1b7bb 100644 --- a/companion/routes.py +++ b/companion/routes.py @@ -203,14 +203,13 @@ def setup_companion_routes() -> APIRouter: token_id, raw_token = mint_pairing_token(owner, invalidate) if configured_origin: - base_url, host, port = configured_origin + host, port = configured_origin hosts = [host] else: - base_url = None hosts = _pairing.lan_ip_candidates() host = hosts[0] if hosts else "127.0.0.1" port = request.url.port or _pairing.default_port() - payload = _pairing.pairing_payload(host, port, raw_token, base_url=base_url) + payload = _pairing.pairing_payload(host, port, raw_token) qr = _pairing.pairing_qr_png_data_uri(payload) qr_ok = bool(qr and qr.startswith("data:image/png;base64,")) @@ -224,8 +223,6 @@ def setup_companion_routes() -> APIRouter: "payload": payload, "qr": qr if qr_ok else None, } - if base_url: - response["base_url"] = base_url return response import json as _json diff --git a/tests/test_companion_pairing.py b/tests/test_companion_pairing.py index 3357f5165..0e2a8e3ae 100644 --- a/tests/test_companion_pairing.py +++ b/tests/test_companion_pairing.py @@ -120,13 +120,22 @@ def test_pairing_payload_shape(): @pytest.mark.parametrize( ("value", "expected"), [ - ("http://odysseus.local", ("http://odysseus.local", "odysseus.local", 80)), - ("https://odysseus.example", ("https://odysseus.example", "odysseus.example", 443)), - ("https://192.168.1.9:7443", ("https://192.168.1.9:7443", "192.168.1.9", 7443)), - ("http://[fd00::1]:7000", ("http://[fd00::1]:7000", "fd00::1", 7000)), + ("http://odysseus", ("odysseus", 80)), + ("http://odysseus:7000", ("odysseus", 7000)), + ("http://localhost:7000", ("localhost", 7000)), + ("http://odysseus.local", ("odysseus.local", 80)), + ("http://api.odysseus.local:7000", ("api.odysseus.local", 7000)), + ("http://10.0.0.1:7000", ("10.0.0.1", 7000)), + ("http://100.64.0.1:7000", ("100.64.0.1", 7000)), + ("http://100.127.255.254:7000", ("100.127.255.254", 7000)), + ("http://127.0.0.1:7000", ("127.0.0.1", 7000)), + ("http://169.254.1.1:7000", ("169.254.1.1", 7000)), + ("http://172.16.0.1:7000", ("172.16.0.1", 7000)), + ("http://172.31.255.254:7000", ("172.31.255.254", 7000)), + ("http://192.168.1.9:7000", ("192.168.1.9", 7000)), ], ) -def test_parse_companion_base_url_accepts_canonical_origins(value, expected): +def test_parse_companion_base_url_accepts_v1_client_addresses(value, expected): assert P.parse_companion_base_url(value) == expected @@ -136,40 +145,61 @@ def test_parse_companion_base_url_accepts_canonical_origins(value, expected): "", "odysseus.example", "ftp://odysseus.example", - "https://user:password@odysseus.example", - "https://odysseus.example/", - "https://odysseus.example/path", - "https://odysseus.example?query=1", - "https://odysseus.example#fragment", - "https://odysseus.example:not-a-port", - "https://odysseus.example:0", - "https://odysseus.example:65536", - " https://odysseus.example", - "https://odysseus.example ", - "https://odysseus\\example", + "https://odysseus.local", + "http://user:password@odysseus.local", + "http://odysseus.local/", + "http://odysseus.local/path", + "http://odysseus.local?query=1", + "http://odysseus.local#fragment", + "http://odysseus.local:not-a-port", + "http://odysseus.local:0", + "http://odysseus.local:65536", + "http://odysseus.local:07000", + "HTTP://odysseus.local:7000", + "http://Odysseus.local:7000", + " http://odysseus.local", + "http://odysseus.local ", + "http://odysseus\\local", + "http://odysseus.local\n", + "http://odysseus.local\t", + "http://odysseus.local\x7f", + "http://example.com:7000", + "http://1.1.1.1:7000", + "http://100.63.255.255:7000", + "http://100.128.0.1:7000", + "http://126.255.255.255:7000", + "http://128.0.0.1:7000", + "http://169.253.255.255:7000", + "http://169.255.0.1:7000", + "http://172.15.255.255:7000", + "http://172.32.0.1:7000", + "http://192.167.255.255:7000", + "http://192.169.0.1:7000", + "http://[fd00::1]:7000", + "http://[fe80::1%25eth0]:7000", + "http://b\N{LATIN SMALL LETTER U WITH DIAERESIS}cher.local:7000", + "http://xn--bcher-kva.local:7000", + "http://xn--bcher-kva:7000", + "http://odysseus%2elocal:7000", + "http://%31%39%32.168.1.9:7000", + "http://odysseus%40local:7000", + "http://.local:7000", + "http://odysseus..local:7000", + "http://odysseus.local.:7000", + "http://-odysseus:7000", + "http://odysseus-:7000", + "http://odysseus_name:7000", + f"http://{'a' * 64}:7000", + f"http://{'a' * 250}.local:7000", ], ) -def test_parse_companion_base_url_rejects_non_origins(value): +def test_parse_companion_base_url_rejects_unsupported_or_noncanonical_addresses( + value, +): with pytest.raises(ValueError): P.parse_companion_base_url(value) -def test_pairing_payload_can_include_configured_base_url(): - p = P.pairing_payload( - "odysseus.example", - 443, - "ody_x", - base_url="https://odysseus.example", - ) - assert p == { - "v": 1, - "host": "odysseus.example", - "port": 443, - "token": "ody_x", - "base_url": "https://odysseus.example", - } - - @pytest.mark.parametrize("payload", ["[]", '{"users": []}']) def test_find_admin_user_ignores_invalid_auth_shape(tmp_path, monkeypatch, payload): auth_file = tmp_path / "auth.json" @@ -298,6 +328,15 @@ def test_pair_post_json_returns_pairing_payload(monkeypatch): assert response["port"] == 7000 assert response["token"] == "ody_raw" assert response["token_id"] == "tok123" + assert set(response) == { + "host", + "port", + "token", + "token_id", + "hosts", + "payload", + "qr", + } assert response["payload"] == { "v": 1, "host": "192.168.1.50", @@ -310,7 +349,7 @@ def test_pair_post_json_returns_pairing_payload(monkeypatch): def test_pair_post_json_prefers_configured_origin(monkeypatch): - monkeypatch.setenv("COMPANION_BASE_URL", "https://odysseus.example") + monkeypatch.setenv("COMPANION_BASE_URL", "http://odysseus.local:7000") mint = MagicMock(return_value=("tok123", "ody_raw")) discovery = MagicMock(side_effect=AssertionError("configured origin must skip LAN discovery")) monkeypatch.setattr(R, "require_admin", lambda request: None, raising=False) @@ -322,16 +361,23 @@ def test_pair_post_json_prefers_configured_origin(monkeypatch): request = _fake_pair_request(format="json", port=7000) response = _pair_route("POST")(request) - assert response["host"] == "odysseus.example" - assert response["port"] == 443 - assert response["base_url"] == "https://odysseus.example" - assert response["hosts"] == ["odysseus.example"] + assert response["host"] == "odysseus.local" + assert response["port"] == 7000 + assert response["hosts"] == ["odysseus.local"] + assert set(response) == { + "host", + "port", + "token", + "token_id", + "hosts", + "payload", + "qr", + } assert response["payload"] == { "v": 1, - "host": "odysseus.example", - "port": 443, + "host": "odysseus.local", + "port": 7000, "token": "ody_raw", - "base_url": "https://odysseus.example", } discovery.assert_not_called() @@ -340,7 +386,7 @@ def test_pair_post_rejects_invalid_config_before_mint_without_echoing_it(monkeyp configured_secret = "secret-password" monkeypatch.setenv( "COMPANION_BASE_URL", - f"https://admin:{configured_secret}@odysseus.example", + f"http://admin:{configured_secret}@odysseus.local", ) mint = MagicMock(side_effect=AssertionError("invalid config must not mint a token")) monkeypatch.setattr(R, "require_admin", lambda request: None, raising=False) From 504b0a77285eeb9931aec7ccaeddaeb56d3d2c0a Mon Sep 17 00:00:00 2001 From: RaresKeY <158580472+RaresKeY@users.noreply.github.com> Date: Mon, 20 Jul 2026 08:43:16 +0000 Subject: [PATCH 3/3] fix(companion): reject numeric pairing hosts --- companion/pairing.py | 10 ++++++++++ tests/test_companion_pairing.py | 7 +++++++ 2 files changed, 17 insertions(+) diff --git a/companion/pairing.py b/companion/pairing.py index 047a9c543..5fc283804 100644 --- a/companion/pairing.py +++ b/companion/pairing.py @@ -50,6 +50,16 @@ def _valid_companion_client_host(host: str) -> bool: return False if any(label.startswith("xn--") for label in labels): return False + # WHATWG URL parsers treat a decimal or ``0x`` single-label hostname + # as an IPv4 number even though Python's strict ``ipaddress`` parser + # rejects that spelling. The v1 client interpolates this host back + # into a URL, so accepting e.g. ``134744072`` would make the phone send + # its bearer token to public 8.8.8.8. Keep DNS labels unambiguous. + if len(labels) == 1 and ( + labels[0].isdigit() + or re.fullmatch(r"0x[0-9a-f]*", labels[0]) is not None + ): + return False return len(labels) == 1 or (len(labels) >= 2 and labels[-1] == "local") return isinstance(address, ipaddress.IPv4Address) and any( diff --git a/tests/test_companion_pairing.py b/tests/test_companion_pairing.py index 0e2a8e3ae..006f70602 100644 --- a/tests/test_companion_pairing.py +++ b/tests/test_companion_pairing.py @@ -175,6 +175,13 @@ def test_parse_companion_base_url_accepts_v1_client_addresses(value, expected): "http://172.32.0.1:7000", "http://192.167.255.255:7000", "http://192.169.0.1:7000", + # WHATWG URL parsing normalizes these legacy numeric host spellings to + # IPv4 addresses even though Python's strict ipaddress parser rejects + # them. 134744072 / 0x08080808 both become public 8.8.8.8. + "http://134744072:7000", + "http://0x:7000", + "http://0x08080808:7000", + "http://017700000001:7000", "http://[fd00::1]:7000", "http://[fe80::1%25eth0]:7000", "http://b\N{LATIN SMALL LETTER U WITH DIAERESIS}cher.local:7000",