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
+113 -2
View File
@@ -231,9 +231,120 @@ What it measured once fixed:
- `tests/__pycache__/test_zz_{dump,live}*.pyc` are stale bytecode for two files
that no longer exist.
## For the security stage
## Security — *Stage 3*
Carried forward rather than answered here:
**Two privilege escalations in the update helper, both root, both fixed.** The
helper is the one place this application deliberately crosses a privilege
boundary, and it crossed it twice more than intended. Neither is reachable
from the web interface: both need code execution as the `lembas` service
account first. That is precisely the boundary the unprivileged split exists to
hold, so "you need a foothold" is the threat model, not a mitigation.
**1. Root ran a script the service account owns.**
`ExecStart=/bin/bash __PREFIX__/app/deploy/update.sh` — inside the checkout,
owned `lembas:lembas`, because `install.sh` clones as that user. So: write your
payload into `deploy/update.sh`, `touch data/update-requested` (the service
account owns that directory too), and systemd runs it as root. The web
interface's `AdminUser` check is not the gate systemd honours.
There is a second path needing no compromise at all: an update pulls new code
*as the service user*, and root then executes whatever `deploy/update.sh` that
pull contained. **Control of the branch was control of root.**
Fixed by installing a root-owned copy at `/usr/local/lib/lembas/update.sh` and
pointing the unit there. The cost — improving `update.sh` needs the installer
re-run — is the right one: root should not execute a script that arrived over
the network a moment ago. The script warns when its own copy has fallen behind.
**The old test asserted the vulnerable line**
(`assert "ExecStart=/bin/bash __PREFIX__/app/deploy/update.sh" in unit`). It
passed for the life of the feature and pinned the bug in place — the recurring
failure of this codebase, applied to a privilege boundary: an assertion about
the text rather than about the property the text was meant to have.
**2. Root sourced a file the service account can replace.**
`. "$PREFIX/.deploy-env"`. The file is root-owned, having been written with
`sudo tee` — but `$PREFIX` is the service account's own directory at mode 755,
and write permission on a *directory* is all it takes to unlink a file and put
another there. On the live host `.deploy-env` did not even exist, so it could
simply be created. `.` runs its contents as root.
This one survived the first fix entirely, and the helper is what made it
reachable: before the `.path` unit existed, `update.sh` only ran when an
administrator invoked it from a shell. Fixed by parsing the two values it wants
with strict patterns instead of sourcing. The test asserts that **nothing**
under `$PREFIX` is sourced, rather than naming `.deploy-env`, because the next
file read from there would have the same problem.
**Upgrade note:** a host that installed the helper before this keeps the old
unit, and only re-running the installer moves it. `update.sh` now detects that
it is running from inside the checkout and says so loudly — otherwise the
vulnerable hosts are exactly the ones that never hear about it.
**Also fixed, sub-threshold as a vulnerability but a real bug:** the share
panel built its `hx-vals` by pasting the search term into a JSON string. Jinja
escapes the quote for HTML and the parser decodes it again before htmx parses
the JSON, so a `"` in a search term ended the string and silently stopped every
checkbox in the panel from submitting anything. `q` was the last key, so an
injected one would also have won a duplicate-key parse. Built with `| tojson`
over the whole object now.
**3. A read-only command that was not read-only.** `SAFE_COMMANDS` — the list a
**subagent** is pinned to, in every mode, unattended, with no approval card
possible — contained `find *`. GNU `find` writes files (`-fprintf`), runs
programs (`-exec … +`) and deletes them (`-delete`), and none of those needs a
character `policy._UNSAFE` refuses. `rg --pre` is the same shape.
So the chain was: a parent in **Plan** mode — which promises "reads freely,
changes nothing" — spawns a helper on a `RISK_READ` tool with no card; the
helper's `shell_run` survives because `writes_off` drops `RISK_WRITE` and
deliberately keeps `RISK_EXECUTE`; `find . -maxdepth 0 -fprintf
~/.ssh/authorized_keys 'ssh-ed25519 …'` matches `find *` and runs. Prompt
injection from a page the model just read is enough to start it.
Fixed with `policy._ACTION`, refusing those flags in `subject()` rather than
trimming the allow list — a pattern cannot express "and no dangerous flags",
and "this one looks read-only" is exactly the reasoning that put `find *` there.
It costs a false refusal on `grep -- -delete`, which is the right direction to
be wrong in: a refusal asks, an allow does not.
**4. `0.0.0.0` walked past the loopback guard.** `_literal` answered from
`is_loopback`, and `0.0.0.0`/`::` are `is_unspecified` — so it returned a
*decided* `False`, which short-circuited `resolves_here` and skipped the DNS
half too. `connect()` to either goes to loopback on Linux, so an SSH profile
pointed at `0.0.0.0` reached this host's own sshd: the one spelling of "this
machine" that walked past the guard whose whole job is that sentence.
**5. Push endpoints skipped the SSRF guard.** `POST /api/push/subscribe`
checked `startswith("https://")` and nothing else, and `send_one` POSTed to it
with no `check_url` — the only outbound client in the codebase not going
through the guard. Delivery is triggered by the caller: send a message, close
the tab, and `_persist` announces it because nobody is following. Checked now
at subscribe **and** again before the POST, since the row outlives the first
check.
**6. A chat could be put in somebody else's folder.** `effective_system_prompt`
walks up from the chat through its folder and that folder's parents, so this
reads another account's system prompt through a field that looks like a tag.
Both paths had it, and `_new_chat`'s is the instructive one: it resolved the
folder, discarded it when it was not the caller's, and then stored the **raw
id** anyway — so the ownership check governed which *seeds* were applied and
not where the chat actually went.
### Clean
Checked and found sound: the branding CSS and custom-theme generation (ids and
colour values both validated on **read**, so a row written by hand still cannot
emit a malformed rule; served as `text/css` rather than inline, so there is no
HTML context to escape); the unauthenticated branding asset route (random
names, traversal guarded twice, magic-number sniffing, SVG excluded); sharing
authorisation on every route; the request file's contents reaching nothing;
`updates._git`'s fixed argv; and the container (non-root, no secret baked, no
docker socket, loopback only).
## Carried forward from earlier stages
Questions raised before the security stage, answered by it:
- the three hand-rolled redirect loops each re-run `check_url` per hop
(confirmed); does each also drop the secret when a hop leaves its origin?