Boundaries that were supposed to hold

The security pass. Six findings, none reachable by visiting the site and
every one a boundary this codebase says it keeps.

A subagent is pinned to a list of read-only commands, in every mode,
unattended, with no card anybody could approve -- and `find *` was on it.
find writes files with -fprintf, runs programs with -exec and removes them
with -delete, and none of that needs a character the metacharacter guard
refuses. A page the model had just read could ask for a helper and get a
key into authorized_keys, from Plan mode, which promises to change
nothing. Refused in `subject()` rather than trimmed from the list: a
pattern cannot say "and no dangerous flags", and "this one looks
read-only" is exactly what put find there.

The loopback guard missed `0.0.0.0`, which is not is_loopback but does
connect to localhost -- so it answered a *decided* False and skipped the
DNS half too. The one spelling of "this machine" that walked past a guard
whose whole job is that sentence.

Twice in the update helper, which is the one place this deliberately
crosses a privilege boundary: root ran a script the service account owns,
and root sourced a file that account can replace. Either turns a
compromise of the web application into root. The first needed no
compromise at all -- a pull happens as the service user and root runs
whatever it fetched, so control of the branch was control of root. The
old test asserted that exact ExecStart line and had pinned it in place.

Push endpoints skipped check_url, the only outbound request that did. And
a chat could be filed in another account's folder, which hands over its
system prompt -- `_new_chat` resolved the folder, discarded it when it was
not the caller's, and stored the raw id anyway.

An existing helper install keeps the old wiring until install.sh is
re-run; update.sh now says so when it finds itself inside the checkout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-07 13:45:59 +02:00
parent c666d7f93a
commit 546f8a30d7
19 changed files with 641 additions and 16 deletions
+86
View File
@@ -28,6 +28,46 @@ from lembas.services import push as push_service
from lembas.services import reports as reports_service
@pytest.fixture(autouse=True)
def resolvable_push_service(monkeypatch):
"""`push.example` is not a real host, and `check_url` resolves.
The endpoint a browser hands back is now checked by the SSRF guard on both
sides -- at subscribe, and again before the POST, because the row outlives
the first check. That guard does DNS, so every fixture endpoint in this file
would be refused for not existing rather than for being anywhere bad.
Stood in with something that keeps the part under test: the *shape* of the
refusal. A literal private or loopback address is still refused, so the
tests that assert the guard is wired in are asserting the real thing.
"""
import ipaddress
from urllib.parse import urlsplit
from lembas.services import fetch as real_fetch
def stand_in(url: str, *, allow_private: bool = False) -> str:
host = (urlsplit(url).hostname or "").strip("[]")
try:
address = ipaddress.ip_address(host)
except ValueError:
if host.endswith(".example"):
return url
return real_fetch.check_url(url, allow_private=allow_private)
if not allow_private and (
address.is_loopback
or address.is_private
or address.is_link_local
or address.is_unspecified
):
raise real_fetch.FetchError("That address is not reachable from here.")
return url
for module in ("lembas.api.push", "lembas.services.push"):
monkeypatch.setattr(f"{module}.fetch_service.check_url", stand_in)
return stand_in
# --- A browser, standing in for one --------------------------------------------
class Browser:
"""The client half of RFC 8291, written out rather than imported.
@@ -404,3 +444,49 @@ def test_the_poll_announces_the_messages_conversation(client: TestClient, db, re
# And it is recorded as said, so the next tick does not say it again.
db.expire_all()
assert db.get(Chat, conversation.id).unread_notified
def test_a_push_endpoint_pointing_inside_the_network_is_refused(client, db, registered):
"""The endpoint is a URL the browser hands us and the server later POSTs to,
and it was the one outbound client not going through the SSRF guard --
`https://` alone says nothing about *where*. Delivery is triggered by the
caller themselves: send a message, close the tab, and `_persist` announces
it because nobody is following."""
refused = client.post(
"/api/push/subscribe",
json={
"endpoint": "https://10.0.0.5:8443/admin/shutdown",
"keys": {"p256dh": "BAA" + "A" * 84, "auth": "AAAAAAAA"},
},
)
assert refused.status_code == 400
assert db.scalar(select(PushSubscription)) is None
async def test_a_stored_endpoint_is_checked_again_before_the_post(db, registered):
"""A row outlives the check made when it was written: a name that pointed at
a push service can point inside the network later. The same split
`agent/hosts.py` makes."""
owner = db.scalar(select(User))
row = PushSubscription(
user_id=owner.id,
endpoint="https://127.0.0.1:9/hijacked",
p256dh=push_service.b64(
ec.generate_private_key(ec.SECP256R1())
.public_key()
.public_bytes(
encoding=serialization.Encoding.X962,
format=serialization.PublicFormat.UncompressedPoint,
)
),
auth_secret=push_service.b64(b"0123456789abcdef"),
)
db.add(row)
db.commit()
sent = await push_service.send_one(db, row, {"title": "x", "body": "y", "url": "/"})
assert sent is False
# Refused rather than dropped: the row is somebody's registration, and being
# unreachable today is not the same as being gone.
assert db.scalar(select(PushSubscription)) is not None