fix(ownership): deny unresolved resource targets - #389
Conversation
|
Deployment failed for project sockguard-website with the following error: Learn More: https://vercel.com/codeswhat?upgradeToPro=build-rate-limit |
|
Warning Review limit reachedNext included review available in 37 minutes. View limit detailsLimit details: You’ve used the included review currently available. You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Review configuration: ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Team Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (17)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…-fail-closed # Conflicts: # CHANGELOG.md
…an-event-filter-consolidation
…an-event-filter-consolidation
# Conflicts: # CHANGELOG.md
…-fail-closed # Conflicts: # CHANGELOG.md # app/internal/ownership/libpod_coverage_test.go # app/internal/ownership/libpod_paths.go # app/internal/visibility/libpod_events.go # docs/content/docs/podman.mdx
Owner isolation refused a target the daemon could not resolve with the same 403 it uses for a resource that resolves to another owner, so a client deleting something already gone read as a permissions failure and the layer disagreed with visibility, which answers 404 for a resource its policy hides. Split the denial verdict in two. verdictDenyMissing carries the unresolved case and answers 404; verdictDeny keeps 403 for a resolved resource whose labels fail the policy, for a remote image SCP source, and for a malformed local one. Both are equally fail-closed: nothing reaches the requested upstream path in enforce mode, warn and audit still log the would-be denial and forward, and an inspect error is still a 502. Denial checks now ask verdict.denied() rather than comparing against one constant, so a missing target cannot leak through a `!= verdictDeny` test. The rule covers every unresolved reference, not just the one the URL names: exec sessions, container:<ref> namespace-sharing targets, and the images, volumes, networks, secrets and configs embedded in create and update bodies. The reason vocabulary now tracks the status, "could not resolve X" for a lookup that came back empty and "denied access to X" for one that came back with the wrong owner, so the SCP malformed-source reason moved to the second wording since nothing was ever looked up for it.
The four Docker-compat prune routes have carried an injected owner label
filter since owner isolation shipped. Their libpod spellings carried nothing:
libpodNeedsOwnerFilter listed only GET reads and both call sites gated it on
GET/HEAD, so POST /libpod/{containers,images,networks,volumes}/prune went
upstream verbatim and deleted every prunable resource on the host whoever
owned it.
All four document a JSON-encoded `filters` parameter taking `label` and read
it through util.PrepareFilters at Podman v5.8.1, and containers/prune is
routed to the same compat.PruneContainers handler as the compat path, so they
take the same injection the compat routes take. libpodNeedsOwnerFilter now
takes the method, like its Docker-compat counterpart, instead of leaving the
method test to each caller.
POST /libpod/pods/prune has no filters to inject: its swagger block documents
no parameters and PodPruneHelper calls runtime.PrunePods with no options at
all. It is refused instead, through a write-side twin of the unscopeable-read
catalog, and the refusal is unconditional — warn mode buys an operator a
measurement of what enforcement would cost, and there is none to take once
another tenant's pods are gone. DELETE /libpod/pods/{name} still names a pod
the ownership layer checks.
POST /commit was the one write in the API that ownership never classified. Every other check starts from a path identifier, and commit names its container in the `container` query parameter instead, so allowPathOwnershipRequest walked straight past it. A client holding the `POST /commit` rule that configs/portainer.yaml ships (and that COMMIT=1 generates through the Tecnativa compat layer) could commit another owner's container, and the image it produced carried no owner label at all: invisible to the owner-filtered image list and outside every later ownership check. Commit is now classified off the query on all three registered spellings (moby's POST /commit, Podman's compat POST /commit, and its native POST /libpod/commit). The named container is authorized as an embedded reference, so it gets the same verdicts as the rest of the branch: 403 for a container another owner holds, 404 for one that does not resolve, 502 for a lookup failure. The owner label is stamped into the commit body's config, and a body is synthesized with a JSON content type when the client sends none, because both engines accept an empty commit body. Three request shapes are refused outright rather than authorized: - No `container` parameter. There is nothing to check, and owner isolation does not forward a write it could not classify. - A repeated or two-case-variant `container`. Moby reads the first value and Podman reads the last, so the container checked would not be the container committed. filter.FoldedScalarQueryValue, exported here for the purpose, is the same helper the container-archive policy uses for that disagreement. - A `changes` value carrying a LABEL instruction. Both engines apply changes on top of the body config (moby through dockerfile.BuildFromConfig, Podman through buildah's parseOverrideChanges), so a LABEL there overwrites the stamp. The refusal is on the instruction rather than the key it sets: deciding which key a LABEL line touches means parsing Dockerfile quoting, multi-pair lines and continuations, and getting that wrong is a silent bypass instead of a broken request. Image labels can still be set in the body config.
setPodmanEventLabelFilter serves both GET /events and GET /libpod/events, and derived its filter key from compatEventsPath, so half its callers got a key named after a path they never used. The result was right by coincidence: the only path visibilityLabelFilterKey answers differently for is the Swarm node list, which no event stream reaches. The key is not path-derived at all. It is the name of the filter Podman's event handler accepts, so it is now a named constant with the reasoning on it, and the test asserts the two things the coincidence was standing in for: the injected and sole-value-marked key is that one on both spellings, and it still matches what owner isolation derives for those paths. If those diverge the ownership layer stops seeing the marker and appends an owner value beside the visibility selector, which Podman ORs, widening the stream this path narrows. No behavior change, so no test fails before it.
…odes podman.mdx still said a local SCP source that inspects as not found "passes through so Podman can return its real error". It has not passed through since the fail-closed change, and it now answers a synthetic 404 without contacting Podman. The same page's Known Limitations entry for showmounted still said "Neither is wired today" about a refusal both isolation layers have carried since the mounted-inventory fix, so it is rewritten as the residual case it actually is: a deployment running neither layer. security.mdx's Layer 6 gets the status contract in one place. A target the daemon could not resolve is a 404, a target that resolved under another owner's label is a 403, a lookup that errored is a 502, and none of the three reaches the upstream path. The 404 half is the load-bearing part: it matches what a visibility policy already answers for a hidden resource, so the pair of layers cannot be told apart by status code, and it is what an idempotent teardown expects when it deletes something already gone. Both pages and the changelog also pick up the two surfaces this branch newly classifies: the libpod prune family, owner-filtered like its Docker-compat twin with pods/prune refused because it takes no filters at all, and container commit, owner-checked off its query parameter with the owner label stamped into the image it produces. The changelog marks both breaking, since a host-wide libpod prune and a commit carrying a LABEL change instruction both worked before and now do not under owner isolation. Two smaller corrections ride along, both about claims the commit work falsified. The Known Limitations entry for the batch image endpoints said owner isolation cannot check them because they name their images in the query; the query is not the obstacle, the set is. And commit.go's own comment said Tecnativa's COMMIT=1 generates the rule that opens the endpoint, when COMMIT=1 alone generates GET,HEAD and it takes POST=1 to widen the section. The "exhaustive" claim about the collection-route helper tests is now "modeled", which is what those tests actually pin.
…-fail-closed # Conflicts: # CHANGELOG.md # app/internal/ownership/libpod_coverage_test.go # app/internal/ownership/libpod_paths.go # docs/content/docs/podman.mdx
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
biggest-littlest
left a comment
There was a problem hiding this comment.
Rebuilt on dev/v2.0 as a stacked chain, CI green, adversarial review findings folded in on the branch.
ALARGECOMPANY
left a comment
There was a problem hiding this comment.
Rebuilt on dev/v2.0 as a stacked chain, CI green, adversarial review findings folded in on the branch.
Owner isolation used to have three ways out of a decision it couldn't make: a
target that didn't resolve got forwarded, a libpod endpoint the path classifier
didn't recognize got forwarded, and a couple of write surfaces were never
classified at all. This closes those, and gives the denials status codes that
mean something.
What fails closed, and with what status
Once a Docker or libpod path is classified as naming a specific resource,
ownership has to resolve that resource before the request can continue. The two
ways that can fail now answer differently:
404when the daemon could not resolve the target. A missing inspect resultestablishes no owner, and there's nothing to report but absence.
403when the target resolved and carries another owner's label.502when the inspect itself errored.Nothing reaches the requested upstream path in any of the three cases.
The
404is the half worth arguing about, and there are two reasons for it.It's the same answer a visibility policy already gives for a hidden resource,
so a caller can't tell the two layers apart by status code and use the
difference as an existence oracle. And it's what an idempotent client expects:
a Compose teardown, Ryuk, or a Terraform destroy that removes an
already-removed container reads
404as "already gone" where a403lookslike a permission failure worth retrying or escalating.
The rule covers the resource named by the URL, resources referenced inside
container and service bodies,
container:<ref>namespace-sharing targets,Docker and libpod exec sessions, and the local source of
POST /libpod/images/scp/{name}. A source that couldn't be parsed at all staysa
403, because nothing was looked up.allow_unowned_imagesstill applies toan image that resolves and has no owner label, never to one that didn't
resolve. Paths that name no resource still pass through the layer untouched,
and
warnandauditkeep the existing rollout contract: log the would-bedenial, forward the request.
Prune
POST /libpod/containers/prune,/images/prune,/networks/pruneand/volumes/prunewere in neither ownership path list. A client under ownerisolation that pruned through a native spelling deleted every prunable resource
on the host, while the identical Docker-compat request deleted only its own.
All four document a
filtersparameter that takeslabeland read it throughutil.PrepareFiltersat Podman v5.8.1, so the owner selector is injected intoeach, exactly as it is into the compat twins.
One detail constrains how:
libpod.PruneImagesflattens the decoded filter mapwith
fmt.Sprintf("%s=%s", k, v[0]), so only the first value underlabelreaches libimage. The injection writes the owner selector first and appends any
visibility selectors after it, so the owner scope is the one that survives that
truncation.
POST /libpod/pods/pruneis the one route in the family that can't be scoped.PodPruneHelpercallsruntime.PrunePods(r.Context())with no options andturns the result into a report, so it documents no parameters, names no pod,
and reports what it removed only after removing it. It's refused with a
403audited as
owner_libpod_pod_prune_unscopeable, and the refusal ignoresrollout mode, because there's no measurement left to take in warn mode once
another owner's pods are gone. Pods still come off one at a time through
DELETE /libpod/pods/{name}, which names a pod the ownership layer checks. Avisibility policy doesn't refuse it: neither of its axes decides a write.
Commit
POST /commitnames its container in thecontainerquery parameter ratherthan the path, and ownership only ever classified
/containers/-prefixedpaths. So a client holding the
POST /commitrule thatportainer.yamlshipscould commit another owner's container, and the image that came out carried no
owner label at all, which put it outside the owner-filtered image list and
every later ownership check.
All three registered spellings are classified off that parameter now: moby's
POST /commit, Podman's compatPOST /commit, and its nativePOST /libpod/commit. The named container gets the same 404/403/502 verdictsas any other target, and the owner label is stamped into the commit body's
config so the new image is owned. A body is synthesized under
Content-Type: application/jsonwhen the client sends none, because bothengines accept an empty commit body and moby runs
CheckForJSONbeforedecoding.
Three shapes are denied instead of forwarded:
containerparameter. Nothing to authorize.container. Moby reads the first value andPodman reads the last, so the container checked wouldn't be the container
committed. This reuses
filter.FoldedScalarQueryValue, the same helper thecontainer-archive policy uses for that disagreement.
changesvalue carrying aLABELinstruction. Both engines apply changeson top of the body config (moby through
dockerfile.BuildFromConfig, Podmanthrough buildah's
parseOverrideChanges), so a LABEL there overwrites thestamp. The refusal is on the instruction, not on the key it sets: deciding
which key a LABEL line touches means parsing Dockerfile quoting, multi-pair
lines and continuations, and getting that wrong is a silent bypass rather
than a broken request. Labels set in the body config are untouched.
Reserved libpod collection reads
Some libpod reads have no owner-scoped form to offer. They take no
filtersparameter and their responses carry no labels, so there's nothing to attach a
filter to and nothing to decide an entry by. Both isolation layers refuse them
with a
403before the upstream is contacted, regardless of rollout mode.GET /libpod/containers/showmountedwas already refused before this branch.GET /libpod/containers/statsandGET /libpod/pods/statsare the additionhere. Both are collection endpoints rather than per-resource ones: a request
that names nothing reports every running container or pod on the host by
design, which Podman's own
ValidatePodStatsOptionsstates outright when itsets
allfor a request that specifies nothing. Per-container stats survive,since
GET /libpod/containers/{name}/statsnames a container the layer cancheck.
podman-readonly.yamlshipped an explicit allow forGET /libpod/pods/statsand no longer does. That rule was a false promise: the preset exists to be a
read-only posture you can put an owner or a visibility policy behind, and the
one endpoint neither can scope could only ever have ended in a
403foranyone running a layer.
Events
/eventsand/libpod/eventsare one handler in Podman(
register_events.goputscompat.GetEventsbehind both), and it evaluatesseveral values under one filter key disjunctively, so appending a selector
widens the stream instead of narrowing it. Both spellings now go through one
rewrite: no selectors forwards untouched, exactly one selector replaces the
labelkey outright, and two or more are refused with a403that doesn'tconsult rollout mode, because a stream isn't a verdict an operator can measure
after the fact.
The replacement also records a sole-value marker on the request, and owner
isolation reads that marker: rather than appending an owner value beside a
visibility selector Podman would OR, it refuses. The filter key both layers use
for those paths is now a named constant with the reasoning attached, instead of
being derived from the compat path by a function that also served the libpod
one.
Breaking
Everything here except the preset change applies only when
ownership.ownerisset.
Under isolation the four filterable routes prune only the caller's
resources, and
POST /libpod/pods/pruneanswers403.changes=LABEL ...now answers403. Move thoselabels into the body config, or run without owner isolation.
podman-readonly.yamlno longer allowsGET /libpod/pods/stats. Add it backto your own config if you run single-tenant with neither isolation layer on.
Checks
From
app/:gofmt -l .clean,go build ./...,go vet ./...,golangci-lint runover ownership, visibility, filter and config at 0 issues,go test -count=1over ownership, visibility, filter, config and cmd, andgo test -count=1 -tags integration ./integration/. Docs and changelog claimsare checked by
scripts/release-metadata.test.mjs.