From 5b6429e77fcb2fe920ff1e1fa384b3eef8a6f7f7 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Mon, 17 Aug 2026 03:25:34 +0000 Subject: [PATCH 1/3] fix(store): validate target_remote at install API boundary An authenticated caller could pass a hostile target_remote (e.g. 'attacker.example.com:9999/x') that flowed unchecked into resolve_rkllama_url and LXC : addressing, enabling SSRF-shaped daemon URL injection or silent mis-routing to unregistered workers via a degenerate stub capability. Add a single boundary check in install_app before any capability resolution or installer construction: target_remote must be None/empty/"local", a registered cluster worker id, or a bare hostname matching ^[A-Za-z0-9._-]+$. Anything else returns HTTP 400 with reason "invalid_target_remote". Docs-Reviewed: agent-coordination.md documents parallel workflow discipline, not API input validation; no doc change needed for this security hardening. --- .../tsk-3rontv-validate-target-remote.md | 2 + tests/routes/test_store_install_v2.py | 123 ++++++++++++++++++ tinyagentos/routes/store_install.py | 34 +++++ 3 files changed, 159 insertions(+) create mode 100644 changelog.d/tsk-3rontv-validate-target-remote.md diff --git a/changelog.d/tsk-3rontv-validate-target-remote.md b/changelog.d/tsk-3rontv-validate-target-remote.md new file mode 100644 index 000000000..130ca6c82 --- /dev/null +++ b/changelog.d/tsk-3rontv-validate-target-remote.md @@ -0,0 +1,2 @@ +### Fixed +- `POST /api/store/install-v2` now validates `target_remote` at the API boundary before it is interpolated into backend daemon URLs (`resolve_rkllama_url`, LXC remote addressing). Hostile strings containing `:`, `/`, `?`, `#`, or `@` are rejected with HTTP 400 and a named `invalid_target_remote` reason, preventing SSRF-shaped installs or silent mis-routing to unregistered workers. diff --git a/tests/routes/test_store_install_v2.py b/tests/routes/test_store_install_v2.py index e0a2159a7..39a175108 100644 --- a/tests/routes/test_store_install_v2.py +++ b/tests/routes/test_store_install_v2.py @@ -635,3 +635,126 @@ async def test_hailo_ollama_installer_receives_hailo_host(self, client): f"expected a get_installer call with method='ollama' and " f"host='http://localhost:7836', got {mock_get.call_args_list!r}" ) + + +# --------------------------------------------------------------------------- +# target_remote boundary validation (hostile string hardening) +# --------------------------------------------------------------------------- + +class TestTargetRemoteValidation: + """Reject hostile target_remote at the install API boundary. + + An authenticated caller must not be able to inject an arbitrary + host/path/port into backend daemon URLs (SSRF-shaped) or silently route + an install to a typo'd host via a degenerate stub capability. + """ + + @pytest.mark.asyncio + async def test_hostile_target_remote_returns_400_no_installer(self, client, fake_registry): + """target_remote='attacker.example.com:9999/x' must 400 with no installer.""" + client._transport.app.state.registry = fake_registry + with patch( + "tinyagentos.routes.store_install.get_installer" + ) as mock_get: + r = await client.post("/api/store/install-v2", json={ + "manifest_id": "qwen2.5-3b", + "variant_id": "q4_k_m", + "target_remote": "attacker.example.com:9999/x", + }) + assert r.status_code == 400 + body = r.json() + assert body["reason"] == "invalid_target_remote" + mock_get.assert_not_called() + + @pytest.mark.asyncio + async def test_target_remote_with_port_and_path_rejected(self, client, fake_registry): + """Any target_remote carrying ':' or '/' is rejected at the boundary.""" + client._transport.app.state.registry = fake_registry + with patch( + "tinyagentos.routes.store_install.get_installer" + ) as mock_get: + r = await client.post("/api/store/install-v2", json={ + "manifest_id": "qwen2.5-3b", + "variant_id": "q4_k_m", + "target_remote": "10.0.0.1:443@attacker.com", + }) + assert r.status_code == 400 + assert r.json()["reason"] == "invalid_target_remote" + mock_get.assert_not_called() + + @pytest.mark.asyncio + async def test_local_target_remote_not_rejected(self, client, fake_registry, pi_capability): + """'local' / None / empty bypass the host validation entirely.""" + client._transport.app.state.registry = fake_registry + with patch( + "tinyagentos.routes.store_install.get_device_capability", + new=AsyncMock(return_value=pi_capability), + ), patch( + "tinyagentos.routes.store_install.get_installer" + ) as mock_get: + backend_inst = MagicMock() + backend_inst.install = AsyncMock(return_value={"success": True, "method": "script"}) + model_inst = MagicMock() + model_inst.install = AsyncMock(return_value={"success": True}) + mock_get.side_effect = [backend_inst, model_inst] + r = await client.post("/api/store/install-v2", json={ + "manifest_id": "qwen2.5-3b", + "variant_id": "q4_k_m", + "target_remote": "local", + }) + assert r.status_code == 200 + + @pytest.mark.asyncio + async def test_registered_worker_target_remote_passes_validation(self, client, fake_registry, pi_capability): + """A target_remote that matches a registered cluster worker is accepted.""" + from tinyagentos.cluster.worker_protocol import WorkerInfo + + worker = WorkerInfo( + name="edge-gpu-01", + url="http://10.0.0.50:6969", + hardware={"ram_mb": 16384, "gpu": {"type": "nvidia", "vram_mb": 24576}, "disk": {"total_gb": 100, "free_gb": 50}}, + backends=[{"name": "rkllama"}], + ) + cluster = client._transport.app.state.cluster_manager + cluster._workers["edge-gpu-01"] = worker + client._transport.app.state.registry = fake_registry + + with patch( + "tinyagentos.routes.store_install.get_device_capability", + new=AsyncMock(return_value=pi_capability), + ), patch( + "tinyagentos.routes.store_install.get_installer" + ) as mock_get: + backend_inst = MagicMock() + backend_inst.install = AsyncMock(return_value={"success": True, "method": "script"}) + model_inst = MagicMock() + model_inst.install = AsyncMock(return_value={"success": True}) + mock_get.side_effect = [backend_inst, model_inst] + r = await client.post("/api/store/install-v2", json={ + "manifest_id": "qwen2.5-3b", + "variant_id": "q4_k_m", + "target_remote": "edge-gpu-01", + }) + assert r.status_code == 200 + + @pytest.mark.asyncio + async def test_bare_hostname_passes_validation(self, client, fake_registry, pi_capability): + """A bare hostname (no port/path) is accepted as a registry-less install.""" + client._transport.app.state.registry = fake_registry + with patch( + "tinyagentos.routes.store_install.get_device_capability", + new=AsyncMock(return_value=pi_capability), + ), patch( + "tinyagentos.routes.store_install.get_installer" + ) as mock_get: + backend_inst = MagicMock() + backend_inst.install = AsyncMock(return_value={"success": True, "method": "script"}) + model_inst = MagicMock() + model_inst.install = AsyncMock(return_value={"success": True}) + mock_get.side_effect = [backend_inst, model_inst] + r = await client.post("/api/store/install-v2", json={ + "manifest_id": "qwen2.5-3b", + "variant_id": "q4_k_m", + "target_remote": "edge-host.pi", + }) + assert r.status_code == 200 diff --git a/tinyagentos/routes/store_install.py b/tinyagentos/routes/store_install.py index e5332f05f..c50ad9c30 100644 --- a/tinyagentos/routes/store_install.py +++ b/tinyagentos/routes/store_install.py @@ -11,6 +11,7 @@ import asyncio import logging +import re from dataclasses import asdict from pathlib import Path from urllib.parse import urlparse @@ -46,6 +47,13 @@ "hailo-ollama", } +# Strict hostname charset for target_remote fallback. Rejects host/path/port +# injection (":", "/", "?", "#", "@") so an authenticated caller cannot steer +# a pull at an arbitrary daemon URL (SSRF-shaped) or route an install to a +# typo'd host silently. A bare hostname still passes so registry-less +# installs (e.g. ad-hoc worker hostnames) keep working. +_HOSTNAME_RE = re.compile(r"^[A-Za-z0-9._-]+$") + # Backend ID → install method known to get_installer(). # rkllama has a purpose-built installer (calls /api/pull, manages symlinks, # restarts systemd units). rk-llama-cpp models are downloaded to disk and @@ -738,6 +746,32 @@ async def install_app(request: Request): target_remote = body.get("target_remote") or None force = bool(body.get("force", False)) + # Boundary validation — reject hostile target_remote before it is + # interpolated into any backend daemon URL by resolve_rkllama_url or the + # lxc : addressing. An authenticated caller must not + # be able to inject an arbitrary host/path/port (SSRF-shaped) or silently + # route an install to an unregistered worker via a degenerate stub + # capability. Accepted values: + # * None / empty / "local" -> local install + # * a registered cluster worker id (cluster.get_worker() non-None) + # * a bare hostname matching _HOSTNAME_RE (registry-less installs) + if target_remote and target_remote != "local": + cluster = getattr(request.app.state, "cluster_manager", None) + is_known_worker = False + if cluster is not None and hasattr(cluster, "get_worker"): + is_known_worker = cluster.get_worker(target_remote) is not None + if not is_known_worker and not _HOSTNAME_RE.match(target_remote): + return JSONResponse( + { + "error": ( + "target_remote must be 'local' or a registered worker id; " + f"got {target_remote!r}" + ), + "reason": "invalid_target_remote", + }, + status_code=400, + ) + # Progress store — opened up here so both legacy and v2 paths can # tag work-in-flight that the frontend polls for. Importing here # avoids a circular import at module load. From a021edc694a726eccab80facc24cdb69e35ea28f Mon Sep 17 00:00:00 2001 From: jaylfc Date: Mon, 17 Aug 2026 04:20:10 +0000 Subject: [PATCH 2/3] fix(store): reject non-string target_remote with 400, not TypeError 500 --- tests/routes/test_store_install_v2.py | 20 ++++++++++++++++++-- tinyagentos/routes/store_install.py | 16 +++++++++++++++- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/tests/routes/test_store_install_v2.py b/tests/routes/test_store_install_v2.py index 39a175108..2f095996b 100644 --- a/tests/routes/test_store_install_v2.py +++ b/tests/routes/test_store_install_v2.py @@ -667,8 +667,8 @@ async def test_hostile_target_remote_returns_400_no_installer(self, client, fake mock_get.assert_not_called() @pytest.mark.asyncio - async def test_target_remote_with_port_and_path_rejected(self, client, fake_registry): - """Any target_remote carrying ':' or '/' is rejected at the boundary.""" + async def test_target_remote_with_port_and_at_sign_rejected(self, client, fake_registry): + """Any target_remote carrying ':' or '@' is rejected at the boundary.""" client._transport.app.state.registry = fake_registry with patch( "tinyagentos.routes.store_install.get_installer" @@ -682,6 +682,22 @@ async def test_target_remote_with_port_and_path_rejected(self, client, fake_regi assert r.json()["reason"] == "invalid_target_remote" mock_get.assert_not_called() + @pytest.mark.asyncio + async def test_nonstring_target_remote_rejected_cleanly(self, client, fake_registry): + """A non-string target_remote (e.g. JSON 123) must 400, not crash 500.""" + client._transport.app.state.registry = fake_registry + with patch( + "tinyagentos.routes.store_install.get_installer" + ) as mock_get: + r = await client.post("/api/store/install-v2", json={ + "manifest_id": "qwen2.5-3b", + "variant_id": "q4_k_m", + "target_remote": 123, + }) + assert r.status_code == 400 + assert r.json()["reason"] == "invalid_target_remote" + mock_get.assert_not_called() + @pytest.mark.asyncio async def test_local_target_remote_not_rejected(self, client, fake_registry, pi_capability): """'local' / None / empty bypass the host validation entirely.""" diff --git a/tinyagentos/routes/store_install.py b/tinyagentos/routes/store_install.py index c50ad9c30..8e7fd4d83 100644 --- a/tinyagentos/routes/store_install.py +++ b/tinyagentos/routes/store_install.py @@ -743,7 +743,21 @@ async def install_app(request: Request): body = await request.json() manifest_id = body.get("manifest_id") or body.get("app_id") variant_id = body.get("variant_id", "auto") - target_remote = body.get("target_remote") or None + raw_target_remote = body.get("target_remote") + if raw_target_remote is not None and not isinstance(raw_target_remote, str): + # JSON allows any type here; a truthy non-string (123, true, [...]) + # would crash the regex below with TypeError → 500. Reject cleanly. + return JSONResponse( + { + "error": ( + "target_remote must be a string, 'local', or omitted; " + f"got {type(raw_target_remote).__name__}" + ), + "reason": "invalid_target_remote", + }, + status_code=400, + ) + target_remote = raw_target_remote or None force = bool(body.get("force", False)) # Boundary validation — reject hostile target_remote before it is From 27acbda1965e7cbf48ba7fa16f7588afda724885 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Mon, 17 Aug 2026 07:02:52 +0000 Subject: [PATCH 3/3] chore: refresh merge ref (deleted-symbols false positive on stale merge; clean merge-tree vs fresh dev verified locally)