Skip to content

Security audit, batch 2: hardening - #13

Merged
Kurtisone merged 9 commits into
mainfrom
security-lot2
Aug 13, 2026
Merged

Security audit, batch 2: hardening#13
Kurtisone merged 9 commits into
mainfrom
security-lot2

Conversation

@Kurtisone

Copy link
Copy Markdown
Owner

Security audit, batch 2: hardening

Follow-up to #12 (batch 1). Where batch 1 closed the things that were
exploitable, this batch closes the things that make exploitation
cheap, and the ones that let an allowed request do damage without
breaking a rule.

Eight commits, each independently revertable. Test suite goes from
406 to 439, ruff check and ruff format --check clean.

What's in it

Dependency and image pinning (E-4). requirements.txt named four
packages with no versions, and the Containerfile installs from it on
every build — so two builds of the same commit could ship different
code, and a compromised release anywhere in the transitive tree landed
in the image with nothing in the diff to show it. Both requirement
files are now fully pinned, transitive deps included. This is a
freeze, not an upgrade: the pinned files were installed into a clean
venv and pip freeze diffed against the unpinned resolution —
identical, pip check clean. deploy/pin-base-image.sh does the same
one layer down, resolving python:3.12 to its RepoDigest; it was run
on the build host, and the FROM line is now a digest.

podman read-only proxy (M-1). The path filter was sound; the
server around it was not. Three ways an allowed request could take
the proxy down without breaking a single allowlist rule: ?follow=true
is a GET on an allowed path that podman answers by never closing the
connection; UnixStreamServer handled one request at a time, so a
slow podman logs blocked everything behind it; and there was no
upstream timeout at all. Now threaded, 30s timeout answering 504 (kept
distinct from the filter's 403, so a wedged podman doesn't read as a
policy rejection), and follow refused on presence of the parameter
rather than on its value — guessing which spellings podman's own
decoder reads as true means reimplementing someone else's boolean
parsing and being right about it. One addition beyond the audit item,
same failure mode: an 8 MiB cap on the forwarded body, since
?tail=all against a large journal was an unbounded read into the
host proxy's memory.

Unauthenticated surfaces (M-3). /docs, /redoc and
/openapi.json are mounted by FastAPI itself, so require_token can't
cover them — the only available states are "published to anyone who
can reach the port" and "not mounted". Now not mounted, unless
API_DOCS_ENABLED=true. /health was explicitly exempt from the rate
limiter and had a test asserting it always would be; that test is
deliberately inverted here. The exemption read as safe while /health
was "a status string nobody can abuse", but with
FORGE_PROVIDER=llama_cpp every hit makes an outbound call to the
inference server. It stays unauthenticated — healthchecks and the UI
status line call it before they have a token — with a test pinning
that, so the limit can't quietly become a gate.

Argument shape in sysadmin (M-4). journalctl -u <name> and
podman logs ... <name> both put the name where a leading dash turns
it into an option. Now --unit=<name> and -- <name>. This is a
second lock on a door collect_node already bolted; what it covers is
the case where discovery output is no longer trustworthy, which is
exactly the assumption the validation rests on.

Rate limiter key expiry (M-2). The counter dict was append-only:
one entry per client IP that ever made a request, held for the life of
the process. Two bounds now, covering different shapes of the same
problem — an amortised sweep (one pass per window) bounds memory over
time, and a key ceiling bounds it within a window, where a flood of
distinct addresses can grow the dict faster than any schedule collects
it. The guarantee the expiry must not break is that dropping a key and
keeping it are observationally identical; two tests hold that from both
sides. Both paths were mutation-tested.

Non-root container (E-4). The serving process ran as container
root and never needed to: uvicorn binds an unprivileged port and the
only writable path is /app/data. Now forge:forge at 1000:1000.
The part worth stating plainly: this image without
--userns=keep-id is a broken container, not a hardened one

under rootless podman the container's UID 0 is already mapped to the
host user, and that mapping is what makes the 0660 proxy sockets and
./data reachable. compose.example.yaml and deploy/README.md
carry the flag, and a test keeps the two halves together.

