Skip to content

fix: enforce --readonly in the router for every write (#75) - #77

Merged
abienkowski merged 4 commits into
mainfrom
fix/readonly-router-75
Oct 12, 2026
Merged

abienkowski merged 4 commits into
mainfrom
fix/readonly-router-75

Conversation

@abienkowski

Copy link
Copy Markdown
Collaborator

Description

Closes #75.

--readonly only reached the middleware chain, and the chain runs only on container create. In read-only mode the proxy therefore still forwarded container lifecycle actions (start, stop, restart, kill, wait, pause, unpause), DELETE /containers/{name} and image pulls, although the README promised that read-only mode denies POST/PUT/DELETE/PATCH.

What changes

  • The router enforces read-only mode. Each router gains a read-only setting (Go NewRouter(m).WithReadonly(b), Rust Router::new(m).with_readonly(b), TS new Router(m).withReadonly(b)), wired from --readonly. Its first check denies every method other than GET and HEAD, on every path, with the existing message read-only mode: <METHOD> requests are not allowed. Path shape (version prefix, %, //) cannot get around it.
  • ReadonlyGate is removed in all three: it would be unreachable, as ExecGate was (refactor: remove the unreachable ExecGate; refresh docs and counts (#55) #74). The chain constructors lose their readonly parameter. The docs now say "4 validation gates + 1 config mutator".
  • Spec (spec/router.qnt): a READONLY constant, the property readonlyAllowsOnlyReads, and a router_readonly instance with rows for stop, delete and create (denied) and GET/HEAD (allowed). The four existing instances get READONLY = false. make test-spec runs router_readonly with --match='^readonly.*Test$', because the shared write rows are denied when the flag is on (documented in spec/README.md).
  • Tests: the same table in all three (TestRouteReadonly / test_route_readonly / "denies every write in read-only mode"): 10 read-only rows plus a flag-off row; a handler test per language that a read-only DELETE gets 403 and never reaches the transport. Integration: a second compose service proxy-readonly (--readonly), with 5 checks: /_ping 200, and stop, delete, create and pull each 403 with the exact read-only message.
  • Docs: README, AGENTS.md, CONTRIBUTING, deploy/test.sh, spec/README.md; counts recounted.

Found in review, not fixed here: #76 (P0)

Read-only mode still allows GET, and a websocket attach is a GET. Through the Go proxy, GET /containers/{name}/attach/ws?stdin=1&stream=1 with Upgrade: websocket is upgraded (101) and can write to the container's stdin, with or without --readonly. I reproduced it against Docker 29.4.0. Rust relays the 101 but the connection breaks; TS times out; neither delivered stdin. This predates this PR and affects every mode, so it is tracked in #76 and noted in the README.

Review notes

  • Release: merging cuts v0.3.8 (patch, as agreed: it restores documented behaviour). Simulated squash: bump=patch tag=v0.3.8.
  • Deployments using --readonly will now get 403 for lifecycle actions, DELETE and pulls, as documented.

Verification

  • Before the fix (d43c26f, tests only): the new tests do not compile (Go h.router.WithReadonly undefined; TS Property 'withReadonly' does not exist on type 'Router').
  • The integration checks catch the bug: main's Go code with this branch's deploy/test.sh and compose file:
    FAIL: POST /containers/*/stop -> 403 (read-only) (expected 403 with "read-only mode: POST requests are not allowed", got 404: {"message":"No such container: no-such-container"})
    FAIL: DELETE /containers/* -> 403 (read-only) (expected 403 with "read-only mode: DELETE requests are not allowed", got 404: {"message":"No such container: no-such-container"})
    FAIL: POST /images/create?fromImage=chainsafe/lodestar -> 403 (read-only) (expected 403 with "read-only mode: POST requests are not allowed", got 403: empty request body)
    47 PASSED, 3 FAILED
    
  • Spec mutation: router_readonly with READONLY = false fails readonlySoundTest, readonlyLifecycleStopDeniedTest, readonlyDeleteDeniedTest and readonlyCreateDeniedTest; the two allow rows pass.
  • This branch:
    • make test-all: Go ok (108), Rust 140 passed, TS 157 tests, 156 pass, 1 skipped.
    • make lint-all, make typecheck, make test-spec (7 runs), make verify ([ok] No violation found, both modules) pass.
    • Integration: Go, Rust and TS each ALL 50 TESTS PASSED; socket suite ALL 15 TESTS PASSED.

Type of change

  • Bug fix
  • New feature
  • Breaking change
  • Documentation update

Implementation(s) changed

  • Go
  • Rust
  • TypeScript
  • Quint specification
  • CI / infrastructure

Testing

  • Unit tests pass (make test-all)
  • Integration tests pass (50 per implementation; socket suite 15)
  • Quint verification passes (make verify, make test-spec)
  • New tests added for the change

Checklist

  • I have read CONTRIBUTING.md
  • My code follows the project's coding style
  • I have updated documentation as needed

@abienkowski abienkowski added Priority: P1 Added to issues and PRs relating to a high severity bugs. Type: Bug Added to issues and PRs if they are addressing a bug labels Oct 12, 2026
@abienkowski
abienkowski merged commit 2bdc552 into main Oct 12, 2026
7 checks passed
@abienkowski
abienkowski deleted the fix/readonly-router-75 branch October 12, 2026 13:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Priority: P1 Added to issues and PRs relating to a high severity bugs. Type: Bug Added to issues and PRs if they are addressing a bug

Projects

None yet

Development

Successfully merging this pull request may close these issues.

--readonly only blocks container create; lifecycle actions and DELETE are still forwarded

1 participant