Security review — 2026-09-10
A pass over the whole surface: every #[page] and #[route] in
src/infrastructure/web/, the git transport, the CLI, the outbound paths, and the
dependency tree. Steid 0.4.0, branch security/review-2026-09-10, verified against a
running instance on :3135 unless a finding says otherwise.
Seven findings: one high, four medium, two low. All seven fixed, one commit each.
Four things are deferred and are carried into current.md.
The headline is that the boundary work is in good shape — no enumeration hole, no
authorization gap, no injection — and every finding is about resource limits or about
a value reaching somewhere it was never checked for. The two that matter most are both
"a library's default is not what we assumed": Topcoat reads a request body with
usize::MAX, and ureq follows ten redirects.
What was looked for and not found
Stated first so a later reader knows these were checked rather than skipped.
- Enumeration. A private repository, an absent one and a mistyped handle all answer
the same. The page 404s are byte-identical (
diffof the two bodies), the git transport answers a uniform 401 challenge for all three, and/api/setuphas one refusal for a wrong token, an already-claimed instance and a bad password./metricsand the detailed/api/instanceanswer 404 rather than 401 for a wrong bearer./auth/forgotanswers the same 303 whatever the address was. - Authorization.
application/access.rsis the only place that decides, andaccess_tests.rsalready walkswritable_repoagainstchangeable_repofor every actor. No write is reachable withoutRole::Owner. The side channels named in the brief all resolve through the same predicates: feeds, search, archive, blame, the compare page, the/issues/{n}↔/pulls/{n}redirect (which only ever redirects within a repository the actor already read), the JSON API, and the per-file last-commit column. - Injection. Markdown never passes raw HTML through and allowlists link schemes on
the control-stripped string; the Atom writer escapes every value through one
function; both
Content-Dispositionbuilders reduce the filename to a set that cannot end the quoted string; the archive prefix is sanitised in the use case; and everygitargument that could come from a URL is either a resolved object id or is preceded by--, withRefNameandRepoPathrefusing a leading-besides. - Secrets. Nothing in
eprintln!/println!carries a token, a password, a reset link or an SMTP URL — the reset link is printed bysteid reset-password, which is its whole purpose.SetupToken,ResetSecret,TokenSecretandSecretall have redactingDebug. One exception, fixed below. - Headers. CSP,
nosniff,Referrer-Policy: no-referrerandX-Frame-Optionsare on every response including error responses. HSTS is Caddy's job anddeploy/Caddyfileis where it belongs — Steid terminates no TLS. Cookie flags are Topcoat'sSessionConfig::default(), downgraded only bySTEID_INSECURE_COOKIES, which announces itself at boot.Cache-Controlwas the gap; fixed below. - Dependencies.
cargo audit: zero vulnerabilities, three standing warnings —bincode1.3.3 unmaintained (RUSTSEC-2025-0141),yaml-rust0.4.5 unmaintained (RUSTSEC-2024-0320),chacha200.10.1 yanked. All three are transitive, none is a vulnerability, and none has an upgrade that does not mean waiting onsyntectand Topcoat.
The surface
Authentication is the session cookie unless a row says otherwise. "Rate limit" names
the limiter from web::rate_limit; the login limiter is 10 per client and 60 globally
per minute, git-auth 60/600 per minute, archive 10/60 per minute, instance 60/600 per
minute, metrics 30/300 per minute, reset 30/120 per minute, backup 2/8 per hour. A
refusal is "uniform" when a caller cannot tell an absent thing from one they may not see.
Pages, read
| Route | Auth | Rate limit | To an anonymous caller | Uniform |
|---|---|---|---|---|
GET / | none | — | redirects to /auth/setup when unclaimed, else to the owner's profile | n/a |
GET /{handle} | none | — | public profile and the repositories that caller may see | yes |
GET /{handle}/repos | none | — | same, paged | yes |
GET /{handle}/repos/{name} | none | — | public repositories only; private and absent both 404 | yes |
GET …/tree/{rev}, …/tree/{rev}/-/{*path} | none | — | as above | yes |
GET …/raw/{rev}/-/{*path} | none | — | as above; application/octet-stream + attachment + default-src 'none'; sandbox, capped at MAX_RAW_BYTES (10 MiB) | yes |
GET …/log, …/log/{rev}, …/log/{rev}/-/{*path} | none | — | as above | yes |
GET …/blame/{rev}/-/{*path} | none | — | as above; one git blame under the 20s cap | yes |
GET …/commits/{sha}, …/compare, …/compare/{spec} | none | — | as above; diffs read through a byte cap | yes |
GET …/branches, …/tags | none | — | as above | yes |
GET …/search | none | none — deferred | as above; grep now read through a 4 MiB cap | yes |
GET …/issues, …/issues/{number}, …/issues/new | none | — | as above; new needs ownership and 404s otherwise | yes |
GET …/pulls, …/pulls/{number}, …/{number}/commits, …/{number}/files, …/pulls/new | none | — | as above; the merge preview is cached per branch pair | yes |
GET …/labels | none | — | as above | yes |
GET …/settings, …/settings/hooks, …/settings/hooks/{hook} | owner | — | 404 | yes |
GET /{handle}/settings, …/settings/tokens | owner | — | 404 | yes |
GET /auth/login, /auth/setup, /auth/forgot | none | — | the form; /auth/setup redirects away once claimed | n/a |
GET /auth/reset/{token} | the token | reset | the form, or the dead-link page — unknown, expired and spent are one answer | yes |
Pages, write — every one CSRF-checked before its use case
| Route | Auth | To an anonymous caller | Uniform |
|---|---|---|---|
POST /{handle}/repos/new, …/repos/import | owner | 403 (CSRF runs first, and consults nothing) | yes |
POST …/{name}/import/retry | owner | 403 | yes |
POST …/issues, …/issues/{n}/comments, …/state, …/edit, …/labels | owner | 403 | yes |
POST …/pulls, …/pulls/{n}/comments, …/state, …/edit, …/labels, …/merge | owner | 403 | yes |
POST …/labels, …/labels/{id}/edit, …/labels/{id}/delete | owner | 403 | yes |
POST …/settings, …/settings/archive, …/default-branch, …/rename, …/delete | owner | 403 | yes |
POST …/settings/hooks, …/hooks/{h}/delete, …/toggle, …/deliveries/{d}/redeliver | owner | 403 | yes |
POST /{handle}/settings | owner | 403 | yes |
POST /{handle}/settings/tokens, …/tokens/{token}/revoke | owner | 403 | yes |
Routes
| Route | Auth | Rate limit | To an anonymous caller | Uniform |
|---|---|---|---|---|
GET /healthz | none | — | ok or unavailable, and nothing else | n/a |
GET /robots.txt | none | — | a fixed document | n/a |
GET /metrics | operator bearer, constant-time | metrics, only on failure | 404 — the surface does not exist without a token | yes |
GET /api/instance | operator bearer, optional | instance, only unauthenticated | {"claimed":…} and nothing else | yes |
POST /api/setup | setup token | login | one 404, byte-identical for every refusal, before Argon2 | yes |
GET /api/me | session | — | 401 | n/a |
GET /api/users/{handle} | none | — | the public profile; 404 for an unknown handle | yes |
GET /api/users/{handle}/repos | none | — | only visible repositories; [] rather than 404 for a handle that owns nothing visible | yes |
GET /{handle}/feed.atom, …/commits.atom, …/issues.atom | none | — | only what the caller may see | yes |
GET …/archive/{file} | none | archive, counted before the 404 | public only; ETag is the resolved commit | yes |
GET /{handle}/settings/backup.tar.gz | instance owner and profile owner | backup, after the ownership check | 404 | yes |
GET …/{repo}/info/refs | token (Basic) or session | git-auth, only when a credential is presented | 401 challenge for private and absent; no service parameter is a 404, so the dumb protocol is never served | yes |
POST …/git-upload-pack, …/git-receive-pack | as above | as above | 401 challenge; archived is 403 with a sentence, importing is 409 | yes |
POST /auth/login, /auth/setup | none | login, before Argon2 | 303 back to the form, saying nothing | yes |
POST /auth/forgot | none | login | the same 303 whatever the address | yes |
POST /auth/reset/{token} | the token | reset | 303; dead and unknown are one answer | yes |
POST /auth/logout | session | — | 403 without a CSRF token | yes |
POST /{handle}/settings/password | owner | — | 403 without a CSRF token | yes |
Commands
| Command | Auth | Reveals |
|---|---|---|
steid | — | serves; prints the setup token once, only while unclaimed, only when it generated it |
steid backup <destination> | the filesystem | a full copy of the instance. Does not migrate, deliberately |
steid restore <archive> | the filesystem | refuses anything but an empty instance, before a byte is written |
steid reset-password <handle> | the filesystem | a live reset link. Prints nothing and still exits 0 for an unknown handle — distinguishable on purpose, since the caller already has the SQLite file |
Findings
1 · High · No limit on a request body, so any POST is a memory amplifier
Form<T> and Json<T> both collect with to_bytes(body, usize::MAX) — read in the
vendored topcoat-router-0.5.0/src/content/{form,json}.rs, and there is no builder knob
for it. So every form endpoint held whatever arrived, /auth/login and /api/setup
included, both anonymous and both reachable before any authentication.
Measured against :3135:
$ ps -o rss= -p $(pgrep -f target/debug/steid) # 20080 KB
$ curl -X POST --data-binary @<(head -c 200000000 /dev/zero | tr '\0' a) \
-H 'Content-Type: application/x-www-form-urlencoded' \
http://127.0.0.1:3135/auth/login # 400
$ ps -o rss= -p $(pgrep -f target/debug/steid) # 811616 KB
One request, 20 MB → 811 MB. A handful in parallel is the machine, and the reply is a 400 either way, so nothing about the endpoint even discourages it.
Fixed in 842e3f0: web::body_limit, applied in the one layer at /. A mebibyte,
which is three orders of magnitude above the largest real form. A declared
Content-Length over the cap is refused with 413 before a byte is read; an undeclared
one fails while it arrives and surfaces as 400. The two git RPC paths are exempt,
matched on the whole four-segment route rather than the last segment — RepoName
reserves neither git-upload-pack nor git-receive-pack, so a repository may be called
that, and the test in the module is what caught it.
Re-measured: 413 and 400 respectively, resident memory flat at 21 MB, and a 4 MiB
git push plus the clone back both succeed.
2 · Medium · A reset token from a URL could panic the worker answering it
POST /auth/reset/{token} interpolated the path parameter into see_other, and a path
parameter arrives percent-decoded — so /auth/reset/a%0d%0ab produced a Location
carrying a real CRLF. see_other refuses that by panicking, not by returning an
error.
$ curl -o /dev/null -w '%{http_code}\n' -X POST \
-d 'password=aaaaaaaaaaaa&confirmation=aaaaaaaaaaaa' \
'http://127.0.0.1:3135/auth/reset/a%0d%0ab'
000
# server log:
thread 'tokio-rt-worker' panicked at topcoat-router-0.5.0/src/error/redirect.rs:140:50:
redirect uri is not a valid header value: InvalidHeaderValue
000 is curl's "no response at all". The process survives — the unwind is per task —
but an anonymous caller can drop a connection and write a panic to the log at will, and
this is the shape header injection would have taken had HeaderValue been laxer.
Fixed in 2c5f03a: the token is percent-encoded on its way into the Location.
Encoding rather than a shape check, because a real token is 64 hex characters and comes
back from encode byte for byte — so the legitimate path is unchanged and there is no
second spelling of ResetSecret's shape to drift out of step with the domain. Two tests
pin it. Every other see_other/redirect call site in the web layer was checked: the
rest take a literal or a validated handle.
3 · Medium · Webhook delivery followed redirects, walking past the SSRF check
check_resolves_publicly resolves the saved URL's host and refuses unless every
address is public. ureq's default max_redirects is 10
(ureq-3.3.0/src/config.rs:874), and nothing re-checked the target of a redirect. So a
receiver on a perfectly public host answering 302 Location: http://169.254.169.254/latest/meta-data/ sent the delivery there past both halves of
the rule — and worse than a blind request, because the response body is stored on the
delivery row and rendered on the deliveries page, so it reads back.
Only the instance owner can add a hook today, so this is self-inflicted for now. It is
exactly the assumption Milestone 7 removes, and the whole reason WebhookUrl is as
narrow as it is.
Fixed in 6b69831: max_redirects(0). The 3xx is then recorded as the delivery's
own answer, which is the honest thing to show a receiver that redirects; nothing that
legitimately receives a webhook needs one. A test reads the setting back off the agent.
4 · Medium · The import clone followed redirects too
The same hole, one layer over. resolve_public_host checks every address the source's
host answers with, and then git clone runs with http.followRedirects at its default
of initial — which permits the first request to be redirected and re-resolves
whatever host it is sent to.
Fixed in 4be05bf: -c http.followRedirects=false on the clone, verified accepted
by the installed git. The cost is real and is the reason this one is a trade rather than
a free fix: a source whose URL only works through a redirect — a repository renamed on
its host — now fails the import instead of silently following. The failure names the
URL and the real one can be pasted in its place, which is better than a check that reads
as a guarantee and is not one.
5 · Medium · A repository search collected everything git matched
grep was the last read on an anonymous page still using the uncapped run. The search
page takes its query from a URL, has no rate limit of its own, and keeps at most
SEARCH_MAX_MATCHES (1000) of them — but ?q=e had git write every matching line in
the repository into memory first, and only then discarded all but those.
$ git grep -I -n -z -F --column --no-color -e e HEAD -- | wc -c
5305298
5.3 MB from one letter over Steid's own source, unauthenticated, per request, for any
public repository. A kernel-sized tree is orders of magnitude worse, and the 20-second
timeout is a long time to spend filling a Vec.
Fixed in 56dc6cd: run_capped_allowing, a 4 MiB ceiling, and the exception
run_allowing carried — grep's exit 1 for "matched nothing" — carried over with it. A
truncated tail costs a result nobody was going to be shown, and a partial final record
simply fails to parse. Both halves are pinned by a test.
6 · Low · The operator bearer token was the one secret with a plain Debug
AppConfig derives Debug and lives in the app context, which is why setup_token and
smtp_url are Secret. operator_token was a bare String — and it is the worst of
the three to print, because unlike the setup token it is long-lived and stays in context
for the life of the process. Nothing renders the config today; this is the class the
type exists to prevent.
Fixed in 6eba149, with the test the other two already had.
7 · Low · No Cache-Control on anything
Every URL under /{handle}/… is the same shape whether it renders something public or
private, so an intermediary had nothing to tell them apart by, and a browser was free to
heuristically cache a private repository's page.
$ curl -D - -o /dev/null http://127.0.0.1:3135/ada/repos/secret | grep -i cache
(nothing)
Fixed in c410dc3: private, no-cache as a default in the same layer, via
or_insert, so the three responses that already decided keep their answer — the asset
routes' year-long public, immutable, git's must-revalidate on an advertisement, and
the backup's no-store. no-cache rather than no-store for the archive's sake: a
response the browser was told not to store has nothing left to answer If-None-Match
with. Checked in the running app — all four are as described and the archive's 304 still
happens.
Deferred
Carried into current.md.
- The search page has no rate limit. The byte cap bounds the memory; the CPU is
still a full
git grepper request on an anonymous page, and it is the most expensive unauthenticated read after the archive. It wants aSearchLimiteralongsideArchiveLimiter— mechanical, but a limiter is a policy decision (how many, over what window, and whether an empty query counts) and those belong to the user. - CSRF on the signed-out forms.
/auth/login,/auth/setup,/auth/forgotand/auth/reset/{token}are exempt with a written reason: there is no session to bind a double-submit token to. Login CSRF is real and needs a pre-session cookie. Already noted by 0008; restated here because a review that did not mention it would read as having missed it. - DNS rebinding on both outbound paths. Both re-resolve at connect time, so a record
whose TTL expires between the check and the dial still gets through. Closing it means
a custom resolver, or pinning the address and setting
Host/SNI by hand — forureqand forgitseparately. Recorded in both modules already. runstill collects without a byte cap for the reads that are not grep or diff —log,for-each-ref,cat-file. Each is bounded by--max-count, by the ref count, or by an object size checked from its header first, so none is reachable in the shape grep was. Worth a sweep if a page ever asks git something open-ended again.
Verification
cargo test— 1309 pass, 0 fail.cargo clippy --all-targets— zero warnings.cargo fmt— clean.cargo audit— zero vulnerabilities, the three standing warnings above.- Against a real instance on :3135: claimed headlessly, signed in, a public and a private repository created, a 4 MiB push and clone over HTTP Basic with a personal access token, and every probe quoted above.