Cleanup. The CMD created /run/systemd/system on every start for
systemctl's sd_booted() check. Nothing calls systemctl any more —
discovery moved to busctl — so the directory has had no reader for a
while. Removed, CMD back to plain exec form, stale comments corrected.

Verified on the Deck

Non-root was tested on the real deployment, not just in CI:

  • uid_map shows 1000 0 1 — keep-id's signature, container UID 1000
    is the host user
  • podman exec forge iduid=1000(forge), writes to /app/data
    succeed
  • both proxies still answer, and journalctl -D /host-journal -u steamos-manager.service returns real entries — supplementary group
    access survives the switch, because the kernel evaluates the journal
    ACL against host-side GIDs rather than the names inside the container

user: "0:0" in compose reverts the non-root change with no rebuild,
and ships commented for that purpose.

Not in this batch

E-2 (provenance), E-1 (files/test sandbox scope) and F-4
(SECURITY.md) are batch 3.

The Containerfile runs `pip install -r requirements.txt` on every
build, and requirements.txt named four packages with no versions.
That meant two builds of the same commit could ship different code,
and any compromised release anywhere in the transitive tree --
starlette, urllib3, h11, uvloop -- landed in the image with nothing
in the diff to show it. The dependency change stopped being a commit
and became a side effect of rebuilding.

Both files are now fully pinned, transitive deps included, split
into a "direct" and a "transitive" section so it's still obvious
which four packages are actually chosen and which are consequences.
The versions are exactly what an unpinned resolve produced today, so
this is a freeze, not an upgrade: verified by installing the pinned
files into a clean venv and diffing `pip freeze` against the
unpinned resolution -- identical, and `pip check` clean.

requirements-dev.txt is pinned for the same reason and arguably a
stronger one: CI installs it on a runner holding a repo-scoped
GITHUB_TOKEN.

Not done, and said out loud in the file header rather than left to
be assumed: `--require-hashes`. It's the version of this that also
survives a hostile index, but it needs a hash per artifact and a
lock regenerated per platform. PyPI's no-reupload rule covers most
of what it would buy here.

tests/test_dependency_pinning.py is the forcing function -- the pins
only hold if the next bare `some-lib` line fails the suite.
`FROM python:3.12` is a mutable tag. It's rebuilt for every CPython
patch release and every Debian security update, so two builds of the
same Forge commit can sit on two different base images -- exactly
the drift the requirements pins just closed, one layer down.

deploy/pin-base-image.sh resolves the tag to its RepoDigest and,
with --write, rewrites the FROM line to the immutable
name@sha256:... form. Re-running it is how you bump: the move shows
up as a one-line diff to review and commit, instead of happening by
itself on the next rebuild.

The FROM line is deliberately left as a tag in this commit rather
than shipped with a digest baked in. A digest is only worth anything
if it's one resolved on the build host, from the registry actually
pulled from -- a digest committed from somewhere else is a number
nobody verified. Running the script once on the Deck is the step
that finishes this item.

The test holds the script's one real assumption: a single FROM line.
A second stage would make the regex pin the wrong one.
…(audit M-1)

The read-only filter was sound; the server around it was not. Three
ways an allowed request could take the proxy down, none of them
breaking a single rule in the allowlist:

- `GET /containers/{id}/logs?follow=true` is a GET on an allowed
  path, and podman answers it by never closing the connection. The
  handler reads the whole upstream body before replying, so one
  follow request parks a handler thread and an upstream socket
  permanently.
- UnixStreamServer handles one request at a time, start to finish.
  A slow `podman logs` blocked everything behind it, including the
  client's own /_ping.
- There was no timeout on the upstream connection at all, so a
  wedged podman held the handler for good rather than for a while.

Availability is part of what a read-only proxy promises: sysadmin
losing its only view of container logs is a real outcome even though
nothing was mutated.

follow is refused on presence of the parameter, not on its value.
`?follow=false` is refused too -- deciding which spellings podman's
own decoder reads as true means reimplementing someone else's
boolean parsing and being right about it, which is the classic way a
filter and the thing it filters end up disagreeing. Forge never
sends the parameter in any form.

