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:
+113
-2
@@ -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?
|
||||
|
||||
Reference in New Issue
Block a user