fix(cluster): reject truncated command payloads with MALFORMED_COMMAND#193
Open
RobinBol wants to merge 3 commits into
Open
fix(cluster): reject truncated command payloads with MALFORMED_COMMAND#193RobinBol wants to merge 3 commits into
RobinBol wants to merge 3 commits into
Conversation
The parser primitives in @athombv/data-types silently substitute zero
values when the source buffer is shorter than the declared field length.
For a ZCL command struct, this means a truncated payload parses into an
object where every numeric field is 0 and every bitmap field has all
bits clear - which for alarm-routing clusters (IAS Zone) happens to look
like "all clear" and for status-bearing response frames happens to look
like "SUCCESS".
This change adds a length precheck and try/catch around command arg
parsing in Cluster.handleFrame and BoundCluster.handleFrame. Truncated
payloads now throw ZCLError('MALFORMED_COMMAND'), which Endpoint.handleFrame
already converts into a proper default-response back to the sender.
Commands declared with encodeMissingFieldsBehavior: 'skip' (OTA) are
exempt from the strict length precheck because they intentionally allow
trailing fields to be omitted; for those, only the try/catch fallback
applies.
Contributor
There was a problem hiding this comment.
Pull request overview
This PR hardens inbound ZCL command argument parsing so truncated/unparseable command payloads are rejected with ZCLError('MALFORMED_COMMAND') instead of being silently parsed (often as all-zero “SUCCESS”/“all-clear” values). This leverages existing Endpoint.handleFrame behavior to automatically emit a proper ZCL Default Response without additional wiring.
Changes:
- Added a shared
parseCommandArgs()helper that performs a minimum-length precheck (when applicable) and converts parse-time exceptions intoMALFORMED_COMMAND. - Updated
Cluster.handleFrameandBoundCluster.handleFrameto use the new helper instead of callingcommand.args.fromBuffer(...)directly. - Added tests to ensure truncated IAS Zone frames don’t invoke handlers and do produce
MALFORMED_COMMANDDefault Responses (with regressions for well-formed frames).
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| test/testMalformedFrames.js | Adds regression + hardening tests verifying truncated payloads yield MALFORMED_COMMAND and do not call handlers. |
| lib/util/parseCommandArgs.js | Introduces defensive argument parsing helper with length precheck + parse error normalization. |
| lib/Cluster.js | Routes inbound cluster command arg parsing through parseCommandArgs(). |
| lib/BoundCluster.js | Routes inbound bound-cluster command arg parsing through parseCommandArgs(). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Merged
5 tasks
nozols
approved these changes
May 21, 2026
Follow-up to the command-arg hardening. Addresses two bypasses that remained after the first pass: 1. Attribute reports (cmdId 0x0A reportAttributes) parse a raw payload into ZCLAttributeDataRecord entries, where each entry's value field was read via `DataType.fromBuffer` without bounds checking. For fixed-width types like map16 (IAS Zone `zoneStatus`, attribute id 0x0002), a missing or short value silently produced an all-bits-false bitmap that propagates to drivers as "all alarms clear". Same fail- open path as the command-arg case from PR #193, just routed through the attribute-report channel. Add a length precheck in `ZCLAttributeDataRecord.fromBuf` before calling the value's `fromBuffer`. On short input, throw `ZCLError('MALFORMED_COMMAND')` which `Endpoint.handleFrame` already converts to a proper default-response back to the sender. 2. The previous `encodeMissingFieldsBehavior: 'skip'` exemption in `parseCommandArgs` was too broad: it skipped *all* length checking for those commands, so an empty payload to e.g. OTA `queryNextImageRequest` parsed as all-zero fields. Tighten to require at least the first field's byte width, which preserves the legitimate "omit trailing optional fields" case while rejecting truncated leading-discriminator payloads. 5 new tests, including regression guards for well-formed reports and for OTA's omit-trailing-fields path. 86/86 tests pass (was 81 + 5).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Hardens command-argument and attribute-record parsing against truncated wire input. Without these checks,
@athombv/data-typessilently zero-fills missing bytes, producing a plausible-looking parse result that:zoneStatusChangeNotificationand attribute reports ofzoneStatus: reads aszoneStatus.alarm1 === false(i.e. "all clear" while truncated frames look indistinguishable from a real "no alarm" report at the consumer layer).status === 'SUCCESS'(because the zero value is what every ZCL status enum maps toSUCCESS).This is a robustness / defense-in-depth change. The framing is correctness-first: safety-critical cluster handlers should never observe args from a payload too short to populate them, and "downstream code cannot tell whether the device reported a valid
falsevalue or whether the parser invented it from missing bytes" is a trust-boundary problem worth closing.Changes
1. Command arg parsing - length precheck + try/catch
Cluster.handleFrame(Cluster.js:780) andBoundCluster.handleFrame(BoundCluster.js:312) now route command-arg parsing through a new helper,lib/util/parseCommandArgs.js:Math.abs(command.args.length)is rejected before the parser runs.octstrlength prefix overruns the remaining buffer) is caught and converted toMALFORMED_COMMANDinstead of leaking aRangeErroras a genericFAILURE.Endpoint.handleFramealready converts thrownZCLErrors into proper ZCL default-response frames back to the sender, so no surrounding wiring changes are needed.2. Attribute report (
reportAttributes) value-length validationZCLAttributeDataRecord.fromBufinzclFrames.jsnow validates that enough bytes remain for the value field before callingDataType.fromBuffer. Without this check, areportAttributesframe carrying a valid attribute header but a truncated value bypassed the command-arg precheck entirely (becausereportAttributesdeclares args as a raw buffer) and re-introduced the same silent zero-fill at the inner record layer. Concretely, azoneStatus(map16) attribute report missing its value bytes parsed as an all-bits-false bitmap, emittedattr.zoneStatus, and reached IAS-Zone drivers as "all alarms clear".On insufficient bytes, throws
ZCLError('MALFORMED_COMMAND'). Variable-length value fields are not covered by this check (see "Out of scope" below).3. Tighter
encodeMissingFieldsBehavior: 'skip'exemptionThe initial precheck fully exempted
'skip'commands (OTA) because they allow trailing fields to be omitted. That was broader than needed: every'skip'command in the codebase has a mandatory leading discriminator (status / fieldControl / payloadType) whose presence determines which subsequent fields are valid. Without that byte, the parse is meaningless.The exemption now requires at least the first field's byte width. This preserves the legitimate "omit trailing optional fields" path (e.g.
queryNextImageRequestwithouthardwareVersion) while rejecting empty / sub-leading-byte payloads.Test plan
All new tests written TDD-style (red first, then implementation).
zoneStatusChangeNotification, truncatedzoneEnrollResponse:MALFORMED_COMMANDdefault-response.zoneStatus(map16) value:attr.zoneStatusevent does not fire.MALFORMED_COMMANDdefault-response.zoneStatusreport still firesattr.zoneStatuswithalarm1 === true(regression).'skip'exemption - emptyqueryNextImageRequest:MALFORMED_COMMAND.queryNextImageRequestwithout trailinghardwareVersionstill parses successfully and reaches the handler with the expectedmanufacturerCode/imageType(regression - preserves existing OTA tolerance).Out of scope
This PR is consumer-side hardening. The deeper fix sits in
@athombv/data-typesand includes:uintFromBuf,enumFromBuf,dataFromBuf) failing on insufficient bytes instead of returning0/ a truncated buffer.bufferFromBuf) failing instead of returning a truncated buffer withisPartial = true(an undocumented flag no consumer checks - the source even contains a// TODO: FIXMEnext to it).Structconstructor rejecting prototype-chain keys likeconstructoras field names.Two of those (Bitmap defensive copy, Struct prototype-chain guard) are already in flight upstream at athombv/node-data-types#44. The
*FromBuftruncation behavior is the breaking change that needs a separate discussion (either astrictopt-in flag or a major-version flip).As a consequence, this PR does not close:
bufferFromBuf'sisPartialpath. Thetry/catchinparseCommandArgscatches nothing here because nothing throws.octstr-typed attributes) where the length prefix overruns the remaining buffer.These can be closed either upstream (cleanest) or by recursing parsed args downstream to reject any Buffer with
isPartial === true(less clean, but unblocks without coordination). Tracked separately.