The server is now threaded with daemon threads, and the upstream
connection has a 30s default timeout answering 504 (kept distinct
from the filter's 403, so a wedged podman doesn't read as a policy
rejection). Client disconnects no longer print a traceback each --
that's how an operator learns to ignore this proxy's output.

One addition beyond the audit item, same failure mode: an 8 MiB cap
on the forwarded body. `?tail=all` against a large journal was an
unbounded read into the host proxy's memory. When it trips,
Content-Length is rewritten to the truncated length -- forwarding
upstream's would leave the client waiting on bytes that never
arrive, which is a hang rather than a short read.

Tests run the real thing over real Unix sockets against a fake
podman: the plumbing is where all three bugs lived, so filter-level
assertions wouldn't have caught any of them. The concurrency test
asserts "not serialised" with a loose threshold rather than a
latency budget.
Two unauthenticated surfaces, for different reasons.

/docs, /redoc and /openapi.json are mounted by FastAPI itself, not by
this app's routes, so Depends(require_token) cannot be attached to
them -- the only available states are "published to anyone who can
reach the port" and "not mounted". They published a complete,
machine-readable map of every endpoint, parameter and schema. Not a
vulnerability on its own; it makes the reconnaissance step free for
someone who has learned nothing else about the instance. Now off
unless API_DOCS_ENABLED=true, which is the switch to flip while
developing against the API.

/health was explicitly exempt from the rate limiter, and there was a
test asserting that it always would be. That test is deliberately
inverted here. The exemption read as safe while /health was "a status
string nobody can abuse" -- but with FORGE_PROVIDER=llama_cpp every
hit makes an outbound call to the inference server to read the loaded
model name. Unmetered, one cheap anonymous request becomes load on
the LLM. It stays unauthenticated (a container healthcheck and the
UI's status line both call it before they have a token) and there's a
test pinning that, so the limit can't quietly become a gate.

The docs tests run the disabled and enabled cases in a subprocess:
docs_url is read once when the FastAPI object is constructed at
import time, so monkeypatching the flag afterwards proves nothing,
and reloading forge.api in-process would hand the rest of the suite a
different module object than the one it holds.
…ags (audit M-4)

`journalctl -u <name>` and `podman logs ... <name>` both put the name
in a position where a leading dash turns it into an option. A unit
named `--output=cat`, or a container named `--help`, would be
interpreted by the tool rather than looked up. Now `--unit=<name>`
(attached: everything after the `=` is the value) and `-- <name>`
(end-of-options marker).

This is a second lock on a door collect_node already bolted: a name
only reaches _collect_cmd if it appeared verbatim in discovery
output, and unit and container names don't normally start with a
dash. What it covers is the case where discovery output is no longer
trustworthy -- a hostile container name, or a proxy answering with
something the host never said. That assumption is exactly what the
validation rests on, which makes it the one worth not resting the
whole thing on.

The test fake matched on `-u` being present in the argv, so it had to
learn the attached form. Worth noting rather than glossing: a mock
keyed on argument shape is coupled to the argument shape, and this is
the kind of change that quietly turns such a mock into a test of
nothing. It failed loudly here, which is the good version.
…t M-2)

The counter dict was append-only: one entry per client IP that ever
made a request, held for the life of the process. On a personal
instance that's a slow leak; for anyone able to vary their source
address it's a fast one. A rate limiter that can be made to exhaust
memory is a poor trade for what it protects.

Two bounds, because they cover different shapes of the same problem:

- A sweep, amortised to one pass per window rather than per request,
  drops keys whose hits have all aged out. That bounds memory over
  time.
- A ceiling on tracked keys, checked independently of the timer,
  bounds it *within* a single window -- where a flood of distinct
  addresses can grow the dict faster than any schedule collects it.
  Over the ceiling, the last resort evicts least-recently-seen
  entries. That does hand those clients a fresh allowance, and the
  comment says so: they're the ones who hammered least recently, and
  the alternative is the process dying, which lifts the limit for
  everyone at once.

The guarantee the expiry must not break is that dropping a key and
keeping it are observationally identical -- a key is only removed once
its newest hit is older than the window, so a returning client gets
exactly the allowance it would have had. Two tests hold that from
both sides: an active client is never swept, and a client past its
window comes back with a full budget.

