authorize-docker-requests: match paths without the API version prefix (fixes the live-daemon denial) - #36
Open
phoenix-server wants to merge 1 commit into
Open
authorize-docker-requests: match paths without the API version prefix (fixes the live-daemon denial)#36phoenix-server wants to merge 1 commit into
phoenix-server wants to merge 1 commit into
Conversation
Deploying agentic-schematics with
|
| Latest commit: |
999e71d
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://efbb9bf2.agentic-schematics.pages.dev |
| Branch Preview URL: | https://fix-authorize-docker-request.agentic-schematics.pages.dev |
…#35) The plugin's PathPlain is the raw request path, version prefix included (main.go makeInput: "PathPlain": u.Path), so every rule keyed on that field matched nothing on a live daemon: the R-17 equality grants and the startswith rules behind lifecycle, container delete and path-named network/volume calls. The sandbox could not create, start, stop or delete anything for its own project. It failed closed, which is why it looked secure rather than broken, and the probe table agreed because its inputs were built from the documented shape - which was itself wrong. New requirement R-19: path matching is version-independent. The policy derives `path` (PathPlain with one optional /v<major>[.<minor>] prefix removed, and no .. segment) and `path_segments` from it; PathArr is no longer read. An unversioned client keeps working, /_ping is untouched, a double prefix strips once, and a traversal path matches nothing. Evidence, in the plugin's real input shape (Path/PathPlain versioned, PathArr split from that, Query parsed), 78 rows on OPA 0.60.0, 1.3.0 and 1.7.1: the 0.4.0 policy decided wrongly on 26 rows - all legitimate requests denied - and this policy decides all 78 as specified, with identical decisions when the table is run without a version prefix and with v1.43. Docs: the Rego input contract corrected in SCHEMATIC.md, agent.rego.schema and modules/opa-policy.md, with input.Query documented and the derivation quoted; A-15 now requires the real shape and says a table alone is not enough; Limitations records a table is only as real as its inputs, and that a project name which is also an API path segment widens the segment match. Q-4 answered, and the reload procedure was wrong twice: disabling a plugin a running daemon references is fatal to the daemon (Error validating authorization plugin ... not found), and the bounce was never needed - the plugin reads the policy file on every request, so the deployed file is the live policy. The script now replaces the file by temp-and-rename (a missing policy file is the plugin's one fail-open path), touches no plugin state, and is verified through the decision log's config_hash. Q-5 answered yes: a snap daemon does bind host P-6 into the plugin at /opa. Closes #35
phoenix-server
force-pushed
the
fix/authorize-docker-requests-versioned-path
branch
from
September 17, 2026 20:32
1e5e7d3 to
999e71d
Compare
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.
Follow-up to #19 — it fixes the policy #19 hardens. #19 merged (squash
fd1d6f2) while this was being written, so this branch is rebased ontomainand carries exactly one commit; the tree it was cut from and that squash are identical.Closes #35.
The defect
The plugin builds its Rego input from the raw request URL —
main.go'smakeInput:Nothing strips the API version prefix (
-skip-pingonly bypassesHEAD /_ping). SoPathPlainis/v1.56/containers/create, and every rule the hardening keyed on that field matched nothing on a live daemon:/containers/create,/networks/create,/volumes/create,/build,/session,/images/create;startswithrules for lifecycle actions, container deletion and path-named network/volume calls.The result on a real host: the sandbox could not create a container, volume or network for its own project, and could not start, stop or delete one. It failed closed, which is why it looked secure rather than broken — a sandbox that can do nothing looks like a sandbox that is properly restricted.
The 0.4.0 probe table could not catch it: its inputs were built from the documented shape, and the documented shape was the fiction. A table agrees with whatever model of the input it is fed.
The fix
New requirement R-19: path matching must be version-independent. The policy derives one path and matches everything against it:
Anchored and single, so
/v1.56/containers/createand/containers/create(a client that omits the version) both become/containers/create;/_pingis untouched;/v1.56/v1.56/containers/createbecomes/v1.56/containers/create, which is no grant.PathArris no longer read at all — the whole-segment matches usepath_segments, so the version element cannot reach a match, and neither can a query string.Deriving in the policy was chosen over rebuilding from
PathArrbecause rebuilding mishandles a path with no version (/_pingwould lose a real segment) and needs the same guard anyway. The..guard is new: the daemon cleans the path before routing while the plugin authorizes the raw one, so no rule should be reachable through a traversal.Evidence — the real input shape
PathandPathPlaincarry the version prefix,PathArris split from that,Queryis parsed (Path/PathPlain=/v1.56/…, as on the host this was found on). 78 rows, three engines:The 26 rows the 0.4.0 policy got wrong were all legitimate requests denied — creates and their benign variants (R15.19–R15.24, R15.26, R15.31, R15.32, R15.34),
/session, volume and network creates, project deletes, lifecycle actions,/build, pull, path-named network and volume operations. The security rows still denied, which is the shape of this defect: the hardening's gate held, its grants were gone.Same table, same policy, different prefix — all 78 rows as expected with no version prefix and with
v1.43, i.e. the decisions do not depend on the version. That is the property R-19 asserts.Documented input contract, corrected
makeInputdirectly, shows"PathPlain": "/v1.47/containers/create"withQuery, and states thatPathPlaincarries the prefix — with the derivation, and why a version-less table proves nothing.skeleton/agent.rego.schema: the input table (plusinput.Query, which was never documented), the derivation, and the two shape errors the table now exists to catch — the version-less one (30 rows wrongly allowed, which is what 0.4.0 shipped) and the real one (26 rows wrongly denied, which is issue authorize-docker-requests: PathPlain carries the API version, so every equality-keyed rule is dead on a live daemon #35).modules/opa-policy.md: the same corrections in the module's input list, R-19 in its summary, and a limitation that a probe table is only as real as its inputs.containers) widens the whole-segment match.The reload procedure was wrong twice
Q-4 is answered by measurement, and the answer changes the procedure:
level=fatal msg="Error validating authorization plugin" error="plugin \"…\" not found", and dockerd exits. In that statedocker plugin enablecannot help, because it needs a running daemon — recovery is to remove the reference from the live configuration file (P-13), start the daemon (unrestricted for the moment), enable the plugin, and put the reference back.scripts/reload-opa-policy.shno longer touches plugin state.evaluatePolicyFilereads the policy file on every request (os.ReadFile(p.policyFile)inside the evaluation), so the deployed file is the live policy and a change takes effect on the next API call.install -Dover the live path could open the daemon: a missing policy file is the plugin's one fail-open path (OPA policy file %s does not exist, failing open and allowing request). The file is now replaced by install-to-temp thenmv -f, and the step is verified through the decision log'sconfig_hash(the sha256 of the bytes the plugin actually evaluated) rather than by looking at the file.P-6into the plugin at/opa(verified by the operator on the snap host).docker plugin inspectshows the argument it runs with (-policy-file /opa/authz/agent.rego) and the mount that carries it (/etc/docker -> /opa), and mapping the first through the second gives the host file.scripts/reload-opa-policy.shuses that when neitherPOLICY_DSTnorPOLICY_DIRis set, and--discoverprints it read-only. Verified against a running plugin: it prints/etc/docker/authz/agent.rego. A policy written to a directory the plugin does not mount installs perfectly and is read by nobody, so this check is part of the procedure now.Verification
bash scripts/validate-catalog.sh→catalog ok: 19 entries, featured=[…], 18 specs, 21 pins verified, exit 0.opa checkunder the enginev0.10embeds (OPA 1.3.0) on the delivered, substituted policy.--rollback, rollback without a previous file (exit 1), a placeholder-bearing source (exit 1),POLICY_DIRandPOLICY_DSToverrides, and--discover(against the live plugin, and against a non-existent plugin name, exit 1). The sha256 the script prints for the deployed file matches the policy the probe table was run against.SCHEMATIC.md+239/-51,scripts/reload-opa-policy.sh+155/-65,modules/policy-reload.md+94/-41,skeleton/agent.rego+61/-23,skeleton/agent.rego.schema+61/-9,modules/opa-policy.md+26/-8.Deliberately not in this PR
sha256:d50c65088c7b332889b2603b1fecfd36eeade312a331fce2bca8d2ba691db1cd) was deployed over the plugin's own policy file by temp-file-and-rename, afteropa checkunder 1.3.0 and the leftover-token check. The plugin then served exactly those bytes: its decision log'sconfig_hashequals the file's sha256 — the same mechanism that reported the previous file (1db27897…) before the swap, so the two are distinguishable — and nine probes in the plugin's real input shape decided as required, including a project create that the 0.4.0 policy denied on that daemon. That is the A-11/A-16 class of evidence a table cannot produce; the remaining acceptance tests run on that host and are the operator's to record.POST /sessionstill rests on the client/daemon protocol, not on a live BuildKit run (unchanged from authorize-docker-requests: verification pass — the v0.2.1 policy loaded on no installable plugin #19, markedinferred:)...guard is a policy-side answer to a daemon/plugin mismatch (moby cleans the path for routing, the plugin authorizes the raw one). It closes the reachability this policy could otherwise have through a traversal; it is not a claim that moby's routing and the plugin's view agree in general.P-3colliding with an API path segment is documented rather than prevented — a project literally namedcontainersshares its segment with the API's own; nothing in the policy can tell them apart.improve-docker-securityis left alone, and its D-3 pin is now stale — to file as its own issue. That package (catalogue-featured) composes this one and pins D-3 atauthorize-docker-requestsv0.2.1 (81721d8f). v0.2.1 is the version whose policy loaded on no installable plugin, so the composition's R-2/A-2 path has never been covered by a policy that works; this PR moves the package to 0.5.0, two versions on. The pin's checksum still validates against the file at that commit, which is exactly why the catalogue check does not catch it: a valid pin can point at a broken version. Re-pinning D-3 (and re-running that package's A-2) belongs in its own issue and PR, per the repository's rule that a change starts with an issue.