Skip to content

fix(ownership): deny unresolved resource targets - #389

Merged
scttbnsn merged 18 commits into
dev/v2.0from
scttbnsn/ownership-fail-closed
Sep 3, 2026
Merged

fix(ownership): deny unresolved resource targets#389
scttbnsn merged 18 commits into
dev/v2.0from
scttbnsn/ownership-fail-closed

Conversation

@scttbnsn

@scttbnsn scttbnsn commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

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:

  • 404 when the daemon could not resolve the target. A missing inspect result
    establishes no owner, and there's nothing to report but absence.
  • 403 when the target resolved and carries another owner's label.
  • 502 when the inspect itself errored.

Nothing reaches the requested upstream path in any of the three cases.

The 404 is 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 404 as "already gone" where a 403 looks
like 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 stays
a 403, because nothing was looked up. allow_unowned_images still applies to
an 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 warn and audit keep the existing rollout contract: log the would-be
denial, forward the request.

Prune

POST /libpod/containers/prune, /images/prune, /networks/prune and
/volumes/prune were in neither ownership path list. A client under owner
isolation 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 filters parameter that takes label and read it through
util.PrepareFilters at Podman v5.8.1, so the owner selector is injected into
each, exactly as it is into the compat twins.

One detail constrains how: libpod.PruneImages flattens the decoded filter map
with fmt.Sprintf("%s=%s", k, v[0]), so only the first value under label
reaches 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/prune is the one route in the family that can't be scoped.
PodPruneHelper calls runtime.PrunePods(r.Context()) with no options and
turns 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 403
audited as owner_libpod_pod_prune_unscopeable, and the refusal ignores
rollout 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. A
visibility policy doesn't refuse it: neither of its axes decides a write.

Commit

POST /commit names its container in the container query parameter rather
than the path, and ownership only ever classified /containers/-prefixed
paths. So a client holding the POST /commit rule that portainer.yaml ships
could 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 compat POST /commit, and its native
POST /libpod/commit. The named container gets the same 404/403/502 verdicts
as 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/json when the client sends none, because both
engines accept an empty commit body and moby runs CheckForJSON before
decoding.

Three shapes are denied instead of forwarded:

  • No container parameter. Nothing to authorize.
  • A repeated or two-case-variant container. Moby reads the first value and
    Podman reads the last, so the container checked wouldn't be the container
    committed. This reuses filter.FoldedScalarQueryValue, 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, 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 filters
parameter 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 403 before the upstream is contacted, regardless of rollout mode.

GET /libpod/containers/showmounted was already refused before this branch.
GET /libpod/containers/stats and GET /libpod/pods/stats are the addition
here. 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 ValidatePodStatsOptions states outright when it
sets all for a request that specifies nothing. Per-container stats survive,
since GET /libpod/containers/{name}/stats names a container the layer can
check.

podman-readonly.yaml shipped an explicit allow for GET /libpod/pods/stats
and 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 403 for
anyone running a layer.

Events

/events and /libpod/events are one handler in Podman
(register_events.go puts compat.GetEvents behind both), and it evaluates
several 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
label key outright, and two or more are refused with a 403 that doesn't
consult 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.owner is
set.

  • A host-wide libpod prune now needs a deployment without owner isolation.
    Under isolation the four filterable routes prune only the caller's
    resources, and POST /libpod/pods/prune answers 403.
  • A commit that relies on changes=LABEL ... now answers 403. Move those
    labels into the body config, or run without owner isolation.
  • podman-readonly.yaml no longer allows GET /libpod/pods/stats. Add it back
    to 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 run over ownership, visibility, filter and config at 0 issues,
go test -count=1 over ownership, visibility, filter, config and cmd, and
go test -count=1 -tags integration ./integration/. Docs and changelog claims
are checked by scripts/release-metadata.test.mjs.

@vercel

vercel Bot commented Aug 29, 2026

Copy link
Copy Markdown

Deployment failed for project sockguard-website with the following error:

Resource is limited - try again in 24 hours (more than 100, code: "api-deployments-free-per-day").

Learn More: https://vercel.com/codeswhat?upgradeToPro=build-rate-limit

@coderabbitai

coderabbitai Bot commented Aug 29, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 37 minutes.

Check out review usage here.

View limit details

Limit 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.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Team

Run ID: 053bbaa7-39d6-4a8f-98a2-4cb9e8026a52

📥 Commits

Reviewing files that changed from the base of the PR and between 39cf4e3 and 2c6d6bf.

⛔ Files ignored due to path filters (1)
  • CHANGELOG.md is excluded by !CHANGELOG.md
📒 Files selected for processing (17)
  • app/internal/filter/container_archive.go
  • app/internal/filter/libpod_unscopeable_writes.go
  • app/internal/filter/libpod_unscopeable_writes_test.go
  • app/internal/ownership/commit.go
  • app/internal/ownership/commit_test.go
  • app/internal/ownership/identifier_collection_test.go
  • app/internal/ownership/libpod_coverage_test.go
  • app/internal/ownership/libpod_paths.go
  • app/internal/ownership/middleware.go
  • app/internal/ownership/middleware_test.go
  • app/internal/ownership/paths.go
  • app/internal/ownership/system_data_usage.go
  • app/internal/visibility/libpod_events.go
  • app/internal/visibility/podman_events.go
  • app/internal/visibility/podman_events_test.go
  • docs/content/docs/podman.mdx
  • docs/content/docs/security.mdx

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…-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
@scttbnsn
scttbnsn marked this pull request as ready for review September 3, 2026 21:00
@vercel

vercel Bot commented Sep 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
sockguard-website Ready Ready Preview Sep 3, 2026 10:30pm UTC

@biggest-littlest biggest-littlest left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rebuilt on dev/v2.0 as a stacked chain, CI green, adversarial review findings folded in on the branch.

@ALARGECOMPANY ALARGECOMPANY left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rebuilt on dev/v2.0 as a stacked chain, CI green, adversarial review findings folded in on the branch.

@scttbnsn
scttbnsn merged commit 494bfc0 into dev/v2.0 Sep 3, 2026
64 of 65 checks passed
@scttbnsn
scttbnsn deleted the scttbnsn/ownership-fail-closed branch September 3, 2026 22:41
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.

3 participants