From edafd5c04a1d1a3f6fa9a2e8fa13f97fb0994816 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Thu, 16 Jul 2026 10:43:44 +0100 Subject: [PATCH 1/3] fix(hailo): correct re-install idempotency check + narrow the orphan reaper (#1846) - already_installed compared rev-parse HEAD (a SHA) to HAILO_OLLAMA_REF (a branch name like main), which never matched, so every re-run needlessly re-fetched and re-installed. Compare resolved commits on both sides. - The unit's ExecStartPre reaped with pkill -9 -f 'hailo-ollama', which kills any process whose command line merely contains that string (an editor, a tail). Match the exact server invocation (binary + serve + port) instead. Note: the default upstream repo (hailo-ai/hailo-ollama) is not reachable via git ls-remote, so a commit-SHA pin cannot be added here; that is tracked separately under the Hailo backend work (#1771). --- scripts/install-hailo.sh | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/scripts/install-hailo.sh b/scripts/install-hailo.sh index 36c32dc52..729aea68e 100755 --- a/scripts/install-hailo.sh +++ b/scripts/install-hailo.sh @@ -372,7 +372,9 @@ Environment=HAILO_OLLAMA_PORT=$HAILO_OLLAMA_PORT # hailo-ollama can leave a bare orphan process listening on the port if it # crashes during model load (the adopt-an-orphan lesson from PR #1755 for # rkllama). Reap any such process before (re)start so the bind cannot fail. -ExecStartPre=-/usr/bin/pkill -9 -f 'hailo-ollama' +# Match the exact server invocation, not any command line merely containing +# "hailo-ollama" (which would kill an editor or tail open on these files). +ExecStartPre=-/usr/bin/pkill -9 -f "$HAILO_OLLAMA_BIN serve --port $HAILO_OLLAMA_PORT" ExecStart=$exec_start Restart=always RestartSec=5 @@ -410,7 +412,13 @@ already_installed() { # * systemd unit enabled # * HTTP API responding with a "models" body [[ -d "$HAILO_OLLAMA_DIR/.git" ]] || return 1 - [[ "$(run_as_user git -C "$HAILO_OLLAMA_DIR" rev-parse HEAD 2>/dev/null || true)" == "$HAILO_OLLAMA_REF" ]] || return 1 + # Compare resolved commits, not HEAD-vs-ref-name: HAILO_OLLAMA_REF is usually + # a branch/tag ("main"), so comparing it to rev-parse HEAD (a SHA) never + # matched and every re-run needlessly re-fetched and re-installed. + local _head _want + _head="$(run_as_user git -C "$HAILO_OLLAMA_DIR" rev-parse HEAD 2>/dev/null || true)" + _want="$(run_as_user git -C "$HAILO_OLLAMA_DIR" rev-parse "${HAILO_OLLAMA_REF}^{commit}" 2>/dev/null || true)" + [[ -n "$_head" && "$_head" == "$_want" ]] || return 1 systemctl is-enabled hailo-ollama.service >/dev/null 2>&1 || return 1 local tags tags="$(curl -fs "http://localhost:$HAILO_OLLAMA_PORT/api/tags" 2>/dev/null || true)" From c8922276d31724157573d788e998dc3439b2c4cc Mon Sep 17 00:00:00 2001 From: jaylfc Date: Thu, 16 Jul 2026 10:44:59 +0100 Subject: [PATCH 2/3] fix(canvas): bound snapshot render dimensions + offload render + clamp element geometry (audit #1) (#1842) --- tests/test_routes_project_canvas.py | 69 +++++++++++++++++++++++++++ tinyagentos/projects/canvas/render.py | 3 ++ tinyagentos/routes/project_canvas.py | 19 +++++++- 3 files changed, 90 insertions(+), 1 deletion(-) diff --git a/tests/test_routes_project_canvas.py b/tests/test_routes_project_canvas.py index 80e16f006..a7d8c63ad 100644 --- a/tests/test_routes_project_canvas.py +++ b/tests/test_routes_project_canvas.py @@ -1126,3 +1126,72 @@ async def test_human_write_not_rate_limited(self, ctx): ) assert resp.status_code == 201, resp.text + +class TestCanvasGeometryClamping: + @pytest.mark.asyncio + async def test_extreme_coordinates_are_clamped_on_create(self, ctx): + pid = await _new_project(ctx, "geom-clamp") + resp = await ctx.client.post( + f"/api/projects/{pid}/canvas/elements", + json={ + "kind": "note", + "x": 1_000_000_000, + "y": -1_000_000_000, + "w": 2_000_000_000, + "h": -500, + }, + ) + assert resp.status_code == 201, resp.text + el = resp.json()["element"] + assert el["x"] == 100_000 + assert el["y"] == -100_000 + assert el["w"] == 100_000 + assert el["h"] == 0 + + @pytest.mark.asyncio + async def test_extreme_coordinates_are_clamped_on_update(self, ctx): + pid = await _new_project(ctx, "geom-clamp-patch") + el = await _create_note(ctx.client, pid) + resp = await ctx.client.patch( + f"/api/projects/{pid}/canvas/elements/{el['id']}", + json={"x": 1_000_000_000, "y": -1_000_000_000, "w": 2_000_000_000, "h": -500}, + ) + assert resp.status_code == 200, resp.text + updated = resp.json()["element"] + assert updated["x"] == 100_000 + assert updated["y"] == -100_000 + assert updated["w"] == 100_000 + assert updated["h"] == 0 + + @pytest.mark.asyncio + async def test_snapshot_render_dimensions_are_clamped(self, ctx): + from pathlib import Path + from PIL import Image + + pid = await _new_project(ctx, "snap-clamp") + await ctx.client.post( + f"/api/projects/{pid}/canvas/elements", + json={ + "kind": "note", + "x": 1_000_000, + "y": 1_000_000, + "w": 1_000_000, + "h": 1_000_000, + "payload": {"text": "extreme"}, + }, + ) + resp = await ctx.client.get(f"/api/projects/{pid}/canvas/snapshot.png") + assert resp.status_code == 200, resp.text + project = await ctx.app.state.project_store.get_project(pid) + target = ( + Path(ctx.app.state.projects_root) + / project["slug"] + / "files" + / "canvas" + / "snapshot.png" + ) + assert target.exists(), f"snapshot not rendered at {target}" + with Image.open(target) as img: + assert img.width <= 8192 + assert img.height <= 8192 + diff --git a/tinyagentos/projects/canvas/render.py b/tinyagentos/projects/canvas/render.py index c74c0cd48..494d3d4b3 100644 --- a/tinyagentos/projects/canvas/render.py +++ b/tinyagentos/projects/canvas/render.py @@ -15,6 +15,7 @@ _MIN_WIDTH = 800 _MIN_HEIGHT = 600 _PADDING = 40 +_MAX_RENDER_DIM = 8192 _KIND_FILL = { "note": (255, 240, 140, 255), @@ -78,6 +79,8 @@ def render_snapshot_png(*, elements: list[dict], output_path: Path) -> Path: min_x, min_y, max_x, max_y = _bounds(elements) width = max(_MIN_WIDTH, int(max_x - min_x) + 2 * _PADDING) height = max(_MIN_HEIGHT, int(max_y - min_y) + 2 * _PADDING) + width = min(width, _MAX_RENDER_DIM) + height = min(height, _MAX_RENDER_DIM) img = Image.new("RGB", (width, height), color=(255, 255, 255)) draw = ImageDraw.Draw(img, "RGBA") try: diff --git a/tinyagentos/routes/project_canvas.py b/tinyagentos/routes/project_canvas.py index 17153d523..e123b1d7b 100644 --- a/tinyagentos/routes/project_canvas.py +++ b/tinyagentos/routes/project_canvas.py @@ -81,6 +81,21 @@ def _check_payload_size(payload: dict) -> None: ) +_CANVAS_ELEMENT_RANGE = 100_000 + + +def _clamp_element_geometry(element: dict) -> dict: + if "x" in element: + element["x"] = max(-_CANVAS_ELEMENT_RANGE, min(_CANVAS_ELEMENT_RANGE, element["x"])) + if "y" in element: + element["y"] = max(-_CANVAS_ELEMENT_RANGE, min(_CANVAS_ELEMENT_RANGE, element["y"])) + if "w" in element: + element["w"] = max(0, min(_CANVAS_ELEMENT_RANGE, element["w"])) + if "h" in element: + element["h"] = max(0, min(_CANVAS_ELEMENT_RANGE, element["h"])) + return element + + def _user_id(request: Request) -> str: uid = getattr(request.state, "user_id", None) if uid: @@ -196,6 +211,7 @@ async def create_canvas_element( status_code=429, ) element = payload.model_dump() + _clamp_element_geometry(element) _check_payload_size(element.get("payload") or {}) cs = request.app.state.project_canvas_store element_id = element.pop("element_id", None) @@ -243,6 +259,7 @@ async def update_canvas_element( ) cs = request.app.state.project_canvas_store patch = {k: v for k, v in payload.model_dump().items() if v is not None} + _clamp_element_geometry(patch) if "payload" in patch: _check_payload_size(patch["payload"]) try: @@ -304,7 +321,7 @@ async def get_canvas_png(project_id: str, request: Request): ) out.mkdir(parents=True, exist_ok=True) target = out / "snapshot.png" - render_snapshot_png(elements=elements, output_path=target) + await asyncio.to_thread(render_snapshot_png, elements=elements, output_path=target) return FileResponse(target, media_type="image/png") From 02c767f9a9cc8e78b01efaf39d950ee585f70a6f Mon Sep 17 00:00:00 2001 From: jaylfc Date: Thu, 16 Jul 2026 10:52:07 +0100 Subject: [PATCH 3/3] fix(security): account proxy stops forwarding local session cookie + hub binds author to signing key (audit #7, #8) (#1847) --- tests/test_hub_posts.py | 13 ++- tests/test_hub_store.py | 28 +++++++ tests/test_routes_account_proxy.py | 82 +++++++++++++++++-- tests/test_routes_account_proxy_hub_slice3.py | 3 +- tinyagentos/hub/posts.py | 13 ++- tinyagentos/hub/store.py | 11 +++ tinyagentos/routes/account_proxy.py | 34 +++++++- 7 files changed, 170 insertions(+), 14 deletions(-) diff --git a/tests/test_hub_posts.py b/tests/test_hub_posts.py index 0f347c264..231c5d11c 100644 --- a/tests/test_hub_posts.py +++ b/tests/test_hub_posts.py @@ -62,10 +62,15 @@ async def test_posts_append_in_chain_order(self, store, data_dir): @pytest.mark.asyncio async def test_verify_chain_passes_for_intact_chain(self, store, data_dir): + author = identity.signing_fingerprint() + pub = identity.public_identity()["signing_pubkey"] + # The chain verifier resolves each entry's key from its declared author, + # so the author -> signing key mapping must be registered. + await store.upsert_author(author, signing_pubkey=pub) await posts.append_post(store, visibility="public", text="a") await posts.append_post(store, visibility="circle", text="b") await posts.append_post(store, visibility="public", text="c") - ok, error = await posts.verify_chain(store, identity.signing_fingerprint()) + ok, error = await posts.verify_chain(store, author) assert ok is True assert error is None @@ -91,6 +96,8 @@ async def test_tampered_post_body_fails_object_verify(self, store, data_dir): @pytest.mark.asyncio async def test_tampered_stored_body_fails_chain_verify(self, store, data_dir): author = identity.signing_fingerprint() + pub = identity.public_identity()["signing_pubkey"] + await store.upsert_author(author, signing_pubkey=pub) p = await posts.append_post(store, visibility="public", text="real") await posts.append_post(store, visibility="circle", text="more") @@ -112,6 +119,8 @@ async def test_tampered_stored_body_fails_chain_verify(self, store, data_dir): @pytest.mark.asyncio async def test_broken_prev_link_fails_chain_verify(self, store, data_dir): author = identity.signing_fingerprint() + pub = identity.public_identity()["signing_pubkey"] + await store.upsert_author(author, signing_pubkey=pub) await posts.append_post(store, visibility="public", text="a") await posts.append_post(store, visibility="circle", text="b") @@ -131,6 +140,8 @@ class TestTombstone: @pytest.mark.asyncio async def test_delete_drops_content_but_keeps_chain_verifiable(self, store, data_dir): author = identity.signing_fingerprint() + pub = identity.public_identity()["signing_pubkey"] + await store.upsert_author(author, signing_pubkey=pub) await posts.append_post(store, visibility="public", text="keep me") p2 = await posts.append_post(store, visibility="circle", text="delete me") diff --git a/tests/test_hub_store.py b/tests/test_hub_store.py index 0dda496b2..7cb990d07 100644 --- a/tests/test_hub_store.py +++ b/tests/test_hub_store.py @@ -93,6 +93,34 @@ def test_verify_never_raises_on_missing_or_garbage_sig(self, data_dir): assert hub_store.verify_object({"type": "x"}, pub) is False assert hub_store.verify_object({"type": "x", "sig": 123}, pub) is False + def test_verify_accepts_object_whose_author_matches_signing_key(self, data_dir): + # A correctly bound object (author == fingerprint of the signing key) + # verifies True. + pub = identity.public_identity()["signing_pubkey"] + signed = hub_store.sign_object( + {"type": "profile", "author": identity.signing_fingerprint(), "version": 1} + ) + assert hub_store.verify_object(signed, pub) is True + + def test_verify_rejects_object_with_wrong_author(self, data_dir, tmp_path, monkeypatch): + # A valid signature from key A must NOT verify as authored by peer B: + # verify_object binds author to the signing key's fingerprint and rejects + # a mismatched author (the peer-impersonation vector). + signed = hub_store.sign_object( + {"type": "profile", "author": identity.signing_fingerprint(), "version": 1} + ) + # Capture the original signing pubkey before switching identities. + orig_pub = identity.public_identity()["signing_pubkey"] + # Mint a different identity in an isolated dir to be the "wrong author". + monkeypatch.setenv("TAOS_DATA_DIR", str(tmp_path / "other")) + other_fp = identity.signing_fingerprint() + # Present the object as authored by the OTHER peer's fingerprint. + impersonated = {**signed, "author": other_fp} + assert hub_store.verify_object(impersonated, orig_pub) is False + # And with a completely foreign (non-fingerprint) author string. + foreign = {**signed, "author": "deadbeef" * 8} + assert hub_store.verify_object(foreign, orig_pub) is False + class TestStoreRoundTrip: @pytest.mark.asyncio diff --git a/tests/test_routes_account_proxy.py b/tests/test_routes_account_proxy.py index 660064557..12a5dc0c2 100644 --- a/tests/test_routes_account_proxy.py +++ b/tests/test_routes_account_proxy.py @@ -176,9 +176,8 @@ async def handler(method, url, **kw): ) _patch_upstream(monkeypatch, handler) - # Do NOT override the cookie header: the `client` fixture carries the taOS - # controller session that the /api/account/* proxy (rightly) requires, and - # that session cookie is what gets forwarded upstream. + # The `client` fixture carries the taOS controller (local admin) session + # cookie. It must NOT be relayed upstream: the forwarded Cookie is empty. r = await client.post( "/api/account/cluster/join/request", json={"device_name": "Mac", "ttl": "10m"}, @@ -187,7 +186,8 @@ async def handler(method, url, **kw): assert r.json()["request_id"] == "r1" assert captured["url"] == "https://taos.my/api/cluster/join/request" assert captured["method"] == "POST" - assert captured["cookie"] # the session cookie was passed through + assert "taos_session" not in captured["cookie"] + assert captured["cookie"] == "" @pytest.mark.asyncio @@ -258,7 +258,8 @@ async def handler(method, url, **kw): assert r.json()["available"] is True assert captured["url"] == "https://taos.my/api/subdomains/check?name=mybiz" assert captured["method"] == "GET" - assert captured["cookie"] # session cookie passed through + assert "taos_session" not in captured["cookie"] + assert captured["cookie"] == "" @pytest.mark.asyncio @@ -279,12 +280,16 @@ async def handler(method, url, **kw): ) _patch_upstream(monkeypatch, handler) - r = await client.post("/api/account/subdomains/claim", json={"name": "mybiz"}) + r = await client.post( + "/api/account/subdomains/claim", + json={"name": "mybiz"}, + ) assert r.status_code == 200 assert r.json()["name"] == "mybiz" assert captured["url"] == "https://taos.my/api/subdomains/claim" assert captured["method"] == "POST" - assert captured["cookie"] + assert "taos_session" not in captured["cookie"] + assert captured["cookie"] == "" assert "mybiz" in captured["body"] @@ -437,7 +442,8 @@ async def handler(method, url, **kw): assert r.json()["status"] == "registered" assert captured["url"] == "https://taos.my/api/hub/identity/register" assert captured["method"] == "POST" - assert captured["cookie"] # session cookie passed through + assert "taos_session" not in captured["cookie"] + assert captured["cookie"] == "" assert "signing_pubkey" in captured["body"] @@ -555,3 +561,63 @@ async def handler(method, url, **kw): ) assert r.status_code == 503 assert "unreachable" in r.json().get("error", "") + + +@pytest.mark.asyncio +async def test_forward_to_strips_local_session_cookie(client, monkeypatch): + """The proxy must NOT relay the local ``taos_session`` admin cookie upstream: + a taos.my log leak would otherwise expose valid local admin session tokens. + An upstream (taos.my) cookie in the same Cookie header IS forwarded.""" + monkeypatch.setenv("TAOS_ACCOUNT_BASE_URL", "https://taos.my") + captured: dict[str, str] = {} + + async def handler(method, url, **kw): + captured["cookie"] = kw.get("headers", {}).get("Cookie", "") + return _FakeResp( + content=b'{"username":"alice","status":"registered"}', + headers={"content-type": "application/json"}, + ) + + # Build a Cookie header with the (valid) local admin session plus an upstream + # taos.my session cookie. The proxy receives this from the browser. We keep + # the real session token so the request is authenticated; the fix must strip + # it from what is relayed upstream while forwarding the upstream cookie. + session = client.cookies.get("taos_session", "") + cookie_header = "taos_session=" + session + "; taosgo_session=upstream-value" + _patch_upstream(monkeypatch, handler) + r = await client.post( + "/api/account/hub/identity/register", + json={"signing_pubkey": "aa", "encryption_pubkey": "bb", "proof": "cc"}, + headers={"cookie": cookie_header}, + ) + assert r.status_code == 200 + forwarded = captured.get("cookie", "") + # The local session cookie must never reach the upstream. + assert "taos_session" not in forwarded + # The upstream cookie is forwarded as-is. + assert "taosgo_session=upstream-value" in forwarded + + +@pytest.mark.asyncio +async def test_forward_to_sends_no_cookie_when_only_local_session(client, monkeypatch): + """When the only cookie present is the local session cookie, the relayed + request must send no Cookie header at all (not an empty one).""" + monkeypatch.setenv("TAOS_ACCOUNT_BASE_URL", "https://taos.my") + captured: dict[str, str] = {} + + async def handler(method, url, **kw): + captured["cookie"] = kw.get("headers", {}).get("Cookie", "") + return _FakeResp( + content=b'{"username":"alice","status":"registered"}', + headers={"content-type": "application/json"}, + ) + + _patch_upstream(monkeypatch, handler) + r = await client.post( + "/api/account/hub/identity/register", + json={"signing_pubkey": "aa", "encryption_pubkey": "bb", "proof": "cc"}, + headers={"cookie": "taos_session=" + client.cookies.get("taos_session", "")}, + ) + assert r.status_code == 200 + assert "taos_session" not in captured.get("cookie", "") + assert captured.get("cookie", "") == "" diff --git a/tests/test_routes_account_proxy_hub_slice3.py b/tests/test_routes_account_proxy_hub_slice3.py index 426acd9e8..5d9d1b9a3 100644 --- a/tests/test_routes_account_proxy_hub_slice3.py +++ b/tests/test_routes_account_proxy_hub_slice3.py @@ -55,7 +55,8 @@ async def handler(method, url, **kw): assert r.json()["request_id"] == "r1" assert captured["url"] == "https://taos.my/api/hub/requests" assert captured["method"] == "POST" - assert captured["cookie"] + assert "taos_session" not in captured["cookie"] + assert captured["cookie"] == "" assert "peerFP" in captured["body"] diff --git a/tinyagentos/hub/posts.py b/tinyagentos/hub/posts.py index ccd5bb7f3..6248afe52 100644 --- a/tinyagentos/hub/posts.py +++ b/tinyagentos/hub/posts.py @@ -208,7 +208,6 @@ async def verify_chain(store: hub_store.HubStore, author: str) -> tuple[bool, Op which is exactly why the chain index must persist past deletion. """ entries = await store.list_chain_entries(author) - pub = identity.public_identity()["signing_pubkey"] prev_hash: Optional[str] = None for entry in entries: if entry.get("prev_hash") != prev_hash: @@ -218,8 +217,16 @@ async def verify_chain(store: hub_store.HubStore, author: str) -> tuple[bool, Op f"does not link to previous entry {prev_hash!r} (chain broken)", ) obj = await store.get_object(entry["hash"]) - if obj is not None and not hub_store.verify_object(obj, pub): - return False, f"seq {entry['seq']}: signature invalid (tampered)" + if obj is not None: + # Verify each entry against the public key resolved from that entry's + # declared author (its signing fingerprint), not one assumed key. A + # directory/network mishap that pairs an entry with the wrong author + # fails the author-to-key binding in verify_object. + resolved = await store.get_author(entry["author"]) + if resolved is None or not resolved.get("signing_pubkey"): + return False, f"seq {entry['seq']}: unknown author {entry['author']!r}" + if not hub_store.verify_object(obj, resolved["signing_pubkey"]): + return False, f"seq {entry['seq']}: signature invalid (tampered)" prev_hash = entry["hash"] return True, None diff --git a/tinyagentos/hub/store.py b/tinyagentos/hub/store.py index dab3448ea..5b6917e43 100644 --- a/tinyagentos/hub/store.py +++ b/tinyagentos/hub/store.py @@ -91,10 +91,21 @@ def verify_object(obj: dict, signing_pubkey_hex: str) -> bool: Returns False (never raises) on a missing or malformed signature so callers verifying arbitrary peer objects degrade cleanly. Tampering with any signed field changes the canonical bytes and fails the check. + + The declared ``author`` MUST be the fingerprint (SHA-256) of the public key + that signed the object, so a valid signature from key A cannot be presented + as authored by peer B. This closes the peer-impersonation gap that opens once + peer ingest lands (today the routes force the local fingerprint, so it is not + yet exploitable). The check is rejected unless ``author`` equals that + fingerprint. """ sig = obj.get("sig") if not isinstance(sig, str) or not sig: return False + author = obj.get("author") + expected_author = identity.fingerprint(signing_pubkey_hex) + if not isinstance(author, str) or author != expected_author: + return False return identity.verify_signature(signing_pubkey_hex, canonical_bytes(obj), sig) diff --git a/tinyagentos/routes/account_proxy.py b/tinyagentos/routes/account_proxy.py index 0732f3d05..c47b37dff 100644 --- a/tinyagentos/routes/account_proxy.py +++ b/tinyagentos/routes/account_proxy.py @@ -125,6 +125,36 @@ def _rewrite_set_cookie(value: str, secure_ok: bool) -> str: return "; ".join(kept) +# The local admin session cookie. The browser presents it on every same-origin +# /api/account/* call, but it must never be relayed to the upstream taos.my: a +# taos.my log leak or compromise would otherwise expose valid local admin +# session tokens. Only the cookies that belong upstream are forwarded. +_LOCAL_SESSION_COOKIE = "taos_session" + + +def _strip_local_session_cookie(cookie_header: str) -> str | None: + """Return ``cookie_header`` with the local ``taos_session`` cookie removed. + + Parses the incoming Cookie header and drops only the local session cookie, + preserving every other cookie (the upstream taos.my session cookie, etc.). + Returns ``None`` when no cookies remain, so the relayed request sends no + Cookie header at all. A malformed Cookie header is returned untouched rather + than dropping unrelated cookies. + """ + kept: list[str] = [] + for part in cookie_header.split(";"): + p = part.strip() + if not p: + continue + name = p.split("=", 1)[0].strip().lower() + if name == _LOCAL_SESSION_COOKIE: + continue + kept.append(p) + if not kept: + return None + return "; ".join(kept) + + async def _forward_to( request: Request, method: str, path: str, *, body: bytes | None = None ) -> Response: @@ -136,7 +166,9 @@ async def _forward_to( headers: dict[str, str] = {} cookie = request.headers.get("cookie") if cookie: - headers["Cookie"] = cookie + relayed = _strip_local_session_cookie(cookie) + if relayed: + headers["Cookie"] = relayed if body is None and method == "POST": # Default: relay the incoming request body verbatim (slice 1/2 actions). body = await request.body()