Files
cameleer-server/.claude/rules/security.md
hsiegeln 46a12f8e43 fix(sse): pin SSE signing mapper to CameleerJson, add null-payload canary test
Agent team flagged two byte-equivalence drift risks: NON_NULL filter
divergence and Instant serializer divergence between plain ObjectMapper
(server) and CameleerJson.mapper() (agent). Switch SsePayloadSigner and
SseConnectionManager to CameleerJson.mapper() so both sides share the
exact canonical form. Add a regression canary that asserts payloads
containing null values cannot verify — protects future emit sites from
introducing fragility.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 15:24:35 +02:00

3.0 KiB

Security invariants

Agent self-service endpoint ownership

Every endpoint under /api/v1/agents/{id}/... that is intended for agent self-service must enforce AgentOwnershipGuard.requireAgentOwnership(id, httpRequest) as its FIRST statement, before any registry lookup, audit log, or other side effect.

Currently enforced on: SSE events, heartbeat, deregister, command-ack. Currently NOT enforced (out of scope): register (bootstrap-token-gated), refresh, replayExchange. Adding the guard there is a follow-up.

RBAC revocation invariant

Any code path that mutates the effective role set of one or more users — including:

  • user_roles (direct role assignment)
  • user_groups (group membership)
  • group_roles (group's role list)
  • Role deletion
  • User deletion
  • Password reset

— MUST bump users.token_revoked_before for each affected user (use Instant.now().plusMillis(1) to guard against same-millisecond races with token issuance, since JWT iat is millisecond-quantized).

UiAuthController.refresh and JwtAuthenticationFilter.tryInternalToken both consult users.token_revoked_before; without the bump on RBAC mutation, refresh- token role-retention privesc is possible (Vuln 2 of the 2026-04-29 review).

ClaimMappingAdminController mutations are EXEMPT — they affect role assignment at the next OIDC login (via OidcAuthController.applyClaimMappings), not currently- issued tokens.

SSE command signing

Every command emitted via SseConnectionManager.onCommandReady is signed with Ed25519 by SsePayloadSigner. The signer THROWS on null/empty/blank/non-object input — there is no silent unsigned fallback.

Emit-site contract: build the payload via CameleerJson.mapper().writeValueAsString(map) — both SsePayloadSigner and the agent's verifier use this exact mapper. Emit sites MUST NOT include null values (CameleerJson drops them via NON_NULL on output) and MUST pre-format any java.time.Instant values as ISO-8601 strings (CameleerJson serializes Instant as ISO-8601, not epoch-ms). The rejectsPayloadsContainingNullValues test in SseCommandSigningContractTest is the regression canary for the null-value drift risk. The agent verifies by parsing the wire JSON, removing the signature field, re-serializing, and Ed25519-verifying against the resulting bytes — this requires byte-equivalence between the server-signed bytes and the agent-reconstructed bytes.

The requireSignedCommands capability bit on AgentInfo (set from AgentRegistrationRequest) is hard-enforced: AgentCommandController refuses (409 Conflict) any single-target or group command sent to an agent that has not advertised requireSignedCommands=true. The broadcast path (POST /api/v1/agents/commands) is exempt — blocking an ops broadcast because one old agent is still in the fleet is too disruptive; signed payloads are silently ignored by pre-verification agents until they upgrade. E4 warn + metric (recordCommandToNonCapableAgent) fire for all non-capable targets even when the 409 guard triggers, giving operators observability.