defaultdict is gone. Reading a key must not create one when the thing
iterating the dict is the sweep meant to shrink it.

Both new paths were mutation-tested: disabling the timed sweep fails
the leak test, disabling the eviction fails the ceiling test.
Resolved on the Deck with ./deploy/pin-base-image.sh --write.
python:3.12 is a mutable tag, rebuilt for every CPython patch release
and every Debian security update, so this fixes the base image this
commit actually runs on. The next bump is a one-line diff to review
rather than something that happens by itself on the next rebuild.
The serving process ran as container root, so anything reaching code
execution inside -- a `shell` or `files` dispatch, most obviously --
could rewrite /app/src/forge/ and everything else in the image. It
never needed that: uvicorn binds 8000, which is unprivileged, and
Forge's only writable path is /app/data (MEMORY_FILE, TRACE_FILE,
RAG_DB_FILE and WORKSPACE_DIR all default under `data/`).

The image now creates forge:forge at 1000:1000, owns /app/data and
/home/forge to it, pre-compiles /app/src as root so no __pycache__
write is attempted later, and drops with USER after the steps that
genuinely need root. HOME is set explicitly: the podman client
resolves config and scratch paths from it, and /root existed while
/home/forge only exists because it was just created.

The part worth being blunt about: **this image without
--userns=keep-id at runtime is a broken container, not a hardened
one.** Under rootless podman the container's UID 0 is already mapped
to the host user, and that mapping is what makes the 0660 proxy
sockets and ./data reachable. A container UID of 1000 maps by default
to a subuid, a stranger to all of it -- sysadmin loses both proxies
and Forge loses its writable directory. keep-id puts UID 1000 back
where container-root used to be. compose.example.yaml and
deploy/README.md now carry that flag next to every run command, and a
test asserts the two halves stay together, because the failure lands
at runtime on a machine no test can reach.

The CMD's mkdir becomes best-effort. sd_booted() checks only that
/run/systemd/system exists -- nothing writes into it -- and the
build-time mkdir covers that unless podman mounts a tmpfs over /run.
In the case where it does and we can't write there, taking the whole
API down over a sysadmin dependency is the wrong trade; it warns once
on stderr instead, where the alternative is a confusing systemctl
error much later. README documents the tmpfs-on-the-directory fix.

Escape hatch, shipped commented in the compose example: `user: "0:0"`
overrides the image USER with no rebuild and restores exactly the
previous posture. A test keeps it commented, since uncommented it
would silently undo all of this.

Not verified here, and it is the one to watch on the Deck:
--group-add keep-groups and --userns=keep-id are two different
mechanisms acting on the same entry path, and keep-groups is what
`journalctl -u` on root-owned units depends on. README's verification
list checks it explicitly as step 2.
The CMD created /run/systemd/system on every start so that
`systemctl` would clear its sd_booted() check before attempting a bus
connection. Nothing calls systemctl any more: discovery moved to
`busctl --json=short` because systemctl hardcodes a connection to
/run/systemd/private and ignores DBUS_SYSTEM_BUS_ADDRESS. busctl has
no such check, so the directory has had no reader for a while.

That makes the warning added in the non-root commit worse than
useless -- the day it fired it would send someone looking at a
dependency that no longer exists. Removed along with the mkdir it
guarded, and CMD goes back to plain exec form: uvicorn is PID 1 with
no shell in between, which is what the `exec` in the old string was
buying.

Also corrects the comments that still described systemctl as the
thing being run: the docstrings in sysadmin.py now say busctl, since
that's what DBUS_SYSTEM_BUS_ADDRESS actually points at. The one
place systemctl is still named is the returncode guard, where the
history is the point -- its two-line failure message was what got
parsed as two fake unit names in production. The command is gone;
the failure mode isn't, so the guard and its account stay.

No behaviour change: the removed code had no effect on a system where
its only consumer is absent.
@Kurtisone
Kurtisone merged commit 54cbc5e into main Aug 13, 2026
2 checks passed
@Kurtisone
Kurtisone deleted the security-lot2 branch August 13, 2026 16:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant