Skip to content

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
mainfrom
fix/authorize-docker-requests-versioned-path
Open

phoenix-server wants to merge 1 commit into
mainfrom
fix/authorize-docker-requests-versioned-path

Conversation

@phoenix-server

@phoenix-server phoenix-server commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator

Follow-up to #19 — it fixes the policy #19 hardens. #19 merged (squash fd1d6f2) while this was being written, so this branch is rebased onto main and 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's makeInput:

"PathPlain":  u.Path,                          // raw path: version prefix, no query
"PathArr":    strings.Split(u.Path, "/"),

Nothing strips the API version prefix (-skip-ping only bypasses HEAD /_ping). So PathPlain is /v1.56/containers/create, and every rule the hardening keyed on that field matched nothing on a live daemon:

  • the equality grants (R-17) — /containers/create, /networks/create, /volumes/create, /build, /session, /images/create;
  • the startswith rules 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:

path := p if {
        p := regex.replace(object.get(input, "PathPlain", ""), "^/v[0-9]+(\\.[0-9]+)?", "")
        not traversal(p)
}
path_segments := split(path, "/")

Anchored and single, so /v1.56/containers/create and /containers/create (a client that omits the version) both become /containers/create; /_ping is untouched; /v1.56/v1.56/containers/create becomes /v1.56/containers/create, which is no grant. PathArr is no longer read at all — the whole-segment matches use path_segments, so the version element cannot reach a match, and neither can a query string.

Deriving in the policy was chosen over rebuilding from PathArr because rebuilding mishandles a path with no version (/_ping would 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

Path and PathPlain carry the version prefix, PathArr is split from that, Query is parsed (Path/PathPlain = /v1.56/…, as on the host this was found on). 78 rows, three engines:

opa versions: 0.60.0 -> Version: 0.60.0; 1.3.0 -> Version: 1.3.0; 1.7.1 -> Version: 1.7.1
input shape: real — Path/PathPlain carry the version prefix (API_VERSION=v1.56)

before (0.4.0 policy):  78 probes | engines 3 | ROWS DEVIATE FROM EXPECTATION   (26 rows)
after  (this commit):   78 probes | engines 3 | ALL ROWS AS EXPECTED

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

  • SCHEMATIC.md's Rego input schema: quotes makeInput directly, shows "PathPlain": "/v1.47/containers/create" with Query, and states that PathPlain carries the prefix — with the derivation, and why a version-less table proves nothing.
  • skeleton/agent.rego.schema: the input table (plus input.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.
  • A-15 now requires the real shape, requires the table to be run with and without a prefix, and states plainly that this test alone is not enough — authorize-docker-requests: PathPlain carries the API version, so every equality-keyed rule is dead on a live daemon #35 is the proof.
  • Limitations records that a table can only find a disagreement it models, and that a project name which is also an API path segment (the tested host uses containers) widens the whole-segment match.

The reload procedure was wrong twice

Q-4 is answered by measurement, and the answer changes the procedure:

  • Disabling a plugin the running daemon references is fatal: level=fatal msg="Error validating authorization plugin" error="plugin \"…\" not found", and dockerd exits. In that state docker plugin enable cannot 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.sh no longer touches plugin state.
  • The bounce was never needed. evaluatePolicyFile reads 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.
  • The old script's install -D over 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 then mv -f, and the step is verified through the decision log's config_hash (the sha256 of the bytes the plugin actually evaluated) rather than by looking at the file.
  • Q-5 is answered too: a snap-installed daemon does bind host P-6 into the plugin at /opa (verified by the operator on the snap host).
  • P-7 no longer has to be guessed. The installed plugin reports its own policy path: docker plugin inspect shows 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.sh uses that when neither POLICY_DST nor POLICY_DIR is set, and --discover prints 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.shcatalog ok: 19 entries, featured=[…], 18 specs, 21 pins verified, exit 0.
  • opa check under the engine v0.10 embeds (OPA 1.3.0) on the delivered, substituted policy.
  • The script's paths were exercised: deploy, --rollback, rollback without a previous file (exit 1), a placeholder-bearing source (exit 1), POLICY_DIR and POLICY_DST overrides, 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.
  • Diffstat: 6 files, +636/-197 — 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

  • No live run by the author — the operator's pass on their own daemon is the live evidence. The substituted policy (sha256:d50c65088c7b332889b2603b1fecfd36eeade312a331fce2bca8d2ba691db1cd) was deployed over the plugin's own policy file by temp-file-and-rename, after opa check under 1.3.0 and the leftover-token check. The plugin then served exactly those bytes: its decision log's config_hash equals 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 /session still 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, marked inferred:).
  • The .. 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-3 colliding with an API path segment is documented rather than prevented — a project literally named containers shares its segment with the API's own; nothing in the policy can tell them apart.
  • improve-docker-security is 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 at authorize-docker-requests v0.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.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 17, 2026

Copy link
Copy Markdown

Deploying agentic-schematics with  Cloudflare Pages  Cloudflare Pages

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

View logs

…#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
phoenix-server force-pushed the fix/authorize-docker-requests-versioned-path branch from 1e5e7d3 to 999e71d Compare September 17, 2026 20:32
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.

authorize-docker-requests: PathPlain carries the API version, so every equality-keyed rule is dead on a live daemon

1 participant