Skip to content

security: validate device command payloads against the sealed hierarchy - #14

Open
IbraTech04 wants to merge 1 commit into
minbar-featuresfrom
sec/unit10-validate-command-payloads
Open

IbraTech04 wants to merge 1 commit into
minbar-featuresfrom
sec/unit10-validate-command-payloads

Conversation

@IbraTech04

Copy link
Copy Markdown
Collaborator

The problem

IssueCommandRequest.payload() is a raw JsonNode. The dispatcher stringified it and handed it to the device verbatim — the sealed CommandPayload hierarchy was never deserialized on the only path that issues commands. CommandPayload's own javadoc admitted it:

* The dispatcher never deserializes the payload into a {@link CommandPayload}; it stores
* the raw JSON and hands it to the agent so classification has to hang off the wire
* string rather than the payload type.

So LogsTailPayload's @Min(1) @Max(500) bound — and every other constraint in that hierarchy — was dead code that merely looked authoritative. An operator holding board:command:inspect could send {"kind":"logs.tail","payload":{"lines":999999999,"anything":"..."}} and it reached the physical kiosk unvalidated, with every bound delegated to agent code that does not live in this repository and cannot be audited from here.

This is not RCE from what is visible — every payload record is zero-arg except LogsTailPayload.lines. It is flagged because validation that exists and does nothing is how this becomes a real hole the first time someone adds a payload field carrying a path or a URL.

The fix

CommandPayloadCodec binds the wire JSON to the subtype selected by the command kind, then:

  • rejects a payload whose own discriminator contradicts the wire kind;
  • runs Jakarta Bean Validation on the bound record;
  • rejects unknown properties via a dedicated ObjectReader — the shared ObjectMapper has FAIL_ON_UNKNOWN_PROPERTIES off, which is the right default for lenient DTOs and exactly wrong for something bound for a physical device.

The dispatcher now stores and forwards the re-serialized validated object, not what the client sent, so anything tacked on beyond the record's components is dropped rather than relayed.

The sealed interface and its closed Id.NAME subtype registry are unchanged — that anti-gadget property is deliberate and stays.

Verification

CommandPayloadCodecTest   tests=7  failures=0  errors=0
---
Tests run: 582, Failures: 0, Errors: 0, Skipped: 0     BUILD SUCCESS

Covers the exact audit payload (lines: 999999999) now rejected with lines must be between 1 and 500, both boundaries (0/501 rejected, 1/500 accepted), unknown properties, a contradicting discriminator, non-object payloads, and that zero-arg commands still accept absent/empty/null payloads.

Verified by test rather than live curl: issuing a real command needs a connected Pi agent that does not exist on this machine, and the dev DB has no enrolled devices.

Preserved

onAck/onProgress/onResult still scope every inbound frame to session.getDeviceId() via loadOwned, so a rogue agent cannot forge results for another device's commands. CommandAuthorizer's risk-level gating is untouched.

🤖 Generated with Claude Code

https://claude.ai/code/session_011xQusSt39vP3c82gAUXRCm

IssueCommandRequest.payload() is a raw JsonNode. The dispatcher
stringified it and handed it to the device verbatim, so the sealed
CommandPayload records were never deserialized on the only path that
issues commands -- LogsTailPayload's 1..500 bound, and every other
constraint in that hierarchy, was dead code. CommandPayload's own
javadoc admitted it. An operator holding board:command:inspect could
send {"kind":"logs.tail","payload":{"lines":999999999}} and it reached
the kiosk unvalidated, with every bound delegated to agent code that
does not live in this repository and cannot be audited from here.

New CommandPayloadCodec binds the wire JSON to the subtype selected by
the command kind, rejects a payload whose discriminator contradicts
that kind, runs bean validation, and rejects unknown properties via a
dedicated reader -- the shared ObjectMapper has FAIL_ON_UNKNOWN_PROPERTIES
off, which is right for lenient DTOs and wrong for something bound for
a physical device. The dispatcher stores and forwards the re-serialized
validated object rather than what the client sent, so anything tacked
on beyond the record's components is dropped.

Not exploitable as RCE from what is visible -- every payload record is
zero-arg except LogsTailPayload.lines -- but the validation looked
authoritative while doing nothing, which is how it becomes a real hole
the first time a payload field carries a path or a URL.

The sealed interface and its closed Id.NAME subtype registry are
unchanged; that anti-gadget property is deliberate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xQusSt39vP3c82gAUXRCm
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

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.

1 participant