fix(security): gate raw inspect; add systemic RBAC coverage - #84
Merged
Conversation
GET /api/inspect/{kind} returns the raw Docker inspect payload, which for a
container includes Config.Env — database passwords, API keys. It was ungated,
so any signed-in account with no sections at all, even read-only, could read
the environment of any container on any host via ?host=. Now gated by the
section owning the kind, with an unknown kind failing closed onto containers.
The Inspect dialog only opens from pages those sections already guard, so no
legitimate use changes.
Found by the new route-coverage test rather than by reading the diff, which is
the point of it: sectionForPath falls through to "" for anything it doesn't
recognise, so a route under an unrecognised prefix is open to every signed-in
user with no error anywhere. The test walks the REAL chi router and fails
unless each /api route either maps to a section or sits on an explicit
allowlist with a stated reason — so adding an endpoint now forces a decision.
It flagged six such routes; five are genuinely shell-level reads and are
allowlisted, inspect was not.
Also covers combinations the per-feature tests structurally could not:
__admin is not grantable through a role or the per-user list; a read-only
ROLE blocks the privileged GETs (exec/pull/push/scan) that isWriteRequest
classifies as writes; role grants don't leak between users; a matrix over all
13 sections × read/write; and the gate fails closed when grants can't be
computed.
Contributor
There was a problem hiding this comment.
Pull request overview
This PR tightens RBAC around the raw Docker inspect endpoint and adds systemic test coverage intended to prevent future “new route accidentally ungated” regressions across the /api surface.
Changes:
- Gate
GET /api/inspect/{kind}by the owning RBAC section (container/images/volumes/networks) with unknown kinds failing closed. - Add systemic RBAC coverage tests, including adversarial pentests for grantability, GET-shaped writes, grant isolation, and fail-closed behavior.
- Document the security fix in
CHANGELOG.md.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| internal/api/rbac_coverage_test.go | Adds router-walk RBAC decision coverage plus RBAC pentests/matrix tests (currently contains a test setup bug and some misleading allowlist entries). |
| internal/api/access_middleware.go | Adds inspect-kind → section mapping so /api/inspect/{kind} is no longer implicitly ungated. |
| CHANGELOG.md | Adds a Security entry describing the raw inspect gating change and its impact. |
Comments suppressed due to low confidence (1)
internal/api/rbac_coverage_test.go:75
srv := &Server{cfg: config.Config{}}leavessrv.mwnil, but Handler() unconditionally callsr.Use(s.mw.RequireSession), so this test will panic before it can walk routes. Initializesrv.mw(a minimal token manager is fine since the test never serves requests).
func TestRBACEveryAPIRouteHasASectionDecision(t *testing.T) {
srv := &Server{cfg: config.Config{}}
h, ok := srv.Handler().(chi.Routes)
if !ok {
t.Fatal("the root handler is not a chi.Routes; cannot enumerate routes")
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+3
to
+15
| import ( | ||
| "context" | ||
| "net/http" | ||
| "net/http/httptest" | ||
| "sort" | ||
| "strings" | ||
| "testing" | ||
|
|
||
| "github.com/go-chi/chi/v5" | ||
|
|
||
| "github.com/koduj-dev/docker-commander/internal/config" | ||
| "github.com/koduj-dev/docker-commander/internal/store" | ||
| ) |
Comment on lines
+56
to
+61
| "/api/oauth/*": "MCP OAuth authorization server (own flow)", | ||
| "/api/.well-known/*": "OAuth/protected-resource metadata", | ||
| "/api/mcp": "bearer-authenticated MCP transport (own principal)", | ||
| "/api/mcp/*": "bearer-authenticated MCP transport (own principal)", | ||
| "/api/update/restart": "admin-only via the /update prefix", | ||
| } |
Second pass after the review, going after classes of gap rather than more cases of the same shape. Closes a gap in my own route-coverage test: it proved each route MAPS to a section, not that the gate is in its chain. A group registered without r.Use(s.permissions) would map correctly and be unenforced — mapping and mounting are separate failures. Now asserted by identifying the middleware in each route's accumulated chain. Adds the end-to-end layer I had wrongly called infeasible: the localhost 2FA exemption lets a test account log in with a password alone, which the WebSocket pentest already relied on. So a real signed-in session now drives the real router — role-derived grants, the per-section write bit, the privileged GETs, revocation on an existing session, and the raw-inspect refusal. These run under -short, so in CI. Adds the MCP analogue of the route walk: enumerate every advertised tool, call it with a deny-all gate, and require the gate's own sentinel in the refusal. A tool that never calls authorize() cannot produce it. A second variant proves the token's section scope is honoured by every tool, not just the two the smoke tests happen to use. All 20 pass. Worth recording: the first version of that MCP test reported 9 of 20 tools as unguarded. They were not — the SDK's JSON-schema validation rejected the call before the handler ran, because I passed empty arguments. Arguments are now synthesised from each tool's declared schema, so the handler is actually entered and new tools are handled without editing the test. Also covers wsChannelSection failing closed on an unknown channel.
Contributor
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
internal/api/rbac_coverage_test.go:59
ungatedRoutesis documented as "the /api paths deliberately reachable". The entries for/api/metrics,/api/notifications,/api/oauth/*,/api/.well-known/*,/api/mcp(/*) do not exist in the router, and/api/update/restartis section-gated as__admin(not actually ungated). Keeping these in the allowlist weakens this systemic coverage test and could mask a future regression in sectionForPath for the admin update routes.
"/api/metrics": "separate metrics-token auth",
"/api/notifications": "own in-app feed",
"/api/oauth/*": "MCP OAuth authorization server (own flow)",
"/api/.well-known/*": "OAuth/protected-resource metadata",
"/api/mcp": "bearer-authenticated MCP transport (own principal)",
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
You asked whether the RBAC work was actually confirmed by tests, given that the
security review found a hole the tests missed. Fair challenge — so I went after the
class of gap rather than adding more of the same, and it immediately found a
High-severity pre-existing vulnerability.
GET /api/inspect/{kind}was ungated. It returns the raw Docker inspectpayload — for a container that includes
Config.Env, i.e. database passwords andAPI keys. Any signed-in account with no sections granted at all, even
read-only, could read the environment of any container on any host via
?host=N. Proven end to end before fixing.Now gated by the section owning the kind (container→
containers, image→images,volume→
volumes, network→networks), with an unknown kind failing closed ontocontainers.Type of change
Checklist
go test -short ./...andgo vet ./...passgofmtgate is clean (gofmt -l $(git ls-files '*.go')after staging)web/dist— N/A (nothing underweb/srcchanged)docs/and added aCHANGELOG.mdentry for user-facing changes —CHANGELOG Security entry; no docs change needed (the gating matches what
docs/users.mdalready documents)Notes for reviewers
Read
TestRBACEveryAPIRouteHasASectionDecisionfirst. It's the reason the bugwas found and the reason the class won't recur.
sectionForPathfalls through to""(ungated) for any prefix it doesn't recognise — so a route added under a newprefix is open to every signed-in user, with no error, no warning, and a green
build. The test walks the real chi router and fails unless every
/apirouteeither maps to a section or sits on an explicit allowlist with a stated reason.
Adding an endpoint now forces a decision.
It flagged six routes. Five are genuinely shell-level reads and are allowlisted
with reasons (
/api/version,/api/system/df,/api/stats/overview,/api/stats/ports,/api/metrics/history— counts, series and aggregates, noconfig and no env).
inspectwas not, and is fixed.No UI impact. The Inspect dialog has exactly four call sites —
ContainerDetail, Images, Volumes, Networks — each on a page that already requires
the matching section, so gating it matches how it is actually used.
The other coverage added targets combinations the per-feature tests
structurally could not reach, because each of those tests checks a rule it already
knows about:
__adminis not grantable — via a role or the per-user list. If it everbecame grantable, holding it would mean user management, settings and LDAP.
exec,pull,push,scan)that
isWriteRequestclassifies as writes. The earlier tests only exercised theaccount-level read-only flag, so this exact combination — per-section write bit
vs. GET-shaped write — was untested.
join and invisible in single-user tests.
checks, so a section behaving differently from the rest shows up.
store underneath it.
What I'd still call uncovered, so it isn't implied otherwise: there is no test
driving a real authenticated HTTP session through the
permissionsmiddlewareend to end — these assert the routing and the gate separately. That's a bigger
harness (login + 2FA) and belongs with the RBAC UI work, where a browser session
exists anyway.