Skip to content

fix(rbac): close four anonymous surfaces from the GA review (G1, G2, G3) - #768

Merged
remyluslosius merged 1 commit into
mainfrom
fix/g-cluster-hardening
Jul 30, 2026
Merged

fix(rbac): close four anonymous surfaces from the GA review (G1, G2, G3)#768
remyluslosius merged 1 commit into
mainfrom
fix/g-cluster-hardening

Conversation

@remyluslosius

Copy link
Copy Markdown
Contributor

Four findings from the GA security review, each a different class, all reachable without credentials.

1. An unauthenticated write to the job queue

POST /diagnostics:enqueue-test-job called queue.Enqueue against the real queue with no check above it. Anyone who could reach the port could enqueue without limit: a denial-of-service primitive and a source of unbounded table growth.

Now requires system:config_write.

2. A license oracle on an /admin/ path

POST /admin/license:verify answered anonymously. Submit any JWT and learn whether it verifies, its tier, its features, its expiry, and whether it was signed with the previous key — which leaks key-rotation state.

Now requires system:read.

3. A side effect gated as a read

POST /hosts/{id}/connectivity:check gated on host:read, but it is not a read: it makes the server open an SSH or ICMP connection to a managed host on demand. A dedicated host:connectivity_check permission already existed in the registry and was wired to nothing.

Deliberate behaviour change: viewer and auditor hold host:read but not host:connectivity_check, so they lose this. ops_lead, security_admin and admin keep it.

4. Customer identity disclosed anonymously

GET /license returned customer_id, the paying organisation's identity, to any anonymous caller. Tier, status and features stay anonymous because a pre-login UI needs them. The identity does not qualify.

What I measured while scoping this

44 mutating routes carry no x-required-permission declaration. Before treating that as alarming: 30 of them do enforce, so it is a contract-accuracy problem rather than a hole, and 11 of the remaining 14 are correctly ungated (pre-session auth, or self-service on the caller's own identity). The genuinely exposed ones were the diagnostics routes above.

Worth recording explicitly: the AC-18 coverage test cannot see any of this. It verifies declared-implies-enforced and says nothing about routes that never declare. Closing that blind spot is a follow-up, and it needs the 30 declarations landed first or the test just fails on all of them.

Also: my ad-hoc scan initially reported license:verify as enforcing, because it followed a helper and matched any EnforcePermission. AC-18 does not have that weakness — it requires the specific constant the route declares, so following a helper that enforces something else does not match.

Tests

Four existing tests asserted the old ungated behaviour and now pass a credentialled caller. Caught locally against the test database from #767 rather than in CI, which is the first time this session that has been true.

system-rbac 1.2.0 -> 1.3.0, AC-21 (negative path). 116 specs, 116 passing. Full go test ./internal/... clean locally.

Four findings, each a different class, all reachable without credentials.

enqueue-test-job wrote to the REAL job queue with no authentication
(handlers.go, queue.Enqueue with no check above it). Anyone who could reach
the port could enqueue without limit: a denial-of-service primitive and a
source of unbounded table growth. Now requires system:config_write.

/admin/license:verify answered anonymously on an /admin/ path. Submit any JWT
and learn whether it verifies, its tier, its features, its expiry, and whether
it was signed with the PREVIOUS key. That last one leaks key-rotation state.
Now requires system:read.

connectivity:check gated on host:read, but it is not a read: it makes the
server open an SSH or ICMP connection to a managed host on demand. A dedicated
host:connectivity_check permission already existed in the registry and was
wired to nothing. Wired now. This is a deliberate behaviour change: viewer and
auditor hold host:read but not host:connectivity_check, so they lose it;
ops_lead, security_admin and admin keep it.

GET /license disclosed customer_id, the paying organisation's identity, to any
anonymous caller. Tier, status and features stay anonymous because a pre-login
UI needs them; the identity does not qualify.

While scoping this I measured the wider contract gap: 44 mutating routes carry
no x-required-permission declaration. 30 of them DO enforce, so that is a
contract accuracy problem rather than a hole, and 11 of the remaining 14 are
correctly ungated (pre-session auth, or self-service on the caller's own
identity). The genuinely exposed ones were the diagnostics routes above. Worth
recording that the AC-18 coverage test cannot see any of this: it verifies
declared-implies-enforced, and says nothing about routes that never declare.
Closing that is a follow-up, and it needs the 30 declarations first.

Also worth recording: my ad-hoc scan initially reported license:verify as
enforcing, because it followed a helper and matched any EnforcePermission.
AC-18 does not have that weakness; it requires the SPECIFIC constant the route
declares, so following a helper that enforces something else does not match.

Four existing tests asserted the old ungated behaviour and now pass a
credentialled caller. Caught locally against the test database from #767
rather than in CI, which is the first time this session that has been true.

Spec: system-rbac 1.2.0 -> 1.3.0, AC-21 (negative path). 116 specs, 116
passing. Full go test ./internal/... clean against the local test database.
@remyluslosius
remyluslosius merged commit 2c1ae66 into main Jul 30, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant