feat(security): filter aggregate reads by host scope (phase 3) - #94
Merged
Conversation
Phase 2 stopped a scoped user ACTING on another host. It did not stop them READING about it, and this closes that — invariant 4 of the design note. It was smaller than the note feared, for an instructive reason: most of the "aggregates" aren't aggregates. Topology, the events feed, disk usage, stats overview and published ports are per-host reads taking ?host=, so phase 2 already covered the ones that map to a section. What actually leaked: - Three dashboard routes map to NO section, so the middleware returned before it looked at the host and served another host's counts, disk usage and published ports. Ungated means "no section required", not "any host you like"; a named host must now be one the caller's grants reach somewhere. - The host list, project list, alert feed and audit log named hosts and workloads the caller was scoped away from. All four are filtered now. The alert feed's unread count is computed AFTER filtering — a badge that still counted hidden events would announce them. - Metrics history was the one real gap. The store keys by container id, so knowing an id was enough to read its CPU/memory series from any host. ContainerStat already carried HostID and recordHistory was dropping it; the sample now keeps it and the series is authorised against it. An unrecorded id is unknown, not local, so ids can't be probed for existence. Running it end to end found a bug phase 2 shipped: the local daemon can be named as host 0 or as the id of the seeded kind='local' row, and only 0 was treated as always-in-scope. A scoped user therefore kept ?host=0 but lost ?host=1, and the local host vanished from their host list. Both names now normalise to the same daemon. The unit tests missed it because they only ever used 0; there is now a test that uses both, and it fails on the old code. Left deliberately global: the alert engine, which watches every host as background work with no user context. Documented rather than implied away. Verified each fix fails its test when removed, and end to end: a user scoped to staging sees only local + staging in the host list, gets 403 on the three dashboard routes for prod, and reaches the local daemon under either id.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Yes, the leaks were fixable. Phase 2 stopped a scoped user acting on another
host; it didn't stop them reading about it. This closes that — invariant 4 of
the design note, the one phase 2 left open.
Type of change
Checklist
go test -short ./...andgo vet ./...passgofmtgate is clean (gofmt -l $(git ls-files '*.go')after staging)cd web && npx tsc --noEmit)web/distdocs/and added aCHANGELOG.mdentry for user-facing changesNotes for reviewers
It was smaller than the design note feared, for an instructive reason: most of
the "aggregates" aren't aggregates. Topology, the events feed, disk usage, stats
overview and published ports are each per-host reads taking
?host=— so phase2's middleware check already covered the ones that map to a section. What actually
leaked was narrower and sharper:
/api/stats/overview,/api/system/df,/api/stats/ports. The middleware returned before it lookedat the host, so they served another host's counts, disk usage and published
ports. Ungated means "no section required", not "any host you like": a named host
must now be one the caller's grants reach somewhere. There's no single section to
check against, hence the coarser
Store.ReachableHosts.hosts and workloads the caller was scoped away from. All four filter now, through
one shared predicate. The alert feed's unread count is computed after
filtering; a badge that still counted hidden events would announce them.
knowing an id was enough to read its CPU/memory series from any host.
ContainerStatalready carriedHostIDandrecordHistorywas throwing itaway — the sample keeps it now and the series is authorised against it. An
unrecorded id is treated as unknown, not local, so ids can't be probed for
existence.
Running it end to end found a bug phase 2 shipped
The local daemon can be named two ways:
?host=0("the default"), or the id ofthe seeded
kind = 'local'row (usually 1). Only0was treated asalways-in-scope. So a scoped user kept
?host=0but was denied?host=1, andthe local host vanished from their host list. Both names normalise to the same
daemon now.
The unit tests missed it because they only ever used
0.TestHostScope_LocalHostIsInScopeUnderEitherIDuses both and fails on the old code.Verification
Every fix was mutation-tested — removing the ungated-route check, the filter
predicate, the post-filter unread count, or the metrics host check each fails its
own test and nothing else's.
End to end, a user scoped to
staging:local, prod, staginglocal, staging/api/stats/overview?host=prod/api/containers?host=0/?host=1/api/containers?host=prodWhat's deliberately left
The alert engine still watches every host — it's background work with no user
context. So a rule that names you as an e-mail recipient can mail you about a host
you can't see in the app. That's a property of how recipients are configured, not
something the app can decide per viewer, and it's documented in
docs/users.mdrather than implied away.