From 684581434e41ceef10342e93abc4962d91b0d557 Mon Sep 17 00:00:00 2001 From: Eric Blankenhorn Date: Thu, 30 Apr 2026 17:10:00 -0500 Subject: [PATCH 01/32] Fix SUBSCRIBE Fixed-Header Flags Do Not Trigger Malformed Close (#486) --- src/mqtt_broker.c | 95 ++++++++++++++--- src/mqtt_packet.c | 58 +++++++++++ tests/test_mqtt_packet.c | 213 +++++++++++++++++++++++++++++++++++++++ wolfmqtt/mqtt_packet.h | 8 ++ 4 files changed, 358 insertions(+), 16 deletions(-) diff --git a/src/mqtt_broker.c b/src/mqtt_broker.c index 5859c1d42..49bfe219c 100644 --- a/src/mqtt_broker.c +++ b/src/mqtt_broker.c @@ -3243,7 +3243,8 @@ static int BrokerHandle_Publish(BrokerClient* bc, int rx_len, return rc; } - /* [MQTT-3.3.2-2] PUBLISH topic must not contain wildcard characters */ + /* [MQTT-3.3.2-2] PUBLISH topic must not contain wildcard characters. + * Return MALFORMED_DATA so dispatch closes the connection. */ if (pub.topic_name && pub.topic_name_len > 0) { word16 i; for (i = 0; i < pub.topic_name_len; i++) { @@ -3251,7 +3252,7 @@ static int BrokerHandle_Publish(BrokerClient* bc, int rx_len, WBLOG_ERR(broker, "broker: PUBLISH topic contains wildcard sock=%d", (int)bc->sock); - return MQTT_CODE_ERROR_BAD_ARG; + return MQTT_CODE_ERROR_MALFORMED_DATA; } } } @@ -3465,6 +3466,28 @@ static int BrokerHandle_PublishRec(BrokerClient* bc, int rx_len) return rc; } +/* [MQTT-2.2.2-2] / [MQTT-3.8.1-1] etc.: a malformed packet MUST cause the + * server to close the network connection. Mirrors the read-failure close + * path: publish will, honor session persistence, then remove the client. */ +static void BrokerClient_AbnormalClose(MqttBroker* broker, BrokerClient* bc) +{ + BrokerClient_PublishWill(broker, bc); + if (bc->clean_session) { + BrokerSubs_RemoveClient(broker, bc); + } + else { + BrokerSubs_OrphanClient(broker, bc); + } + BrokerClient_Remove(broker, bc); +} + +/* Decode-level errors that indicate a malformed packet on the wire. */ +static int BrokerRcIsMalformed(int rc) +{ + return (rc == MQTT_CODE_ERROR_MALFORMED_DATA || + rc == MQTT_CODE_ERROR_PACKET_TYPE); +} + /* -------------------------------------------------------------------------- */ /* Per-client processing (called from Step) */ /* -------------------------------------------------------------------------- */ @@ -3526,15 +3549,7 @@ static int BrokerClient_Process(MqttBroker* broker, BrokerClient* bc) } else if (rc < 0) { WBLOG_ERR(broker, "broker: read failed sock=%d rc=%d", (int)bc->sock, rc); - BrokerClient_PublishWill(broker, bc); /* abnormal disconnect */ - /* Session persistence: keep subs if clean_session=0 */ - if (bc->clean_session) { - BrokerSubs_RemoveClient(broker, bc); - } - else { - BrokerSubs_OrphanClient(broker, bc); - } - BrokerClient_Remove(broker, bc); + BrokerClient_AbnormalClose(broker, bc); return 0; } @@ -3549,6 +3564,24 @@ static int BrokerClient_Process(MqttBroker* broker, BrokerClient* bc) ((BrokerWsCtx*)bc->ws_ctx)->processing = 1; } #endif + /* [MQTT-2.2.2-2] Reject malformed fixed-header reserved flags. The + * per-type decoders also enforce this (see MqttDecode_FixedHeader), + * but PUBACK / PUBCOMP / PINGREQ / DISCONNECT are not run through a + * decoder here, so the broker enforces it directly before dispatch. */ + if (!MqttPacket_FixedHeaderFlagsValid(bc->rx_buf[0])) { + WBLOG_ERR(broker, + "broker: invalid fixed-header flags type=%u byte=0x%02X " + "sock=%d [MQTT-2.2.2-2]", + type, bc->rx_buf[0], (int)bc->sock); + if (bc->connected) { + BrokerClient_AbnormalClose(broker, bc); + } + else { + BrokerSubs_RemoveClient(broker, bc); + BrokerClient_Remove(broker, bc); + } + return 0; + } /* [MQTT-3.1.0-1] First packet must be CONNECT */ if (type != MQTT_PACKET_TYPE_CONNECT && !bc->connected) { WBLOG_ERR(broker, @@ -3581,31 +3614,61 @@ static int BrokerClient_Process(MqttBroker* broker, BrokerClient* bc) break; } case MQTT_PACKET_TYPE_PUBLISH: - (void)BrokerHandle_Publish(bc, rc, broker); + { + int p_rc = BrokerHandle_Publish(bc, rc, broker); + if (BrokerRcIsMalformed(p_rc)) { + BrokerClient_AbnormalClose(broker, bc); + return 0; + } break; + } case MQTT_PACKET_TYPE_PUBLISH_ACK: /* QoS 1 ack from subscriber - delivery complete */ break; case MQTT_PACKET_TYPE_PUBLISH_REC: + { /* QoS 2 step 2: subscriber sends PUBREC, broker * responds with PUBREL */ - (void)BrokerHandle_PublishRec(bc, rc); + int p_rc = BrokerHandle_PublishRec(bc, rc); + if (BrokerRcIsMalformed(p_rc)) { + BrokerClient_AbnormalClose(broker, bc); + return 0; + } break; + } case MQTT_PACKET_TYPE_PUBLISH_REL: + { /* QoS 2 step 3: publisher sends PUBREL, broker * responds with PUBCOMP */ - (void)BrokerHandle_PublishRel(bc, rc); + int p_rc = BrokerHandle_PublishRel(bc, rc); + if (BrokerRcIsMalformed(p_rc)) { + BrokerClient_AbnormalClose(broker, bc); + return 0; + } break; + } case MQTT_PACKET_TYPE_PUBLISH_COMP: /* QoS 2 step 4: subscriber sends PUBCOMP - delivery * complete */ break; case MQTT_PACKET_TYPE_SUBSCRIBE: - (void)BrokerHandle_Subscribe(bc, rc, broker); + { + int s_rc = BrokerHandle_Subscribe(bc, rc, broker); + if (BrokerRcIsMalformed(s_rc)) { + BrokerClient_AbnormalClose(broker, bc); + return 0; + } break; + } case MQTT_PACKET_TYPE_UNSUBSCRIBE: - (void)BrokerHandle_Unsubscribe(bc, rc, broker); + { + int u_rc = BrokerHandle_Unsubscribe(bc, rc, broker); + if (BrokerRcIsMalformed(u_rc)) { + BrokerClient_AbnormalClose(broker, bc); + return 0; + } break; + } case MQTT_PACKET_TYPE_PING_REQ: (void)BrokerSend_PingResp(bc); break; diff --git a/src/mqtt_packet.c b/src/mqtt_packet.c index 7f96d0814..86543b8c4 100644 --- a/src/mqtt_packet.c +++ b/src/mqtt_packet.c @@ -182,6 +182,59 @@ static int MqttEncode_FixedHeader(byte *tx_buf, int tx_buf_len, int remain_len, return header_len; } +/* [MQTT-2.2.2-1] Required fixed-header reserved-flag values per packet type. + * PUBLISH (type 3) carries DUP/QoS/RETAIN and is validated separately. */ +static int FixedHeaderFlagsExpected(byte type, byte *expected) +{ + switch (type) { + case MQTT_PACKET_TYPE_CONNECT: + case MQTT_PACKET_TYPE_CONNECT_ACK: + case MQTT_PACKET_TYPE_PUBLISH_ACK: + case MQTT_PACKET_TYPE_PUBLISH_REC: + case MQTT_PACKET_TYPE_PUBLISH_COMP: + case MQTT_PACKET_TYPE_SUBSCRIBE_ACK: + case MQTT_PACKET_TYPE_UNSUBSCRIBE_ACK: + case MQTT_PACKET_TYPE_PING_REQ: + case MQTT_PACKET_TYPE_PING_RESP: + case MQTT_PACKET_TYPE_DISCONNECT: + case MQTT_PACKET_TYPE_AUTH: + *expected = 0x0; + return 1; + case MQTT_PACKET_TYPE_PUBLISH_REL: + case MQTT_PACKET_TYPE_SUBSCRIBE: + case MQTT_PACKET_TYPE_UNSUBSCRIBE: + *expected = 0x2; + return 1; + default: + return 0; + } +} + +int MqttPacket_FixedHeaderFlagsValid(byte type_flags) +{ + byte type = (byte)MQTT_PACKET_TYPE_GET(type_flags); + byte flags = (byte)MQTT_PACKET_FLAGS_GET(type_flags); + byte expected; + + if (type == MQTT_PACKET_TYPE_PUBLISH) { + byte qos = (byte)MQTT_PACKET_FLAGS_GET_QOS(type_flags); + byte dup = (flags & MQTT_PACKET_FLAG_DUPLICATE) ? 1 : 0; + if (qos > MQTT_QOS_2) { + return 0; + } + if (qos == MQTT_QOS_0 && dup) { + return 0; + } + return 1; + } + if (FixedHeaderFlagsExpected(type, &expected)) { + return (flags == expected) ? 1 : 0; + } + /* Unknown/reserved type: this helper validates the flag nibble only. + * Callers are responsible for rejecting unknown packet types. */ + return 1; +} + static int MqttDecode_FixedHeader(byte *rx_buf, int rx_buf_len, int *remain_len, byte type, MqttQoS *p_qos, byte *p_retain, byte *p_duplicate) { @@ -199,6 +252,11 @@ static int MqttDecode_FixedHeader(byte *rx_buf, int rx_buf_len, int *remain_len, return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_PACKET_TYPE); } + /* [MQTT-2.2.2-2] Reject invalid fixed-header reserved flags. */ + if (!MqttPacket_FixedHeaderFlagsValid(header->type_flags)) { + return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_MALFORMED_DATA); + } + /* Extract header flags */ if (p_qos) { *p_qos = (MqttQoS)MQTT_PACKET_FLAGS_GET_QOS(header->type_flags); diff --git a/tests/test_mqtt_packet.c b/tests/test_mqtt_packet.c index 4d95976ed..ac517c6e6 100644 --- a/tests/test_mqtt_packet.c +++ b/tests/test_mqtt_packet.c @@ -2113,6 +2113,206 @@ TEST(decode_unsuback_malformed_remain_len_one) ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); } +/* ============================================================================ + * Fixed-header reserved-flag validation [MQTT-2.2.2-2] + * + * The first byte of every MQTT packet packs the type (high nibble) and a + * reserved-flag nibble. Most packet types fix that nibble to a single + * required value; PUBLISH carries DUP/QoS/RETAIN. Invalid values MUST be + * treated as malformed and cause the receiver to close the connection. + * + * MqttPacket_FixedHeaderFlagsValid is the single source of truth used by + * MqttDecode_FixedHeader (covers SUBSCRIBE, UNSUBSCRIBE, PUBREL, etc.) and + * by the broker dispatch (covers types with no decoder: PUBACK, PUBCOMP, + * PINGREQ, DISCONNECT). These tests pin both surfaces. + * ============================================================================ */ + +TEST(fixed_header_flags_valid_canonical_values) +{ + /* Canonical first-byte values for each fixed-flag packet type. */ + ASSERT_EQ(1, MqttPacket_FixedHeaderFlagsValid(0x10)); /* CONNECT */ + ASSERT_EQ(1, MqttPacket_FixedHeaderFlagsValid(0x20)); /* CONNACK */ + ASSERT_EQ(1, MqttPacket_FixedHeaderFlagsValid(0x40)); /* PUBACK */ + ASSERT_EQ(1, MqttPacket_FixedHeaderFlagsValid(0x50)); /* PUBREC */ + ASSERT_EQ(1, MqttPacket_FixedHeaderFlagsValid(0x62)); /* PUBREL */ + ASSERT_EQ(1, MqttPacket_FixedHeaderFlagsValid(0x70)); /* PUBCOMP */ + ASSERT_EQ(1, MqttPacket_FixedHeaderFlagsValid(0x82)); /* SUBSCRIBE */ + ASSERT_EQ(1, MqttPacket_FixedHeaderFlagsValid(0x90)); /* SUBACK */ + ASSERT_EQ(1, MqttPacket_FixedHeaderFlagsValid(0xA2)); /* UNSUBSCRIBE */ + ASSERT_EQ(1, MqttPacket_FixedHeaderFlagsValid(0xB0)); /* UNSUBACK */ + ASSERT_EQ(1, MqttPacket_FixedHeaderFlagsValid(0xC0)); /* PINGREQ */ + ASSERT_EQ(1, MqttPacket_FixedHeaderFlagsValid(0xD0)); /* PINGRESP */ + ASSERT_EQ(1, MqttPacket_FixedHeaderFlagsValid(0xE0)); /* DISCONNECT */ + ASSERT_EQ(1, MqttPacket_FixedHeaderFlagsValid(0xF0)); /* AUTH (v5) */ +} + +TEST(fixed_header_flags_valid_zero_required_rejects_nonzero) +{ + /* Types whose reserved nibble MUST be 0000. Each non-zero permutation + * is malformed. */ + ASSERT_EQ(0, MqttPacket_FixedHeaderFlagsValid(0x11)); /* CONNECT */ + ASSERT_EQ(0, MqttPacket_FixedHeaderFlagsValid(0x18)); + ASSERT_EQ(0, MqttPacket_FixedHeaderFlagsValid(0x21)); /* CONNACK */ + ASSERT_EQ(0, MqttPacket_FixedHeaderFlagsValid(0x42)); /* PUBACK */ + ASSERT_EQ(0, MqttPacket_FixedHeaderFlagsValid(0x52)); /* PUBREC */ + ASSERT_EQ(0, MqttPacket_FixedHeaderFlagsValid(0x72)); /* PUBCOMP */ + ASSERT_EQ(0, MqttPacket_FixedHeaderFlagsValid(0x91)); /* SUBACK */ + ASSERT_EQ(0, MqttPacket_FixedHeaderFlagsValid(0xB2)); /* UNSUBACK */ + ASSERT_EQ(0, MqttPacket_FixedHeaderFlagsValid(0xC1)); /* PINGREQ */ + ASSERT_EQ(0, MqttPacket_FixedHeaderFlagsValid(0xD1)); /* PINGRESP */ + ASSERT_EQ(0, MqttPacket_FixedHeaderFlagsValid(0xE2)); /* DISCONNECT */ + ASSERT_EQ(0, MqttPacket_FixedHeaderFlagsValid(0xFF)); /* AUTH */ +} + +TEST(fixed_header_flags_valid_two_required_rejects_other) +{ + /* [MQTT-3.6.1-1] PUBREL, [MQTT-3.8.1-1] SUBSCRIBE, [MQTT-3.10.1-1] + * UNSUBSCRIBE all require the low nibble = 0010. Any other value is + * malformed. */ + int v; + for (v = 0x60; v <= 0x6F; v++) { + if (v == 0x62) continue; + ASSERT_EQ(0, MqttPacket_FixedHeaderFlagsValid((byte)v)); + } + for (v = 0x80; v <= 0x8F; v++) { + if (v == 0x82) continue; + ASSERT_EQ(0, MqttPacket_FixedHeaderFlagsValid((byte)v)); + } + for (v = 0xA0; v <= 0xAF; v++) { + if (v == 0xA2) continue; + ASSERT_EQ(0, MqttPacket_FixedHeaderFlagsValid((byte)v)); + } +} + +TEST(fixed_header_flags_valid_publish_qos_and_dup) +{ + /* PUBLISH carries DUP/QoS/RETAIN: 0x3X where X = DUP|QoS|RETAIN. */ + /* QoS 0..2 with DUP=0 are all legal regardless of RETAIN. */ + ASSERT_EQ(1, MqttPacket_FixedHeaderFlagsValid(0x30)); /* QoS0, no DUP */ + ASSERT_EQ(1, MqttPacket_FixedHeaderFlagsValid(0x31)); /* QoS0, RETAIN */ + ASSERT_EQ(1, MqttPacket_FixedHeaderFlagsValid(0x32)); /* QoS1 */ + ASSERT_EQ(1, MqttPacket_FixedHeaderFlagsValid(0x33)); /* QoS1, RETAIN */ + ASSERT_EQ(1, MqttPacket_FixedHeaderFlagsValid(0x34)); /* QoS2 */ + ASSERT_EQ(1, MqttPacket_FixedHeaderFlagsValid(0x35)); /* QoS2, RETAIN */ + ASSERT_EQ(1, MqttPacket_FixedHeaderFlagsValid(0x3A)); /* QoS1, DUP */ + ASSERT_EQ(1, MqttPacket_FixedHeaderFlagsValid(0x3C)); /* QoS2, DUP */ + + /* [MQTT-3.3.1-4] QoS = 3 (bits 1-2 = 11) is reserved/malformed. */ + ASSERT_EQ(0, MqttPacket_FixedHeaderFlagsValid(0x36)); + ASSERT_EQ(0, MqttPacket_FixedHeaderFlagsValid(0x37)); + ASSERT_EQ(0, MqttPacket_FixedHeaderFlagsValid(0x3E)); + ASSERT_EQ(0, MqttPacket_FixedHeaderFlagsValid(0x3F)); + + /* [MQTT-3.3.1-2] DUP MUST be 0 when QoS = 0. */ + ASSERT_EQ(0, MqttPacket_FixedHeaderFlagsValid(0x38)); /* QoS0, DUP=1 */ + ASSERT_EQ(0, MqttPacket_FixedHeaderFlagsValid(0x39)); /* QoS0, DUP, RET */ +} + +/* End-to-end: the SUBSCRIBE decoder rejects each invalid first-byte + * variant cited in the issue report (0x80, 0x81, 0x83) and the broker's + * dispatch uses the same helper, so both surfaces close the connection. */ +#ifdef WOLFMQTT_BROKER +TEST(decode_subscribe_invalid_fixed_header_flags) +{ + /* Body matches the valid SUBSCRIBE wire from decode_subscribe_v311_ + * single_topic; only the leading type+flags byte varies. */ + byte rx_buf[] = { + 0x82, 0x06, + 0x00, 0x01, + 0x00, 0x01, + 0x61, + 0x01 + }; + MqttSubscribe sub; + MqttTopic topic_arr[1]; + int rc; + byte invalid[] = { 0x80, 0x81, 0x83, 0x84, 0x86, 0x88, 0x8F }; + size_t i; + + for (i = 0; i < sizeof(invalid); i++) { + rx_buf[0] = invalid[i]; + XMEMSET(&sub, 0, sizeof(sub)); + XMEMSET(topic_arr, 0, sizeof(topic_arr)); + sub.topics = topic_arr; + rc = MqttDecode_Subscribe(rx_buf, (int)sizeof(rx_buf), &sub); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); + } +} + +TEST(decode_unsubscribe_invalid_fixed_header_flags) +{ + /* Wire: type|flags=0xA2, remaining=5, packet_id=0x0001, topic_len=0x0001, + * "a". Per [MQTT-3.10.1-1] only 0xA2 is legal. */ + byte rx_buf[] = { + 0xA2, 0x05, + 0x00, 0x01, + 0x00, 0x01, + 0x61 + }; + MqttUnsubscribe unsub; + MqttTopic topic_arr[1]; + int rc; + byte invalid[] = { 0xA0, 0xA1, 0xA3, 0xA4, 0xA6, 0xA8, 0xAF }; + size_t i; + + for (i = 0; i < sizeof(invalid); i++) { + rx_buf[0] = invalid[i]; + XMEMSET(&unsub, 0, sizeof(unsub)); + XMEMSET(topic_arr, 0, sizeof(topic_arr)); + unsub.topics = topic_arr; + rc = MqttDecode_Unsubscribe(rx_buf, (int)sizeof(rx_buf), &unsub); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); + } +} +#endif /* WOLFMQTT_BROKER */ + +TEST(decode_pubrel_invalid_fixed_header_flags) +{ + /* Wire: type|flags=0x62, remain=2, packet_id=0x0001. */ + byte rx_buf[] = { 0x62, 0x02, 0x00, 0x01 }; + MqttPublishResp resp; + int rc; + + rx_buf[0] = 0x60; /* PUBREL with reserved nibble = 0000 */ + XMEMSET(&resp, 0, sizeof(resp)); + rc = MqttDecode_PublishResp(rx_buf, (int)sizeof(rx_buf), + MQTT_PACKET_TYPE_PUBLISH_REL, &resp); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); + + rx_buf[0] = 0x63; + XMEMSET(&resp, 0, sizeof(resp)); + rc = MqttDecode_PublishResp(rx_buf, (int)sizeof(rx_buf), + MQTT_PACKET_TYPE_PUBLISH_REL, &resp); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); +} + +TEST(decode_publish_qos3_rejected) +{ + /* [MQTT-3.3.1-4] QoS 3 (reserved) MUST be treated as malformed. */ + byte buf[] = { 0x36, 7, + 0x00, 0x03, 'a', '/', 'b', + 'H', 'I' }; + MqttPublish pub; + int rc; + + XMEMSET(&pub, 0, sizeof(pub)); + rc = MqttDecode_Publish(buf, (int)sizeof(buf), &pub); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); +} + +TEST(decode_publish_qos0_with_dup_rejected) +{ + /* [MQTT-3.3.1-2] DUP MUST be 0 when QoS = 0. */ + byte buf[] = { 0x38, 5, + 0x00, 0x03, 'a', '/', 'b' }; + MqttPublish pub; + int rc; + + XMEMSET(&pub, 0, sizeof(pub)); + rc = MqttDecode_Publish(buf, (int)sizeof(buf), &pub); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); +} + /* ============================================================================ * MqttEncode/Decode_PublishResp v5 roundtrip * ============================================================================ */ @@ -2482,6 +2682,19 @@ void run_mqtt_packet_tests(void) RUN_TEST(decode_unsuback_malformed_remain_len_zero); RUN_TEST(decode_unsuback_malformed_remain_len_one); + /* Fixed-header reserved-flag validation [MQTT-2.2.2-2] */ + RUN_TEST(fixed_header_flags_valid_canonical_values); + RUN_TEST(fixed_header_flags_valid_zero_required_rejects_nonzero); + RUN_TEST(fixed_header_flags_valid_two_required_rejects_other); + RUN_TEST(fixed_header_flags_valid_publish_qos_and_dup); +#ifdef WOLFMQTT_BROKER + RUN_TEST(decode_subscribe_invalid_fixed_header_flags); + RUN_TEST(decode_unsubscribe_invalid_fixed_header_flags); +#endif + RUN_TEST(decode_pubrel_invalid_fixed_header_flags); + RUN_TEST(decode_publish_qos3_rejected); + RUN_TEST(decode_publish_qos0_with_dup_rejected); + #ifdef WOLFMQTT_V5 RUN_TEST(publish_resp_v5_success_with_props_roundtrip); RUN_TEST(publish_resp_v5_error_no_props_roundtrip); diff --git a/wolfmqtt/mqtt_packet.h b/wolfmqtt/mqtt_packet.h index 6fcd463e2..e464651a6 100644 --- a/wolfmqtt/mqtt_packet.h +++ b/wolfmqtt/mqtt_packet.h @@ -654,6 +654,14 @@ WOLFMQTT_API int MqttPacket_Write(struct _MqttClient *client, byte* tx_buf, WOLFMQTT_API int MqttPacket_Read(struct _MqttClient *client, byte* rx_buf, int rx_buf_len, int timeout_ms); +/* [MQTT-2.2.2-2] Validate the fixed-header reserved-flag bits for the given + * first packet byte (type+flags). Returns 1 if the flags are valid for the + * packet type, 0 if malformed. PUBLISH (type 3) carries DUP/QoS/RETAIN; QoS + * value 3 ([MQTT-3.3.1-4]) and DUP=1 with QoS=0 ([MQTT-3.3.1-2]) are + * rejected. The receiver MUST close the network connection on a malformed + * packet. */ +WOLFMQTT_API int MqttPacket_FixedHeaderFlagsValid(byte type_flags); + /* Packet Element Encoders/Decoders */ WOLFMQTT_API int MqttDecode_Num(byte* buf, word16 *len, word32 buf_len); WOLFMQTT_API int MqttEncode_Num(byte *buf, word16 len); From 95251ae125fa1225cded6dbcf15ff966af78f907 Mon Sep 17 00:00:00 2001 From: Eric Blankenhorn Date: Thu, 30 Apr 2026 18:16:03 -0500 Subject: [PATCH 02/32] Fix Zero-Length ClientId Unique Assignment --- src/mqtt_broker.c | 126 ++++++++-- tests/include.am | 18 ++ tests/test_broker_connect.c | 473 ++++++++++++++++++++++++++++++++++++ wolfmqtt/mqtt_broker.h | 2 + 4 files changed, 595 insertions(+), 24 deletions(-) create mode 100644 tests/test_broker_connect.c diff --git a/src/mqtt_broker.c b/src/mqtt_broker.c index 49bfe219c..8c13963fe 100644 --- a/src/mqtt_broker.c +++ b/src/mqtt_broker.c @@ -2711,6 +2711,8 @@ static int BrokerHandle_Connect(BrokerClient* bc, int rx_len, MqttConnect mc; MqttConnectAck ack; MqttMessage lwt; + word16 id_len = 0; + int auto_assigned = 0; XMEMSET(&mc, 0, sizeof(mc)); XMEMSET(&ack, 0, sizeof(ack)); @@ -2740,7 +2742,6 @@ static int BrokerHandle_Connect(BrokerClient* bc, int rx_len, bc->client_id[0] = '\0'; #endif if (mc.client_id) { - word16 id_len = 0; if (MqttDecode_Num((byte*)mc.client_id - MQTT_DATA_LEN_SIZE, &id_len, MQTT_DATA_LEN_SIZE) == MQTT_DATA_LEN_SIZE) { #ifdef WOLFMQTT_STATIC_MEMORY @@ -2762,14 +2763,101 @@ static int BrokerHandle_Connect(BrokerClient* bc, int rx_len, goto send_connack; } #endif - BROKER_STORE_STR(bc->client_id, mc.client_id, id_len, - BROKER_MAX_CLIENT_ID_LEN); + if (id_len > 0) { + BROKER_STORE_STR(bc->client_id, mc.client_id, id_len, + BROKER_MAX_CLIENT_ID_LEN); + } + } + } + + /* Reserve the "auto-" prefix for server-assigned IDs. Without this an + * attacker could observe their own assigned auto-XXXXXXXX, then reconnect + * with an explicit client_id matching a predicted future value and + * hijack a victim's session via the duplicate-takeover path below. */ + if (id_len >= 5 && BROKER_STR_VALID(bc->client_id) && + XSTRNCMP(bc->client_id, "auto-", 5) == 0) { + WBLOG_ERR(broker, + "broker: client_id with reserved 'auto-' prefix sock=%d", + (int)bc->sock); + #ifdef WOLFMQTT_V5 + if (mc.protocol_level >= MQTT_CONNECT_PROTOCOL_LEVEL_5) { + ack.return_code = MQTT_REASON_CLIENT_ID_NOT_VALID; + } + else + #endif + { + ack.return_code = MQTT_CONNECT_ACK_CODE_REFUSED_ID; } + goto send_connack; + } + + /* [MQTT-3.1.3-8] v3.1.1: zero-length ClientId requires CleanSession=1. + * The server MUST respond with CONNACK 0x02 (Identifier rejected) and + * close the connection. v5 dropped this restriction in favor of Clean + * Start + Session Expiry Interval, so it is enforced for v3.1.1 only. */ + if (id_len == 0 && !mc.clean_session +#ifdef WOLFMQTT_V5 + && mc.protocol_level < MQTT_CONNECT_PROTOCOL_LEVEL_5 +#endif + ) { + WBLOG_ERR(broker, + "broker: empty ClientId with clean_session=0 sock=%d " + "[MQTT-3.1.3-8]", (int)bc->sock); + ack.return_code = MQTT_CONNECT_ACK_CODE_REFUSED_ID; + goto send_connack; } bc->protocol_level = mc.protocol_level; bc->keep_alive_sec = mc.keep_alive_sec; bc->last_rx = WOLFMQTT_BROKER_GET_TIME_S(); + + /* [MQTT-3.1.3-6] If we accepted a zero-length ClientId, assign a unique + * server-generated one before the duplicate-check / session-resume block + * below so the assigned ID flows through normal handling. v5 also echoes + * it back to the client via the Assigned Client Identifier property + * (emitted in the v5 CONNACK construction below); v3.1.1 has no such + * field, so the assignment is server-internal. */ + if (id_len == 0 && !BROKER_STR_VALID(bc->client_id)) { + /* "auto-" + at least 8 hex chars + NUL. On a 16-bit-int platform the + * full 32-bit counter prints as more than 8 nibbles, so size for + * headroom rather than the minimum. The counter advances on every + * empty-ID CONNECT that reaches this point — including connects that + * are later refused (e.g., auth failure below) — so it is not a + * count of accepted clients. */ + char auto_id[32]; + int auto_len; + unsigned long id_value = (unsigned long)broker->next_auto_id++; + if (broker->next_auto_id == 0) { + /* Skip 0 on wrap for stylistic consistency with next_packet_id; + * unlike packet IDs, 0 has no protocol significance here. */ + broker->next_auto_id = 1; + } + auto_len = XSNPRINTF(auto_id, (int)sizeof(auto_id), + "auto-%08lx", id_value); + if (auto_len > 0) { + BROKER_STORE_STR(bc->client_id, auto_id, (word16)auto_len, + BROKER_MAX_CLIENT_ID_LEN); + } + if (!BROKER_STR_VALID(bc->client_id)) { + /* Storage failed (e.g., WOLFMQTT_MALLOC returned NULL in the + * dynamic-memory path). Refuse rather than proceeding with an + * untracked client. */ + WBLOG_ERR(broker, + "broker: auto-id store failed sock=%d", (int)bc->sock); + #ifdef WOLFMQTT_V5 + if (mc.protocol_level >= MQTT_CONNECT_PROTOCOL_LEVEL_5) { + ack.return_code = MQTT_REASON_SERVER_UNAVAILABLE; + } + else + #endif + { + ack.return_code = MQTT_CONNECT_ACK_CODE_REFUSED_UNAVAIL; + } + goto send_connack; + } + auto_assigned = 1; + } + WBLOG_INFO(broker, "broker: CONNECT proto=%u clean=%d will=%d client_id=%s", mc.protocol_level, mc.clean_session, mc.enable_lwt, BROKER_STR_VALID(bc->client_id) ? bc->client_id : "(null)"); @@ -3001,25 +3089,16 @@ static int BrokerHandle_Connect(BrokerClient* bc, int rx_len, ack.return_code == MQTT_CONNECT_ACK_CODE_ACCEPTED) { MqttProp* prop; - /* If client sent empty client ID, generate one and inform client */ - if (!BROKER_STR_VALID(bc->client_id)) { - char auto_id[32]; - int id_len = XSNPRINTF(auto_id, (int)sizeof(auto_id), - "auto-%04x", broker->next_packet_id++); - if (broker->next_packet_id == 0) { - broker->next_packet_id = 1; - } - if (id_len > 0) { - BROKER_STORE_STR(bc->client_id, auto_id, (word16)id_len, - BROKER_MAX_CLIENT_ID_LEN); - } - if (BROKER_STR_VALID(bc->client_id)) { - prop = MqttProps_Add(&ack.props); - if (prop != NULL) { - prop->type = MQTT_PROP_ASSIGNED_CLIENT_ID; - prop->data_str.str = bc->client_id; - prop->data_str.len = (word16)XSTRLEN(bc->client_id); - } + /* [MQTT-3.1.3-6] Echo any server-assigned ClientId to v5 clients. + * Keyed off auto_assigned (set in the auto-id branch above) so this + * stays true to its name even if a future code path populates + * bc->client_id from another source (e.g., a TLS-cert identity). */ + if (auto_assigned) { + prop = MqttProps_Add(&ack.props); + if (prop != NULL) { + prop->type = MQTT_PROP_ASSIGNED_CLIENT_ID; + prop->data_str.str = bc->client_id; + prop->data_str.len = (word16)XSTRLEN(bc->client_id); } } @@ -3050,9 +3129,7 @@ static int BrokerHandle_Connect(BrokerClient* bc, int rx_len, } #endif -#if defined(WOLFMQTT_BROKER_WILL) || defined(WOLFMQTT_STATIC_MEMORY) send_connack: -#endif rc = MqttEncode_ConnectAck(bc->tx_buf, BROKER_CLIENT_TX_SZ(bc), &ack); if (rc > 0) { WBLOG_INFO(broker, "broker: CONNACK send sock=%d code=%d", (int)bc->sock, @@ -3744,6 +3821,7 @@ int MqttBroker_Init(MqttBroker* broker, MqttBrokerNet* net) broker->running = 0; broker->log_level = BROKER_LOG_LEVEL_DEFAULT; broker->next_packet_id = 1; + broker->next_auto_id = 1; #if !defined(WOLFMQTT_WOLFIP) && !defined(WOLFMQTT_BROKER_CUSTOM_NET) /* For the default POSIX backend, the net callbacks expect ctx to be a diff --git a/tests/include.am b/tests/include.am index eba7de88d..043f7a134 100644 --- a/tests/include.am +++ b/tests/include.am @@ -15,6 +15,24 @@ tests_unit_tests_DEPENDENCIES = src/libwolfmqtt.la # Test framework header noinst_HEADERS += tests/unit_test.h +# Broker CONNECT handler tests. Compiles src/mqtt_broker.c into the test +# binary with WOLFMQTT_BROKER_CUSTOM_NET so the network layer is replaced +# with mock callbacks driven by the harness. +if BUILD_BROKER +check_PROGRAMS += tests/test_broker_connect +tests_test_broker_connect_SOURCES = \ + tests/test_broker_connect.c \ + src/mqtt_broker.c +tests_test_broker_connect_CFLAGS = -DWOLFMQTT_BROKER -DWOLFMQTT_BROKER_CUSTOM_NET \ + -DWOLFMQTT_BROKER_NO_LOG -DNO_MAIN_DRIVER \ + '-DWOLFMQTT_BROKER_GET_TIME_S()=((WOLFMQTT_BROKER_TIME_T)0)' \ + '-DBROKER_SLEEP_MS(ms)=do{}while(0)' \ + $(AM_CFLAGS) +tests_test_broker_connect_CPPFLAGS = -I$(top_srcdir) $(AM_CPPFLAGS) +tests_test_broker_connect_LDADD = src/libwolfmqtt.la $(LIB_STATIC_ADD) +tests_test_broker_connect_DEPENDENCIES = src/libwolfmqtt.la +endif + if BUILD_FUZZ if BUILD_BROKER noinst_PROGRAMS += tests/fuzz/broker_fuzz diff --git a/tests/test_broker_connect.c b/tests/test_broker_connect.c new file mode 100644 index 000000000..fb1249a8c --- /dev/null +++ b/tests/test_broker_connect.c @@ -0,0 +1,473 @@ +/* test_broker_connect.c + * + * Copyright (C) 2006-2026 wolfSSL Inc. + * + * This file is part of wolfMQTT. + * + * wolfMQTT is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * wolfMQTT is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1335, USA + */ + +/* Standalone unit tests for the broker CONNECT handler. + * + * mqtt_broker.c is built into this binary with WOLFMQTT_BROKER_CUSTOM_NET so + * the default sockets layer is replaced with in-process mock callbacks. The + * harness feeds a hand-built CONNECT packet through MqttBroker_Step() and + * captures the CONNACK bytes the broker writes back, so we can assert on the + * return code and properties without spinning up a real broker. + * + * These tests pin the [MQTT-3.1.3-6] / [MQTT-3.1.3-8] zero-length ClientId + * rules: v3.1.1 + clean=0 + empty ID must be refused with CONNACK 0x02; + * v3.1.1 + clean=1 + empty ID must be accepted; v5 still emits the Assigned + * Client Identifier property. + */ + +#ifdef HAVE_CONFIG_H + #include +#endif + +#include "wolfmqtt/mqtt_client.h" +#include "wolfmqtt/mqtt_broker.h" +#include "wolfmqtt/mqtt_packet.h" + +/* Provide storage for the unit-test framework's global counters. Must be + * defined before unit_test.h is included. */ +#define UNIT_TEST_IMPLEMENTATION +#include "tests/unit_test.h" + +/* Mock socket constants */ +#define MOCK_LISTEN_SOCK 100 +#define MOCK_CLIENT_SOCK 101 +#define MOCK_BUF_SZ 1024 + +/* Per-test mock state. Reset by reset_mock_state() before each scenario. */ +static byte g_in_buf[MOCK_BUF_SZ]; +static size_t g_in_len; +static size_t g_in_pos; +static byte g_out_buf[MOCK_BUF_SZ]; +static size_t g_out_len; +static int g_client_accepted; +static int g_client_closed; + +/* -------------------------------------------------------------------------- */ +/* Mock network callbacks */ +/* -------------------------------------------------------------------------- */ + +static int mock_listen(void* ctx, BROKER_SOCKET_T* sock, + word16 port, int backlog) +{ + (void)ctx; (void)port; (void)backlog; + *sock = MOCK_LISTEN_SOCK; + return MQTT_CODE_SUCCESS; +} + +static int mock_accept(void* ctx, BROKER_SOCKET_T listen_sock, + BROKER_SOCKET_T* client_sock) +{ + (void)ctx; (void)listen_sock; + if (!g_client_accepted) { + g_client_accepted = 1; + *client_sock = MOCK_CLIENT_SOCK; + return MQTT_CODE_SUCCESS; + } + return MQTT_CODE_ERROR_TIMEOUT; +} + +static int mock_read(void* ctx, BROKER_SOCKET_T sock, + byte* buf, int buf_len, int timeout_ms) +{ + int avail; + (void)ctx; (void)timeout_ms; + if (sock != MOCK_CLIENT_SOCK || g_client_closed) { + return MQTT_CODE_ERROR_TIMEOUT; + } + if (g_in_pos >= g_in_len) { + return MQTT_CODE_ERROR_TIMEOUT; + } + avail = (int)(g_in_len - g_in_pos); + if (buf_len > avail) { + buf_len = avail; + } + XMEMCPY(buf, g_in_buf + g_in_pos, (size_t)buf_len); + g_in_pos += (size_t)buf_len; + return buf_len; +} + +static int mock_write(void* ctx, BROKER_SOCKET_T sock, + const byte* buf, int buf_len, int timeout_ms) +{ + (void)ctx; (void)sock; (void)timeout_ms; + if (g_out_len + (size_t)buf_len > sizeof(g_out_buf)) { + return MQTT_CODE_ERROR_NETWORK; + } + XMEMCPY(g_out_buf + g_out_len, buf, (size_t)buf_len); + g_out_len += (size_t)buf_len; + return buf_len; +} + +static int mock_close(void* ctx, BROKER_SOCKET_T sock) +{ + (void)ctx; (void)sock; + g_client_closed = 1; + return MQTT_CODE_SUCCESS; +} + +/* -------------------------------------------------------------------------- */ +/* Test fixture */ +/* -------------------------------------------------------------------------- */ + +static void reset_mock_state(const byte* connect_buf, size_t connect_len) +{ + XMEMSET(g_in_buf, 0, sizeof(g_in_buf)); + XMEMSET(g_out_buf, 0, sizeof(g_out_buf)); + XMEMCPY(g_in_buf, connect_buf, connect_len); + g_in_len = connect_len; + g_in_pos = 0; + g_out_len = 0; + g_client_accepted = 0; + g_client_closed = 0; +} + +static void install_mock_net(MqttBrokerNet* net) +{ + XMEMSET(net, 0, sizeof(*net)); + net->listen = mock_listen; + net->accept = mock_accept; + net->read = mock_read; + net->write = mock_write; + net->close = mock_close; +} + +/* Drive the broker through enough Step() calls to consume the CONNECT and + * emit the CONNACK. The first Step() accepts the client; the second reads + * and dispatches the CONNECT, which writes the CONNACK and may close. */ +static void run_broker_one_connect(MqttBroker* broker) +{ + int i; + for (i = 0; i < 4; i++) { + MqttBroker_Step(broker); + if (g_in_pos >= g_in_len && g_out_len > 0) { + break; + } + } +} + +static void setup(void) { } +static void teardown(void) { } + +/* -------------------------------------------------------------------------- */ +/* CONNECT wire helpers */ +/* -------------------------------------------------------------------------- */ + +/* Build a v3.1.1 CONNECT packet with zero-length ClientId. + * Fixed header: 0x10, remain=12 + * Variable header: protocol "MQTT" (4), level 4, flags, keepalive=60 + * Payload: ClientId length 0x0000 (no bytes) + * The connect_flags byte encodes CleanSession in bit 1 (0x02). */ +static size_t build_v311_connect_emptyid(byte* out, byte connect_flags) +{ + static const byte tmpl[] = { + 0x10, 12, + 0x00, 0x04, 'M', 'Q', 'T', 'T', + 0x04, + 0x00, /* connect_flags placeholder */ + 0x00, 0x3C, + 0x00, 0x00 + }; + XMEMCPY(out, tmpl, sizeof(tmpl)); + out[9] = connect_flags; + return sizeof(tmpl); +} + +#ifdef WOLFMQTT_V5 +/* Build a v5 CONNECT packet with zero-length ClientId. + * Fixed header: 0x10, remain=13 + * Variable header: "MQTT" (4), level 5, flags, keepalive=60, props_len=0 + * Payload: ClientId length 0x0000 */ +static size_t build_v5_connect_emptyid(byte* out, byte connect_flags) +{ + static const byte tmpl[] = { + 0x10, 13, + 0x00, 0x04, 'M', 'Q', 'T', 'T', + 0x05, + 0x00, /* connect_flags placeholder */ + 0x00, 0x3C, + 0x00, /* properties length = 0 */ + 0x00, 0x00 + }; + XMEMCPY(out, tmpl, sizeof(tmpl)); + out[9] = connect_flags; + return sizeof(tmpl); +} +#endif + +/* -------------------------------------------------------------------------- */ +/* Tests */ +/* -------------------------------------------------------------------------- */ + +/* [MQTT-3.1.3-8] v3.1.1: zero-length ClientId with CleanSession=0 must be + * rejected with CONNACK reason 0x02 (Identifier rejected) and the network + * connection must be closed. */ +TEST(connect_v311_emptyid_clean0_refused) +{ + MqttBroker broker; + MqttBrokerNet net; + byte connect[64]; + size_t connect_len; + + install_mock_net(&net); + XMEMSET(&broker, 0, sizeof(broker)); + ASSERT_EQ(MQTT_CODE_SUCCESS, MqttBroker_Init(&broker, &net)); + ASSERT_EQ(MQTT_CODE_SUCCESS, MqttBroker_Start(&broker)); + + /* connect_flags = 0x00: CleanSession=0, no will, no auth */ + connect_len = build_v311_connect_emptyid(connect, 0x00); + reset_mock_state(connect, connect_len); + run_broker_one_connect(&broker); + + /* CONNACK: 0x20 0x02 0x00 0x02 + * byte[0] = CONNACK type+flags + * byte[1] = remaining length (2 in v3.1.1) + * byte[2] = session-present flag (must be 0 for any non-zero return) + * byte[3] = return code 0x02 = Identifier rejected */ + ASSERT_TRUE(g_out_len >= 4); + ASSERT_EQ(0x20, g_out_buf[0]); + ASSERT_EQ(0x02, g_out_buf[1]); + ASSERT_EQ(0x00, g_out_buf[2]); + ASSERT_EQ(MQTT_CONNECT_ACK_CODE_REFUSED_ID, g_out_buf[3]); + ASSERT_TRUE(g_client_closed); + + MqttBroker_Stop(&broker); + MqttBroker_Free(&broker); +} + +/* [MQTT-3.1.3-6] v3.1.1: zero-length ClientId with CleanSession=1 must be + * accepted, and the broker MUST assign a unique ClientId server-side. v3.1.1 + * has no protocol field for echoing the assigned ID, so we verify the + * assignment ran by checking that broker->next_auto_id advanced. */ +TEST(connect_v311_emptyid_clean1_accepted) +{ + MqttBroker broker; + MqttBrokerNet net; + byte connect[64]; + size_t connect_len; + word32 auto_id_before; + + install_mock_net(&net); + XMEMSET(&broker, 0, sizeof(broker)); + ASSERT_EQ(MQTT_CODE_SUCCESS, MqttBroker_Init(&broker, &net)); + ASSERT_EQ(MQTT_CODE_SUCCESS, MqttBroker_Start(&broker)); + auto_id_before = broker.next_auto_id; + + /* connect_flags = 0x02: CleanSession=1 */ + connect_len = build_v311_connect_emptyid(connect, 0x02); + reset_mock_state(connect, connect_len); + run_broker_one_connect(&broker); + + ASSERT_TRUE(g_out_len >= 4); + ASSERT_EQ(0x20, g_out_buf[0]); + ASSERT_EQ(0x02, g_out_buf[1]); + ASSERT_EQ(MQTT_CONNECT_ACK_CODE_ACCEPTED, g_out_buf[3]); + ASSERT_FALSE(g_client_closed); + /* The auto-id branch must have run (counter advanced). Catches a + * regression where BROKER_STORE_STR silently no-ops or the v3.1.1 path + * gets re-gated to v5-only. */ + ASSERT_TRUE(broker.next_auto_id > auto_id_before); + + MqttBroker_Stop(&broker); + MqttBroker_Free(&broker); +} + +/* Sanity: a normal v3.1.1 CONNECT with a non-empty ClientId is accepted + * regardless of CleanSession. Pins that the new empty-ID checks didn't + * regress the normal path. */ +TEST(connect_v311_nonempty_clean0_accepted) +{ + MqttBroker broker; + MqttBrokerNet net; + /* Same as build_v311_connect_emptyid but with ClientId "id" (2 bytes). + * Remaining length grows by 2; ClientId length field becomes 0x0002. */ + byte connect[] = { + 0x10, 14, + 0x00, 0x04, 'M', 'Q', 'T', 'T', + 0x04, + 0x00, /* CleanSession = 0 */ + 0x00, 0x3C, + 0x00, 0x02, 'i', 'd' + }; + + install_mock_net(&net); + XMEMSET(&broker, 0, sizeof(broker)); + ASSERT_EQ(MQTT_CODE_SUCCESS, MqttBroker_Init(&broker, &net)); + ASSERT_EQ(MQTT_CODE_SUCCESS, MqttBroker_Start(&broker)); + + reset_mock_state(connect, sizeof(connect)); + run_broker_one_connect(&broker); + + ASSERT_TRUE(g_out_len >= 4); + ASSERT_EQ(0x20, g_out_buf[0]); + ASSERT_EQ(MQTT_CONNECT_ACK_CODE_ACCEPTED, g_out_buf[3]); + ASSERT_FALSE(g_client_closed); + + MqttBroker_Stop(&broker); + MqttBroker_Free(&broker); +} + +/* The broker reserves the "auto-" prefix for server-assigned IDs. An + * explicit client_id starting with "auto-" must be refused, otherwise an + * attacker could observe their own assigned ID, predict a future value, and + * collide with a victim via the duplicate-takeover path. */ +TEST(connect_v311_explicit_auto_prefix_refused) +{ + MqttBroker broker; + MqttBrokerNet net; + /* CONNECT with client_id "auto-foo" (8 bytes). Remaining = 12 (header) + 8. + * ClientId length field = 0x0008. */ + byte connect[] = { + 0x10, 20, + 0x00, 0x04, 'M', 'Q', 'T', 'T', + 0x04, + 0x02, /* CleanSession = 1 */ + 0x00, 0x3C, + 0x00, 0x08, 'a', 'u', 't', 'o', '-', 'f', 'o', 'o' + }; + + install_mock_net(&net); + XMEMSET(&broker, 0, sizeof(broker)); + ASSERT_EQ(MQTT_CODE_SUCCESS, MqttBroker_Init(&broker, &net)); + ASSERT_EQ(MQTT_CODE_SUCCESS, MqttBroker_Start(&broker)); + + reset_mock_state(connect, sizeof(connect)); + run_broker_one_connect(&broker); + + ASSERT_TRUE(g_out_len >= 4); + ASSERT_EQ(0x20, g_out_buf[0]); + ASSERT_EQ(MQTT_CONNECT_ACK_CODE_REFUSED_ID, g_out_buf[3]); + ASSERT_TRUE(g_client_closed); + + MqttBroker_Stop(&broker); + MqttBroker_Free(&broker); +} + +#ifdef WOLFMQTT_V5 +/* v5 dropped the [MQTT-3.1.3-8] CleanSession=1-only restriction; an empty + * ClientId is acceptable with any Clean Start value. The broker MUST emit + * the Assigned Client Identifier property in CONNACK. */ +TEST(connect_v5_emptyid_assigned_id_emitted) +{ + MqttBroker broker; + MqttBrokerNet net; + byte connect[64]; + size_t connect_len; + word16 assigned_id_len; + + install_mock_net(&net); + XMEMSET(&broker, 0, sizeof(broker)); + ASSERT_EQ(MQTT_CODE_SUCCESS, MqttBroker_Init(&broker, &net)); + ASSERT_EQ(MQTT_CODE_SUCCESS, MqttBroker_Start(&broker)); + + /* Clean Start = 1 (bit 1) */ + connect_len = build_v5_connect_emptyid(connect, 0x02); + reset_mock_state(connect, connect_len); + run_broker_one_connect(&broker); + + /* v5 CONNACK layout for our small response: + * [0] 0x20 CONNACK type+flags + * [1] remaining_len (VBI; expect 1 byte for our payload) + * [2] session_present + * [3] reason_code (0x00 = Success) + * [4] properties_len (VBI; expect 1 byte) + * [5] first property tag — MUST be 0x12 (ASSIGNED_CLIENT_ID) + * [6..7] string length (big-endian word16) + * [8..] UTF-8 string ("auto-XXXXXXXX") + * MqttProps_Add appends to the end of the prop list (mqtt_packet.c + * MqttProps_Add walks to the tail), and the broker adds ASSIGNED_CLIENT_ + * ID before the feature properties, so it MUST be the first tag in the + * encoded output. */ + ASSERT_TRUE(g_out_len >= 8); + ASSERT_EQ(0x20, g_out_buf[0]); + ASSERT_EQ(MQTT_REASON_SUCCESS, g_out_buf[3]); + ASSERT_EQ(MQTT_PROP_ASSIGNED_CLIENT_ID, g_out_buf[5]); + + /* The string length must be non-zero and the bytes must look like our + * "auto-" prefix. This catches a regression where the property tag is + * present but the value isn't actually filled in. */ + assigned_id_len = (word16)((g_out_buf[6] << 8) | g_out_buf[7]); + ASSERT_TRUE(assigned_id_len > 5); + ASSERT_TRUE((size_t)8 + assigned_id_len <= g_out_len); + ASSERT_EQ(0, XMEMCMP(&g_out_buf[8], "auto-", 5)); + ASSERT_FALSE(g_client_closed); + + MqttBroker_Stop(&broker); + MqttBroker_Free(&broker); +} + +/* v5: empty ClientId + Clean Start = 0 must also be accepted. v5 dropped + * [MQTT-3.1.3-8]; the protocol_level<5 predicate in the broker's rejection + * gate must keep this case out of the refuse path. Pins that the v5 escape + * hatch in the gate doesn't regress. */ +TEST(connect_v5_emptyid_clean0_accepted) +{ + MqttBroker broker; + MqttBrokerNet net; + byte connect[64]; + size_t connect_len; + word32 auto_id_before; + + install_mock_net(&net); + XMEMSET(&broker, 0, sizeof(broker)); + ASSERT_EQ(MQTT_CODE_SUCCESS, MqttBroker_Init(&broker, &net)); + ASSERT_EQ(MQTT_CODE_SUCCESS, MqttBroker_Start(&broker)); + auto_id_before = broker.next_auto_id; + + /* Clean Start = 0 */ + connect_len = build_v5_connect_emptyid(connect, 0x00); + reset_mock_state(connect, connect_len); + run_broker_one_connect(&broker); + + ASSERT_TRUE(g_out_len >= 6); + ASSERT_EQ(0x20, g_out_buf[0]); + ASSERT_EQ(MQTT_REASON_SUCCESS, g_out_buf[3]); + ASSERT_EQ(MQTT_PROP_ASSIGNED_CLIENT_ID, g_out_buf[5]); + ASSERT_FALSE(g_client_closed); + ASSERT_TRUE(broker.next_auto_id > auto_id_before); + + MqttBroker_Stop(&broker); + MqttBroker_Free(&broker); +} +#endif /* WOLFMQTT_V5 */ + +/* -------------------------------------------------------------------------- */ +/* Runner */ +/* -------------------------------------------------------------------------- */ + +int main(int argc, char** argv) +{ + (void)argc; (void)argv; + + TEST_RUNNER_BEGIN(); + + TEST_SUITE_BEGIN("broker_connect", setup, teardown); + RUN_TEST(connect_v311_emptyid_clean0_refused); + RUN_TEST(connect_v311_emptyid_clean1_accepted); + RUN_TEST(connect_v311_nonempty_clean0_accepted); + RUN_TEST(connect_v311_explicit_auto_prefix_refused); +#ifdef WOLFMQTT_V5 + RUN_TEST(connect_v5_emptyid_assigned_id_emitted); + RUN_TEST(connect_v5_emptyid_clean0_accepted); +#endif + TEST_SUITE_END(); + + TEST_RUNNER_END(); +} diff --git a/wolfmqtt/mqtt_broker.h b/wolfmqtt/mqtt_broker.h index 416b180a9..d53eda7ac 100644 --- a/wolfmqtt/mqtt_broker.h +++ b/wolfmqtt/mqtt_broker.h @@ -319,6 +319,8 @@ typedef struct MqttBroker { #endif MqttBrokerNet net; word16 next_packet_id; + word32 next_auto_id; /* monotonically increasing counter for + * server-assigned ClientIds (empty-ID accepts) */ #ifdef ENABLE_MQTT_TLS BROKER_SOCKET_T listen_sock_tls; /* TLS listener socket */ word16 port_tls; /* TLS port (default 8883) */ From 3025f2aa12e62c057248d01c3cb385f612b3a1f1 Mon Sep 17 00:00:00 2001 From: Eric Blankenhorn Date: Thu, 30 Apr 2026 18:50:55 -0500 Subject: [PATCH 03/32] Fix UTF-8 handling --- ChangeLog.md | 46 +++-- src/mqtt_packet.c | 120 +++++++++--- tests/test_mqtt_packet.c | 383 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 494 insertions(+), 55 deletions(-) diff --git a/ChangeLog.md b/ChangeLog.md index a41685b73..349aa738e 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -3,36 +3,32 @@ ### v2.0.1 (Pending) * Security Hardening - - Reject MQTT UTF-8 encoded strings containing U+0000 in `MqttDecode_String` - per [MQTT-1.5.3-2] / [MQTT-1.5.4-2]. Closes an embedded-NUL truncation - attack that allowed broker-side auth bypass, ClientId collision, and - topic-routing confusion when the broker compared decoded strings with - C-string semantics. - - The CONNECT Password field is decoded by a new internal helper - (`MqttDecode_Password`) that applies the same NUL rejection. Per - MQTT-3.1.3.5 the field is Binary Data so the spec does not require - this check, but wolfMQTT compares passwords with `XSTRLEN`/`XSTRCMP`, - so a binary password with an embedded NUL would be silently truncated - and could enable an auth bypass. Spec-compliant clients sending binary - passwords containing 0x00 will be rejected by the broker as a result. + - Reject ill-formed UTF-8 in MQTT UTF-8 string fields per [MQTT-1.5.3-1]. + `MqttDecode_String` now validates each decoded string against RFC 3629 + and rejects encodings of surrogate code points (U+D800..U+DFFF) with + `MQTT_CODE_ERROR_MALFORMED_DATA`. Receivers MUST close the network + connection on malformed packets, which the broker's existing decode- + error path enforces. The check covers ClientId, Will Topic, Topic Name, + Topic Filter, Username, and v5 STRING/STRING_PAIR property values. * API / Behavior Changes - `MqttDecode_String` may now return `MQTT_CODE_ERROR_MALFORMED_DATA` - when the decoded string contains an embedded NUL byte. Previously - `MQTT_CODE_ERROR_OUT_OF_BUFFER` was the only possible negative return. + on ill-formed UTF-8. This applies to client builds as well as broker + builds — [MQTT-1.5.3-1] is normative for both. wolfMQTT clients that + previously accepted PUBLISH messages with non-UTF-8 topics from + misbehaving brokers will now error on those messages. There is no + opt-out: the spec is a MUST. - `MqttDecode_Publish` now propagates the underlying error from - `MqttDecode_String` (e.g. `MALFORMED_DATA`) instead of always returning - `MQTT_CODE_ERROR_OUT_OF_BUFFER` on topic decode failure. - - `MqttDecode_Props` propagates `MQTT_CODE_ERROR_MALFORMED_DATA` from + `MqttDecode_String` (e.g. `MALFORMED_DATA`) instead of always + returning `MQTT_CODE_ERROR_OUT_OF_BUFFER` on topic decode failure. + - `MqttDecode_Props` similarly now propagates the underlying error from `MqttDecode_String` for v5 STRING and STRING_PAIR property types - (Reason String, Content Type, User Property, etc.). Other negative - returns continue to be mapped to `MQTT_CODE_ERROR_PROPERTY` so client - code that branches on that error code is unaffected. - - New `XMEMCHR(s, c, n)` portability macro added to `mqtt_types.h` - (defaults to `memchr` for standard builds). Builds using - `WOLFMQTT_CUSTOM_STRING` must define `XMEMCHR` themselves, matching - the pattern used for `XSTRLEN`, `XMEMCMP`, etc.; the header emits - an explicit `#error` if it is missing. + (Reason String, Content Type, User Property, etc.) instead of masking + it as `MQTT_CODE_ERROR_PROPERTY`. + - The CONNECT Password decode no longer goes through `MqttDecode_String` + because [MQTT-3.1.3.5] defines Password as Binary Data, not a UTF-8 + string. A binary password containing bytes that are not valid UTF-8 + (e.g., `0xC0`, `0xFF`) would otherwise be incorrectly rejected. ### v2.0.0 (03/20/2026) Release 2.0.0 has been developed according to wolfSSL's development and QA diff --git a/src/mqtt_packet.c b/src/mqtt_packet.c index 86543b8c4..417d0c170 100644 --- a/src/mqtt_packet.c +++ b/src/mqtt_packet.c @@ -391,10 +391,76 @@ int MqttEncode_Int(byte* buf, word32 len) return MQTT_DATA_INT_SIZE; } -/* Returns number of buffer bytes decoded */ -/* Returns pointer to string (which is not guaranteed to be null terminated) */ -/* Note: MqttDecode_Password mirrors this implementation for the CONNECT - * Password binary-data field. Keep length and bounds handling in sync. */ +/* [MQTT-1.5.3-1] Validate that the given byte sequence is well-formed UTF-8 + * per RFC 3629, including the surrogate-code-point ban (U+D800..U+DFFF MUST + * NOT be encoded). Returns 1 if valid, 0 if malformed. + * + * RFC 3629 byte-pattern table: + * 1-byte: 00..7F -> U+0000..U+007F + * 2-byte: C2..DF 80..BF -> U+0080..U+07FF + * 3-byte: E0 A0..BF 80..BF -> U+0800..U+0FFF + * E1..EC 80..BF 80..BF -> U+1000..U+CFFF + * ED 80..9F 80..BF -> U+D000..U+D7FF + * EE..EF 80..BF 80..BF -> U+E000..U+FFFF + * 4-byte: F0 90..BF 80..BF 80..BF -> U+10000..U+3FFFF + * F1..F3 80..BF 80..BF 80..BF -> U+40000..U+FFFFF + * F4 80..8F 80..BF 80..BF -> U+100000..U+10FFFF + * Anything else (overlong, surrogate, > U+10FFFF, lone continuation, + * truncated multi-byte) is malformed. */ +static int Utf8WellFormed(const byte* s, word16 len) +{ + word16 i = 0; + while (i < len) { + byte b0 = s[i]; + byte b1, b2, b3; + + if (b0 < 0x80) { + i++; + continue; + } + if (b0 < 0xC2 || b0 > 0xF4) { + /* C0/C1 are overlong-only; F5..FF exceed U+10FFFF or are not + * UTF-8 leading bytes. */ + return 0; + } + if (b0 < 0xE0) { + /* 2-byte */ + if ((word32)i + 1 >= (word32)len) return 0; + b1 = s[i+1]; + if ((b1 & 0xC0) != 0x80) return 0; + i += 2; + } + else if (b0 < 0xF0) { + /* 3-byte */ + if ((word32)i + 2 >= (word32)len) return 0; + b1 = s[i+1]; + b2 = s[i+2]; + if ((b1 & 0xC0) != 0x80 || (b2 & 0xC0) != 0x80) return 0; + if (b0 == 0xE0 && b1 < 0xA0) return 0; /* overlong */ + if (b0 == 0xED && b1 >= 0xA0) return 0; /* surrogate */ + i += 3; + } + else { + /* 4-byte (b0 in F0..F4) */ + if ((word32)i + 3 >= (word32)len) return 0; + b1 = s[i+1]; + b2 = s[i+2]; + b3 = s[i+3]; + if ((b1 & 0xC0) != 0x80 || + (b2 & 0xC0) != 0x80 || + (b3 & 0xC0) != 0x80) return 0; + if (b0 == 0xF0 && b1 < 0x90) return 0; /* overlong */ + if (b0 == 0xF4 && b1 >= 0x90) return 0; /* > U+10FFFF */ + i += 4; + } + } + return 1; +} + +/* Returns pointer to string (which is not guaranteed to be null terminated). + * [MQTT-1.5.3-1] Rejects ill-formed UTF-8 with MQTT_CODE_ERROR_MALFORMED_DATA; + * receivers MUST close the network connection on malformed packets, which the + * existing decode-error propagation in the broker handles. */ int MqttDecode_String(byte *buf, const char **pstr, word16 *pstr_len, word32 buf_len) { int len; @@ -407,10 +473,7 @@ int MqttDecode_String(byte *buf, const char **pstr, word16 *pstr_len, word32 buf return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_OUT_OF_BUFFER); } buf += len; - /* [MQTT-1.5.3-2] / [MQTT-1.5.4-2]: a UTF-8 encoded string MUST NOT - * include U+0000. Reject so downstream C-string handling cannot be - * tricked by an embedded NUL truncating the value. */ - if (str_len > 0 && XMEMCHR(buf, 0x00, str_len) != NULL) { + if (str_len > 0 && !Utf8WellFormed(buf, str_len)) { return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_MALFORMED_DATA); } if (pstr_len) { @@ -706,16 +769,11 @@ int MqttDecode_Props(MqttPacketType packet, MqttProp** props, byte* pbuf, (const char**)&cur_prop->data_str.str, &cur_prop->data_str.len, (word32)(buf_len - (buf - pbuf))); - if (tmp == MQTT_CODE_ERROR_MALFORMED_DATA) { - /* Propagate spec-required NUL rejection so callers can - * distinguish malformed UTF-8 from generic property - * decode failures. Other negative codes keep their - * legacy MQTT_CODE_ERROR_PROPERTY mapping below. */ + if (tmp < 0) { + /* Preserve specific error (e.g., MALFORMED_DATA from + * UTF-8 check) instead of masking as PROPERTY. */ rc = tmp; } - else if (tmp < 0) { - rc = MQTT_TRACE_ERROR(MQTT_CODE_ERROR_PROPERTY); - } else if ((word32)tmp <= (buf_len - (buf - pbuf))) { buf += tmp; total += tmp; @@ -778,14 +836,9 @@ int MqttDecode_Props(MqttPacketType packet, MqttProp** props, byte* pbuf, (const char**)&cur_prop->data_str.str, &cur_prop->data_str.len, (word32)(buf_len - (buf - pbuf))); - /* See MQTT_DATA_TYPE_STRING above for the rationale on - * propagating MALFORMED_DATA distinctly from other errors. */ - if (tmp == MQTT_CODE_ERROR_MALFORMED_DATA) { + if (tmp < 0) { rc = tmp; } - else if (tmp < 0) { - rc = MQTT_TRACE_ERROR(MQTT_CODE_ERROR_PROPERTY); - } else if ((word32)tmp <= (buf_len - (buf - pbuf))) { buf += tmp; total += tmp; @@ -795,13 +848,9 @@ int MqttDecode_Props(MqttPacketType packet, MqttProp** props, byte* pbuf, (const char**)&cur_prop->data_str2.str, &cur_prop->data_str2.len, (word32)(buf_len - (buf - pbuf))); - /* See MQTT_DATA_TYPE_STRING above. */ - if (tmp == MQTT_CODE_ERROR_MALFORMED_DATA) { + if (tmp < 0) { rc = tmp; } - else if (tmp < 0) { - rc = MQTT_TRACE_ERROR(MQTT_CODE_ERROR_PROPERTY); - } else if ((word32)tmp <= (buf_len - (buf - pbuf))) { buf += tmp; @@ -1274,15 +1323,25 @@ int MqttDecode_Connect(byte *rx_buf, int rx_buf_len, MqttConnect *mc_connect) } if (packet.flags & MQTT_CONNECT_FLAG_PASSWORD) { - tmp = MqttDecode_Password(rx_payload, &mc_connect->password, NULL, + /* Password is binary data, not a UTF-8 string ([MQTT-3.1.3.5]). Decode + * the length prefix directly so MqttDecode_String's UTF-8 validation + * does not reject non-UTF-8 password bytes. */ + word16 plen = 0; + tmp = MqttDecode_Num(rx_payload, &plen, (word32)(rx_buf_len - (rx_payload - rx_buf))); if (tmp < 0) { return tmp; } - if ((rx_payload - rx_buf) + tmp > header_len + remain_len) { + if ((word32)plen > + (word32)(rx_buf_len - (rx_payload - rx_buf) - tmp)) { return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_OUT_OF_BUFFER); } - rx_payload += tmp; + if ((rx_payload - rx_buf) + tmp + (int)plen > + header_len + remain_len) { + return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_OUT_OF_BUFFER); + } + mc_connect->password = (char*)(rx_payload + tmp); + rx_payload += tmp + plen; } (void)rx_payload; @@ -1589,6 +1648,7 @@ int MqttDecode_Publish(byte *rx_buf, int rx_buf_len, MqttPublish *publish) variable_len = MqttDecode_String(rx_payload, &publish->topic_name, &publish->topic_name_len, (word32)(rx_buf_len - (rx_payload - rx_buf))); if (variable_len < 0) { + /* Preserve specific error (e.g., MALFORMED_DATA from UTF-8 check). */ return variable_len; } if (variable_len + header_len > rx_buf_len) { diff --git a/tests/test_mqtt_packet.c b/tests/test_mqtt_packet.c index ac517c6e6..a727a9bed 100644 --- a/tests/test_mqtt_packet.c +++ b/tests/test_mqtt_packet.c @@ -307,6 +307,362 @@ TEST(encode_decode_vbi_roundtrip) } } +/* ============================================================================ + * UTF-8 well-formedness validation [MQTT-1.5.3-1] + * + * MqttDecode_String validates that the bytes following the 2-byte length + * prefix are well-formed UTF-8 per RFC 3629, including the surrogate-code- + * point ban (U+D800..U+DFFF). The internal validator is static; we exercise + * it through the protocol-level decoders (Subscribe, Publish, Connect) since + * those are what enforce the spec rule on the wire. + * + * Each test below builds a SUBSCRIBE packet whose topic-filter bytes carry + * the UTF-8 sequence under test, and asserts: + * - well-formed bytes -> decode succeeds (rc > 0) + * - ill-formed bytes -> MQTT_CODE_ERROR_MALFORMED_DATA (which the broker's + * existing error path translates into a connection close). + * + * Bytes used by the tests: + * ASCII "ab" 61 62 + * 2-byte U+00E9 (é) C3 A9 + * 3-byte U+20AC (€) E2 82 AC + * 3-byte U+0800 (low edge) E0 A0 80 + * 3-byte U+D7FF (last pre- ED 9F BF + * surrogate) + * 3-byte U+E000 (first post-EE 80 80 + * surrogate) + * 4-byte U+10000 F0 90 80 80 + * 4-byte U+10FFFF (max) F4 8F BF BF + * overlong 2-byte 0x2F C0 AF + * overlong 3-byte 0x2F E0 80 AF + * overlong 4-byte 0x2F F0 80 80 AF + * surrogate U+D800 ED A0 80 + * surrogate U+DFFF ED BF BF + * > U+10FFFF F4 90 80 80 + * F5+ leading byte F5 80 80 80 + * lone continuation 80 + * truncated 2-byte C2 + * truncated 4-byte F0 90 80 + * invalid leading FE/FF FE / FF + * ============================================================================ */ + +#ifdef WOLFMQTT_BROKER +/* Build a v3.1.1 SUBSCRIBE wire buffer with the given topic_filter bytes + * and write it into out. Returns the total wire length. */ +static int build_subscribe_with_topic(byte* out, size_t out_sz, + const byte* topic, word16 topic_len) +{ + /* type=0x82, remain=VBI, packet_id=0x0001, topic_len=word16, + * topic..., options=0x01. remain = 2 + 2 + topic_len + 1 = topic_len+5 */ + int remain = (int)topic_len + 5; + int written = 0; + if ((size_t)remain + 2 > out_sz || remain > 127) { + return -1; + } + out[written++] = 0x82; + out[written++] = (byte)remain; + out[written++] = 0x00; out[written++] = 0x01; /* packet_id */ + out[written++] = (byte)(topic_len >> 8); + out[written++] = (byte)(topic_len & 0xFF); + if (topic_len > 0) { + XMEMCPY(out + written, topic, topic_len); + written += topic_len; + } + out[written++] = 0x01; /* options: QoS 1 */ + return written; +} + +/* Run a SUBSCRIBE through the decoder with the given topic-filter bytes and + * return the decoder's return code. */ +static int decode_subscribe_with_topic(const byte* topic, word16 topic_len) +{ + byte rx_buf[128]; + MqttSubscribe sub; + MqttTopic topic_arr[1]; + int wire_len = build_subscribe_with_topic(rx_buf, sizeof(rx_buf), + topic, topic_len); + if (wire_len < 0) { + return -1; + } + XMEMSET(&sub, 0, sizeof(sub)); + XMEMSET(topic_arr, 0, sizeof(topic_arr)); + sub.topics = topic_arr; + return MqttDecode_Subscribe(rx_buf, wire_len, &sub); +} + +TEST(decode_string_utf8_valid_ascii) +{ + byte t[] = { 'a', 'b' }; + int rc = decode_subscribe_with_topic(t, (word16)sizeof(t)); + ASSERT_TRUE(rc > 0); +} + +TEST(decode_string_utf8_valid_2byte) +{ + /* "café" tail: U+00E9 = C3 A9 */ + byte t[] = { 'c', 0xC3, 0xA9 }; + int rc = decode_subscribe_with_topic(t, (word16)sizeof(t)); + ASSERT_TRUE(rc > 0); +} + +TEST(decode_string_utf8_valid_3byte) +{ + /* U+20AC € = E2 82 AC */ + byte t[] = { 0xE2, 0x82, 0xAC }; + int rc = decode_subscribe_with_topic(t, (word16)sizeof(t)); + ASSERT_TRUE(rc > 0); +} + +TEST(decode_string_utf8_valid_4byte) +{ + /* U+10000 = F0 90 80 80 */ + byte t[] = { 0xF0, 0x90, 0x80, 0x80 }; + int rc = decode_subscribe_with_topic(t, (word16)sizeof(t)); + ASSERT_TRUE(rc > 0); +} + +TEST(decode_string_utf8_valid_max_codepoint) +{ + /* U+10FFFF = F4 8F BF BF */ + byte t[] = { 0xF4, 0x8F, 0xBF, 0xBF }; + int rc = decode_subscribe_with_topic(t, (word16)sizeof(t)); + ASSERT_TRUE(rc > 0); +} + +TEST(decode_string_utf8_valid_d7ff_just_below_surrogate) +{ + /* U+D7FF = ED 9F BF (last code point before the surrogate range) */ + byte t[] = { 0xED, 0x9F, 0xBF }; + int rc = decode_subscribe_with_topic(t, (word16)sizeof(t)); + ASSERT_TRUE(rc > 0); +} + +TEST(decode_string_utf8_valid_e000_just_above_surrogate) +{ + /* U+E000 = EE 80 80 (first code point after the surrogate range) */ + byte t[] = { 0xEE, 0x80, 0x80 }; + int rc = decode_subscribe_with_topic(t, (word16)sizeof(t)); + ASSERT_TRUE(rc > 0); +} + +TEST(decode_string_utf8_invalid_overlong_2byte) +{ + /* 0xC0 0xAF encodes '/' (0x2F) overlong; RFC 3629 forbids overlong. */ + byte t[] = { 0xC0, 0xAF }; + int rc = decode_subscribe_with_topic(t, (word16)sizeof(t)); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); +} + +TEST(decode_string_utf8_invalid_overlong_3byte) +{ + /* 0xE0 0x80 0xAF encodes '/' overlong */ + byte t[] = { 0xE0, 0x80, 0xAF }; + int rc = decode_subscribe_with_topic(t, (word16)sizeof(t)); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); +} + +TEST(decode_string_utf8_invalid_overlong_4byte) +{ + /* 0xF0 0x80 0x80 0xAF encodes '/' overlong */ + byte t[] = { 0xF0, 0x80, 0x80, 0xAF }; + int rc = decode_subscribe_with_topic(t, (word16)sizeof(t)); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); +} + +TEST(decode_string_utf8_invalid_surrogate_low) +{ + /* U+D800 = ED A0 80 — first surrogate, [MQTT-1.5.3-1] forbids. */ + byte t[] = { 0xED, 0xA0, 0x80 }; + int rc = decode_subscribe_with_topic(t, (word16)sizeof(t)); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); +} + +TEST(decode_string_utf8_invalid_surrogate_high) +{ + /* U+DFFF = ED BF BF — last surrogate */ + byte t[] = { 0xED, 0xBF, 0xBF }; + int rc = decode_subscribe_with_topic(t, (word16)sizeof(t)); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); +} + +TEST(decode_string_utf8_invalid_above_max) +{ + /* F4 90 80 80 encodes U+110000 (above U+10FFFF). */ + byte t[] = { 0xF4, 0x90, 0x80, 0x80 }; + int rc = decode_subscribe_with_topic(t, (word16)sizeof(t)); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); +} + +TEST(decode_string_utf8_invalid_f5_leading) +{ + /* F5..FF are not valid UTF-8 leading bytes. */ + byte t[] = { 0xF5, 0x80, 0x80, 0x80 }; + int rc = decode_subscribe_with_topic(t, (word16)sizeof(t)); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); +} + +TEST(decode_string_utf8_invalid_lone_continuation) +{ + /* 0x80 alone — continuation byte without a leading byte. */ + byte t[] = { 0x80 }; + int rc = decode_subscribe_with_topic(t, (word16)sizeof(t)); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); +} + +TEST(decode_string_utf8_invalid_truncated_2byte) +{ + /* 0xC2 needs one continuation but is alone. */ + byte t[] = { 0xC2 }; + int rc = decode_subscribe_with_topic(t, (word16)sizeof(t)); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); +} + +TEST(decode_string_utf8_invalid_truncated_4byte) +{ + /* 0xF0 0x90 0x80 needs one more continuation. */ + byte t[] = { 0xF0, 0x90, 0x80 }; + int rc = decode_subscribe_with_topic(t, (word16)sizeof(t)); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); +} + +TEST(decode_string_utf8_invalid_FE_FF) +{ + /* FE / FF are not valid UTF-8 anywhere. */ + byte t[] = { 0xFE }; + int rc = decode_subscribe_with_topic(t, (word16)sizeof(t)); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); +} + +TEST(decode_connect_invalid_utf8_clientid_overlong) +{ + /* CONNECT v3.1.1 with ClientId bytes 0xC0 0xAF (overlong). Reporter's + * dynamic test case `connect_clientid_overlong` — should now refuse. + * Wire: 0x10 + remain=14, "MQTT", level=4, flags=0x02, keepalive=60, + * client_id_len=0x0002, [C0 AF]. */ + byte rx_buf[] = { + 0x10, 14, + 0x00, 0x04, 'M', 'Q', 'T', 'T', + 0x04, + 0x02, + 0x00, 0x3C, + 0x00, 0x02, 0xC0, 0xAF + }; + MqttConnect dec; + int rc; + + XMEMSET(&dec, 0, sizeof(dec)); + rc = MqttDecode_Connect(rx_buf, (int)sizeof(rx_buf), &dec); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); +} + +TEST(decode_connect_invalid_utf8_clientid_surrogate) +{ + /* Reporter's `connect_clientid_surrogate` case: ClientId bytes ED A0 80 + * (U+D800). */ + byte rx_buf[] = { + 0x10, 15, + 0x00, 0x04, 'M', 'Q', 'T', 'T', + 0x04, + 0x02, + 0x00, 0x3C, + 0x00, 0x03, 0xED, 0xA0, 0x80 + }; + MqttConnect dec; + int rc; + + XMEMSET(&dec, 0, sizeof(dec)); + rc = MqttDecode_Connect(rx_buf, (int)sizeof(rx_buf), &dec); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); +} +#endif /* WOLFMQTT_BROKER */ + +TEST(decode_publish_invalid_utf8_topic) +{ + /* PUBLISH QoS 0 with topic bytes ED A0 80 (surrogate U+D800). + * Wire: 0x30, remain=7, topic_len=0x0003, [ED A0 80], payload "x". */ + byte buf[] = { + 0x30, 7, + 0x00, 0x03, 0xED, 0xA0, 0x80, + 'x', 'x' /* dummy payload bytes */ + }; + MqttPublish pub; + int rc; + + XMEMSET(&pub, 0, sizeof(pub)); + rc = MqttDecode_Publish(buf, (int)sizeof(buf), &pub); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); +} + +#ifdef WOLFMQTT_BROKER +/* CONNECT with a binary (non-UTF-8) password must decode successfully, + * because MQTT defines the Password field as Binary Data — not a UTF-8 + * string. The diff routes the password decode around MqttDecode_String to + * avoid spuriously rejecting valid binary passwords containing bytes like + * 0xC0 (which are not legal UTF-8 leading bytes). */ +TEST(decode_connect_v311_binary_password) +{ + /* Hand-built v3.1.1 CONNECT wire: + * fixed: 0x10, remain=20 + * var hdr: "MQTT" (4 + len2), level 4, flags 0xC2 (user|pass|clean), + * keepalive 60 + * payload: client_id "c" (3), username "u" (3), + * password [0xC0 0xAF] (length 2 + 2 = 4) + * remain = 10 + 3 + 3 + 4 = 20 */ + byte rx_buf[] = { + 0x10, 20, + 0x00, 0x04, 'M', 'Q', 'T', 'T', + 0x04, + 0xC2, /* flags: USER|PASS|CLEAN */ + 0x00, 0x3C, + 0x00, 0x01, 'c', + 0x00, 0x01, 'u', + 0x00, 0x02, 0xC0, 0xAF + }; + MqttConnect dec; + int rc; + word16 plen = 0; + + XMEMSET(&dec, 0, sizeof(dec)); + rc = MqttDecode_Connect(rx_buf, (int)sizeof(rx_buf), &dec); + ASSERT_TRUE(rc > 0); + ASSERT_NOT_NULL(dec.password); + + /* The broker reads the password length by stepping back 2 bytes from + * the password pointer (see src/mqtt_broker.c). Pin that contract: the + * 2 bytes preceding mc_connect->password must encode 0x0002. */ + ASSERT_EQ(MQTT_DATA_LEN_SIZE, + MqttDecode_Num((byte*)dec.password - MQTT_DATA_LEN_SIZE, + &plen, MQTT_DATA_LEN_SIZE)); + ASSERT_EQ(2, plen); + ASSERT_EQ((byte)0xC0, ((byte*)dec.password)[0]); + ASSERT_EQ((byte)0xAF, ((byte*)dec.password)[1]); +} + +/* CONNECT with an invalid UTF-8 username must be refused. Username is a + * UTF-8 string per [MQTT-3.1.3.4] and goes through MqttDecode_String. */ +TEST(decode_connect_invalid_utf8_username) +{ + /* Same shape as decode_connect_v311_binary_password but no password + * flag; username = surrogate ED A0 80. + * remain = 10 + 3 + 5 = 18 */ + byte rx_buf[] = { + 0x10, 18, + 0x00, 0x04, 'M', 'Q', 'T', 'T', + 0x04, + 0x82, /* flags: USER|CLEAN */ + 0x00, 0x3C, + 0x00, 0x01, 'c', + 0x00, 0x03, 0xED, 0xA0, 0x80 + }; + MqttConnect dec; + int rc; + + XMEMSET(&dec, 0, sizeof(dec)); + rc = MqttDecode_Connect(rx_buf, (int)sizeof(rx_buf), &dec); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); +} +#endif /* WOLFMQTT_BROKER */ + /* ============================================================================ * MqttEncode_Publish * ============================================================================ */ @@ -2567,6 +2923,33 @@ void run_mqtt_packet_tests(void) RUN_TEST(decode_vbi_overlong_2byte_127); RUN_TEST(encode_decode_vbi_roundtrip); + /* UTF-8 well-formedness validation [MQTT-1.5.3-1] */ +#ifdef WOLFMQTT_BROKER + RUN_TEST(decode_string_utf8_valid_ascii); + RUN_TEST(decode_string_utf8_valid_2byte); + RUN_TEST(decode_string_utf8_valid_3byte); + RUN_TEST(decode_string_utf8_valid_4byte); + RUN_TEST(decode_string_utf8_valid_max_codepoint); + RUN_TEST(decode_string_utf8_valid_d7ff_just_below_surrogate); + RUN_TEST(decode_string_utf8_valid_e000_just_above_surrogate); + RUN_TEST(decode_string_utf8_invalid_overlong_2byte); + RUN_TEST(decode_string_utf8_invalid_overlong_3byte); + RUN_TEST(decode_string_utf8_invalid_overlong_4byte); + RUN_TEST(decode_string_utf8_invalid_surrogate_low); + RUN_TEST(decode_string_utf8_invalid_surrogate_high); + RUN_TEST(decode_string_utf8_invalid_above_max); + RUN_TEST(decode_string_utf8_invalid_f5_leading); + RUN_TEST(decode_string_utf8_invalid_lone_continuation); + RUN_TEST(decode_string_utf8_invalid_truncated_2byte); + RUN_TEST(decode_string_utf8_invalid_truncated_4byte); + RUN_TEST(decode_string_utf8_invalid_FE_FF); + RUN_TEST(decode_connect_invalid_utf8_clientid_overlong); + RUN_TEST(decode_connect_invalid_utf8_clientid_surrogate); + RUN_TEST(decode_connect_v311_binary_password); + RUN_TEST(decode_connect_invalid_utf8_username); +#endif + RUN_TEST(decode_publish_invalid_utf8_topic); + /* MqttEncode_Publish */ RUN_TEST(encode_publish_qos1_packet_id_zero); RUN_TEST(encode_publish_qos2_packet_id_zero); From 8aedf69018fa472db09b1d301c5ef7017453a899 Mon Sep 17 00:00:00 2001 From: Eric Blankenhorn Date: Fri, 1 May 2026 08:08:59 -0500 Subject: [PATCH 04/32] Fix Unsupported Protocol Level Is Not Rejected with CONNACK 0x01 --- src/mqtt_broker.c | 25 +++++++++++ src/mqtt_packet.c | 11 ++++- tests/test_broker_connect.c | 85 +++++++++++++++++++++++++++++++++++++ 3 files changed, 119 insertions(+), 2 deletions(-) diff --git a/src/mqtt_broker.c b/src/mqtt_broker.c index 8c13963fe..48fef4e13 100644 --- a/src/mqtt_broker.c +++ b/src/mqtt_broker.c @@ -2737,6 +2737,31 @@ static int BrokerHandle_Connect(BrokerClient* bc, int rx_len, ack.protocol_level = mc.protocol_level; #endif + /* [MQTT-3.1.2-2] Reject unsupported Protocol Level. Per spec, the server + * MUST respond with CONNACK 0x01 (unacceptable protocol level) and then + * disconnect. v3.1.1 (level 4) is always supported; v5 (level 5) only + * when WOLFMQTT_V5 is compiled in. Other values reach this branch and + * are refused before any session/will/auth processing. */ + if (mc.protocol_level != MQTT_CONNECT_PROTOCOL_LEVEL_4 +#ifdef WOLFMQTT_V5 + && mc.protocol_level != MQTT_CONNECT_PROTOCOL_LEVEL_5 +#endif + ) { + WBLOG_ERR(broker, + "broker: unsupported protocol level %u sock=%d [MQTT-3.1.2-2]", + (unsigned)mc.protocol_level, (int)bc->sock); + ack.return_code = MQTT_CONNECT_ACK_CODE_REFUSED_PROTO; +#ifdef WOLFMQTT_V5 + /* The client claimed an unknown protocol; we don't know what wire + * format they expect for the CONNACK. Fall back to the v3.1.1 + * shape (no properties), which is what [MQTT-3.1.2-2] specifies + * verbatim and is the simplest format any reasonable client can + * parse. */ + ack.protocol_level = MQTT_CONNECT_PROTOCOL_LEVEL_4; +#endif + goto send_connack; + } + /* Store client ID */ #ifdef WOLFMQTT_STATIC_MEMORY bc->client_id[0] = '\0'; diff --git a/src/mqtt_packet.c b/src/mqtt_packet.c index 417d0c170..222ccd3dc 100644 --- a/src/mqtt_packet.c +++ b/src/mqtt_packet.c @@ -1205,7 +1205,13 @@ int MqttDecode_Connect(byte *rx_buf, int rx_buf_len, MqttConnect *mc_connect) #ifdef WOLFMQTT_V5 mc_connect->props = NULL; - if (mc_connect->protocol_level >= MQTT_CONNECT_PROTOCOL_LEVEL_5) { + /* Only decode v5 properties when the level is exactly 5. Treating any + * level >= 5 as v5 incorrectly consumes a properties-length byte for + * unsupported levels (e.g., 6) — the broker's [MQTT-3.1.2-2] rejection + * runs after this function, so we must let the wire decode under the + * level the spec actually defines for it (here: nothing, fall through + * to the v3.1.1-shape payload). */ + if (mc_connect->protocol_level == MQTT_CONNECT_PROTOCOL_LEVEL_5) { /* Decode Length of Properties */ if (rx_buf_len < (rx_payload - rx_buf)) { return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_OUT_OF_BUFFER); @@ -1252,7 +1258,8 @@ int MqttDecode_Connect(byte *rx_buf, int rx_buf_len, MqttConnect *mc_connect) #ifdef WOLFMQTT_V5 mc_connect->lwt_msg->props = NULL; - if (mc_connect->protocol_level >= MQTT_CONNECT_PROTOCOL_LEVEL_5) { + /* See note above: only level 5 carries v5 LWT properties on the wire. */ + if (mc_connect->protocol_level == MQTT_CONNECT_PROTOCOL_LEVEL_5) { word32 lwt_props_len = 0; int lwt_tmp; /* Decode Length of LWT Properties */ diff --git a/tests/test_broker_connect.c b/tests/test_broker_connect.c index fb1249a8c..19ffb07b0 100644 --- a/tests/test_broker_connect.c +++ b/tests/test_broker_connect.c @@ -360,6 +360,88 @@ TEST(connect_v311_explicit_auto_prefix_refused) MqttBroker_Free(&broker); } +/* [MQTT-3.1.2-2] Unsupported Protocol Level must be refused with CONNACK + * 0x01 (REFUSED_PROTO) followed by disconnect. The CONNACK MUST come back + * in v3.1.1 wire shape (4 bytes: type, remain=2, flags, code) regardless + * of the level the client claimed — we don't know what their wire format + * actually is, and the spec text specifies "CONNACK return code 0x01" + * verbatim. + * + * Cases below mirror the dynamic test evidence in the issue report: + * level 0x03 -> refused + * level 0x06 -> refused (and not silently accepted as v5 just because + * the encoder uses level >= 5 as a mode switch). + */ +static void run_unsupported_level(byte level) +{ + MqttBroker broker; + MqttBrokerNet net; + /* Wire matches the reporter's case for level X with ClientId "A": + * 10 0d 00 04 4d 51 54 54 LL 02 00 3c 00 01 41 + * (15 bytes total: fixed header 2 + remain 13 = type+nameLen+name+ + * level+flags+keepalive+idLen+id) */ + byte connect[] = { + 0x10, 13, + 0x00, 0x04, 'M', 'Q', 'T', 'T', + 0x00, /* protocol_level placeholder */ + 0x02, /* CleanSession=1 */ + 0x00, 0x3C, + 0x00, 0x01, 'A' + }; + connect[8] = level; + + install_mock_net(&net); + XMEMSET(&broker, 0, sizeof(broker)); + ASSERT_EQ(MQTT_CODE_SUCCESS, MqttBroker_Init(&broker, &net)); + ASSERT_EQ(MQTT_CODE_SUCCESS, MqttBroker_Start(&broker)); + + reset_mock_state(connect, sizeof(connect)); + run_broker_one_connect(&broker); + + /* Expect a v3.1.1-shaped CONNACK: 0x20, 0x02, flags, REFUSED_PROTO. + * [MQTT-3.1.2-2] mandates 0x01 for any unsupported level regardless of + * what the client claimed they spoke. */ + ASSERT_TRUE(g_out_len >= 4); + ASSERT_EQ(0x20, g_out_buf[0]); + ASSERT_EQ(0x02, g_out_buf[1]); + ASSERT_EQ(0x00, g_out_buf[2]); + ASSERT_EQ(MQTT_CONNECT_ACK_CODE_REFUSED_PROTO, g_out_buf[3]); + ASSERT_TRUE(g_client_closed); + + MqttBroker_Stop(&broker); + MqttBroker_Free(&broker); +} + +TEST(connect_unsupported_level_3_refused) +{ + run_unsupported_level(0x03); +} + +TEST(connect_unsupported_level_6_refused) +{ + /* Catches the secondary issue the reporter flagged in section 3 of #496: + * pre-fix MqttDecode_Connect treated `protocol_level >= 5` as v5, so for + * level 6 the decoder consumed the byte at the v5 props_len position + * (0x00 here, parsed as zero-length props), then read the next two bytes + * (0x00 0x01) as the ClientId length prefix and 'A' as the start of a + * 1-byte ClientId. With WOLFMQTT_V5 the decoder still returned success + * for this particular wire, but it produced a misaligned MqttConnect with + * ClientId="" — so the test below also fails on the post-decode path + * unless the broker's [MQTT-3.1.2-2] check rejects the level. (For wires + * without enough trailing bytes, the pre-fix decoder instead returned + * OUT_OF_BUFFER and never emitted a CONNACK at all, which would also + * fail the `g_out_len >= 4` assertion.) Either way, this test pins the + * fix at both layers. */ + run_unsupported_level(0x06); +} + +TEST(connect_unsupported_level_127_refused) +{ + /* Top of the byte range — guards against a future "treat high values + * as latest known" mutation. */ + run_unsupported_level(0x7F); +} + #ifdef WOLFMQTT_V5 /* v5 dropped the [MQTT-3.1.3-8] CleanSession=1-only restriction; an empty * ClientId is acceptable with any Clean Start value. The broker MUST emit @@ -463,6 +545,9 @@ int main(int argc, char** argv) RUN_TEST(connect_v311_emptyid_clean1_accepted); RUN_TEST(connect_v311_nonempty_clean0_accepted); RUN_TEST(connect_v311_explicit_auto_prefix_refused); + RUN_TEST(connect_unsupported_level_3_refused); + RUN_TEST(connect_unsupported_level_6_refused); + RUN_TEST(connect_unsupported_level_127_refused); #ifdef WOLFMQTT_V5 RUN_TEST(connect_v5_emptyid_assigned_id_emitted); RUN_TEST(connect_v5_emptyid_clean0_accepted); From 02cb54398791b11ab69328abff5553664242e857 Mon Sep 17 00:00:00 2001 From: Eric Blankenhorn Date: Fri, 1 May 2026 09:05:02 -0500 Subject: [PATCH 05/32] Fix CONNACK Flags Receive-Side Validation --- src/mqtt_packet.c | 15 +++++++ tests/test_mqtt_packet.c | 88 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+) diff --git a/src/mqtt_packet.c b/src/mqtt_packet.c index 222ccd3dc..3d3622120 100644 --- a/src/mqtt_packet.c +++ b/src/mqtt_packet.c @@ -1388,6 +1388,21 @@ int MqttDecode_ConnectAck(byte *rx_buf, int rx_buf_len, connect_ack->flags = *rx_payload++; connect_ack->return_code = *rx_payload++; + /* [MQTT-3.2.2-1] Bits 7-1 of the Connect Acknowledge Flags byte are + * reserved and MUST be 0. Any other value is a protocol violation; + * [MQTT-4.8.0-1] requires the receiver to close the connection. */ + if ((connect_ack->flags & + (byte)~MQTT_CONNECT_ACK_FLAG_SESSION_PRESENT) != 0) { + return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_MALFORMED_DATA); + } + /* [MQTT-3.2.2-4] If the CONNACK return code is non-zero (CONNECT + * refused), Session Present MUST be 0. A refused CONNACK that + * claims an existing session is malformed. */ + if (connect_ack->return_code != MQTT_CONNECT_ACK_CODE_ACCEPTED && + (connect_ack->flags & MQTT_CONNECT_ACK_FLAG_SESSION_PRESENT)) { + return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_MALFORMED_DATA); + } + #ifdef WOLFMQTT_V5 connect_ack->props = NULL; if (connect_ack->protocol_level >= MQTT_CONNECT_PROTOCOL_LEVEL_5) { diff --git a/tests/test_mqtt_packet.c b/tests/test_mqtt_packet.c index a727a9bed..139a93462 100644 --- a/tests/test_mqtt_packet.c +++ b/tests/test_mqtt_packet.c @@ -1092,6 +1092,86 @@ TEST(decode_connack_malformed_remain_len_one) ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); } +/* [MQTT-3.2.2-1] / [MQTT-3.2.2-4] CONNACK Flags receive-side validation. + * + * The Connect Acknowledge Flags byte has only bit 0 (Session Present) + * defined; bits 7-1 are reserved and MUST be 0. Additionally, a non-zero + * return code (refusal) MUST come back with Session Present = 0. The + * decoder must reject violations so the client closes the connection + * per [MQTT-4.8.0-1]. + * + * Helper: build a 4-byte v3.1.1 CONNACK with the given flags+return_code + * and ask MqttDecode_ConnectAck what it returns. */ +static int decode_connack_flags(byte flags, byte return_code) +{ + byte buf[4]; + MqttConnectAck ack; + buf[0] = MQTT_PACKET_TYPE_SET(MQTT_PACKET_TYPE_CONNECT_ACK); + buf[1] = 2; + buf[2] = flags; + buf[3] = return_code; + XMEMSET(&ack, 0, sizeof(ack)); + return MqttDecode_ConnectAck(buf, (int)sizeof(buf), &ack); +} + +TEST(decode_connack_flags_session_present_accepted) +{ + /* SP=1 with return_code=0 is the canonical resumed-session case. */ + int rc = decode_connack_flags(0x01, MQTT_CONNECT_ACK_CODE_ACCEPTED); + ASSERT_TRUE(rc > 0); +} + +TEST(decode_connack_flags_no_session_accepted) +{ + int rc = decode_connack_flags(0x00, MQTT_CONNECT_ACK_CODE_ACCEPTED); + ASSERT_TRUE(rc > 0); +} + +TEST(decode_connack_flags_reserved_bit_1_rejected) +{ + /* 0x02: bit 1 set (reserved). */ + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, + decode_connack_flags(0x02, MQTT_CONNECT_ACK_CODE_ACCEPTED)); +} + +TEST(decode_connack_flags_reserved_bit_7_rejected) +{ + /* 0x80: bit 7 set (reserved). */ + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, + decode_connack_flags(0x80, MQTT_CONNECT_ACK_CODE_ACCEPTED)); +} + +TEST(decode_connack_flags_all_reserved_rejected) +{ + /* 0xFE: bits 7-1 all set, SP=0. */ + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, + decode_connack_flags(0xFE, MQTT_CONNECT_ACK_CODE_ACCEPTED)); +} + +TEST(decode_connack_flags_all_bits_rejected) +{ + /* 0xFF: bits 7-1 set + SP=1. Reserved-bit check fires first. */ + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, + decode_connack_flags(0xFF, MQTT_CONNECT_ACK_CODE_ACCEPTED)); +} + +TEST(decode_connack_refused_with_session_present_rejected) +{ + /* [MQTT-3.2.2-4]: refused CONNACK MUST have SP=0. flags=0x01 with a + * non-zero return code is malformed. */ + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, + decode_connack_flags(0x01, MQTT_CONNECT_ACK_CODE_REFUSED_PROTO)); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, + decode_connack_flags(0x01, MQTT_CONNECT_ACK_CODE_REFUSED_ID)); +} + +TEST(decode_connack_refused_without_session_present_accepted) +{ + /* Refusal with SP=0 is the legal shape. */ + int rc = decode_connack_flags(0x00, MQTT_CONNECT_ACK_CODE_REFUSED_PROTO); + ASSERT_TRUE(rc > 0); +} + /* ============================================================================ * MqttEncode_Subscribe * ============================================================================ */ @@ -2977,6 +3057,14 @@ void run_mqtt_packet_tests(void) RUN_TEST(decode_connack_valid); RUN_TEST(decode_connack_malformed_remain_len_zero); RUN_TEST(decode_connack_malformed_remain_len_one); + RUN_TEST(decode_connack_flags_session_present_accepted); + RUN_TEST(decode_connack_flags_no_session_accepted); + RUN_TEST(decode_connack_flags_reserved_bit_1_rejected); + RUN_TEST(decode_connack_flags_reserved_bit_7_rejected); + RUN_TEST(decode_connack_flags_all_reserved_rejected); + RUN_TEST(decode_connack_flags_all_bits_rejected); + RUN_TEST(decode_connack_refused_with_session_present_rejected); + RUN_TEST(decode_connack_refused_without_session_present_accepted); /* MqttEncode_Subscribe */ RUN_TEST(encode_subscribe_packet_id_zero); From f188016a99b88678c9644598de6d81f02f56bc24 Mon Sep 17 00:00:00 2001 From: Eric Blankenhorn Date: Fri, 1 May 2026 09:15:21 -0500 Subject: [PATCH 06/32] Fix PUBLISH DUP Initial-Send and QoS0 Constraint Is Not Enforced by the Generic Encoder --- src/mqtt_packet.c | 7 ++++ tests/test_mqtt_packet.c | 70 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/src/mqtt_packet.c b/src/mqtt_packet.c index 3d3622120..5cff3cdff 100644 --- a/src/mqtt_packet.c +++ b/src/mqtt_packet.c @@ -1547,6 +1547,13 @@ int MqttEncode_Publish(byte *tx_buf, int tx_buf_len, MqttPublish *publish, } variable_len += MQTT_DATA_LEN_SIZE; /* For packet_id */ } + else if (publish->duplicate) { + /* [MQTT-3.3.1-2] DUP MUST be 0 for all QoS 0 PUBLISH messages. + * The decoder rejects this combination via MqttPacket_FixedHeader + * FlagsValid; mirror the constraint at the encoder boundary so the + * library never produces a forbidden wire packet for a caller. */ + return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_BAD_ARG); + } #ifdef WOLFMQTT_V5 if (publish->protocol_level >= MQTT_CONNECT_PROTOCOL_LEVEL_5) { diff --git a/tests/test_mqtt_packet.c b/tests/test_mqtt_packet.c index 139a93462..9ad1ea68f 100644 --- a/tests/test_mqtt_packet.c +++ b/tests/test_mqtt_packet.c @@ -794,6 +794,73 @@ TEST(encode_publish_qos0_no_flags_in_header) ASSERT_EQ(0, (int)MQTT_PACKET_FLAGS_GET(tx_buf[0])); } +/* [MQTT-3.3.1-2] DUP MUST be 0 for all QoS 0 messages. The encoder must + * refuse the forbidden combination at the API boundary so the library can't + * produce a wire packet that the decoder (and any spec-compliant receiver) + * would reject as malformed. */ +TEST(encode_publish_qos0_with_dup_rejected) +{ + byte tx_buf[64]; + byte payload[] = { 'x' }; + MqttPublish pub; + int rc; + + XMEMSET(&pub, 0, sizeof(pub)); + pub.topic_name = "a"; + pub.qos = MQTT_QOS_0; + pub.duplicate = 1; + pub.buffer = payload; + pub.total_len = sizeof(payload); + + rc = MqttEncode_Publish(tx_buf, (int)sizeof(tx_buf), &pub, 0); + ASSERT_EQ(MQTT_CODE_ERROR_BAD_ARG, rc); +} + +/* QoS 1 with DUP=1 is a legitimate retransmission shape per [MQTT-4.3.2]. + * Pin that the new check is QoS-0-specific and doesn't break retransmits. */ +TEST(encode_publish_qos1_with_dup_accepted) +{ + byte tx_buf[64]; + byte payload[] = { 'x' }; + MqttPublish pub; + int rc; + + XMEMSET(&pub, 0, sizeof(pub)); + pub.topic_name = "a"; + pub.qos = MQTT_QOS_1; + pub.packet_id = 42; + pub.duplicate = 1; + pub.buffer = payload; + pub.total_len = sizeof(payload); + + rc = MqttEncode_Publish(tx_buf, (int)sizeof(tx_buf), &pub, 0); + ASSERT_TRUE(rc > 0); + /* Fixed-header low nibble: DUP|QoS1 = 0x8 | 0x2 = 0xA. */ + ASSERT_EQ(0xA, (int)MQTT_PACKET_FLAGS_GET(tx_buf[0])); +} + +/* QoS 2 with DUP=1 is also a legitimate retransmission shape per [MQTT-4.3.3]. */ +TEST(encode_publish_qos2_with_dup_accepted) +{ + byte tx_buf[64]; + byte payload[] = { 'x' }; + MqttPublish pub; + int rc; + + XMEMSET(&pub, 0, sizeof(pub)); + pub.topic_name = "a"; + pub.qos = MQTT_QOS_2; + pub.packet_id = 42; + pub.duplicate = 1; + pub.buffer = payload; + pub.total_len = sizeof(payload); + + rc = MqttEncode_Publish(tx_buf, (int)sizeof(tx_buf), &pub, 0); + ASSERT_TRUE(rc > 0); + /* DUP|QoS2 = 0x8 | 0x4 = 0xC. */ + ASSERT_EQ(0xC, (int)MQTT_PACKET_FLAGS_GET(tx_buf[0])); +} + /* f-2360: topic_name with strlen > 65535 must not produce a "successful" * encode. MqttEncode_String returns -1 for oversize strings; the encoder * must surface that as a negative return rather than adding -1 to the @@ -3038,6 +3105,9 @@ void run_mqtt_packet_tests(void) RUN_TEST(encode_publish_qos1_retain_flags_in_header); RUN_TEST(encode_publish_qos2_duplicate_flags_in_header); RUN_TEST(encode_publish_qos0_no_flags_in_header); + RUN_TEST(encode_publish_qos0_with_dup_rejected); + RUN_TEST(encode_publish_qos1_with_dup_accepted); + RUN_TEST(encode_publish_qos2_with_dup_accepted); RUN_TEST(encode_publish_topic_oversized_rejected); /* MqttDecode_Publish */ From 8c6fc32104e1f3ab50879108d53195c269a5a14a Mon Sep 17 00:00:00 2001 From: Eric Blankenhorn Date: Fri, 1 May 2026 12:03:01 -0500 Subject: [PATCH 07/32] Handle QoS2 Duplicate PUBLISH Forwarded Before PUBREL --- src/mqtt_broker.c | 200 ++++++++++++- tests/test_broker_connect.c | 552 +++++++++++++++++++++++++++++++++--- wolfmqtt/mqtt_broker.h | 31 ++ 3 files changed, 744 insertions(+), 39 deletions(-) diff --git a/src/mqtt_broker.c b/src/mqtt_broker.c index 48fef4e13..b0590da59 100644 --- a/src/mqtt_broker.c +++ b/src/mqtt_broker.c @@ -1220,11 +1220,158 @@ static int BrokerNetDisconnect(void* context) /* -------------------------------------------------------------------------- */ /* Client management */ /* -------------------------------------------------------------------------- */ +/* -------------------------------------------------------------------------- */ +/* Inbound QoS 2 dedup state (per [MQTT-4.3.3]) */ +/* */ +/* Track packet IDs for which we've sent PUBREC and are waiting for PUBREL. */ +/* A duplicate PUBLISH carrying the same packet ID gets PUBREC'd again but */ +/* must NOT be re-delivered to subscribers. The state is per-client and is */ +/* cleared on disconnect; surviving across reconnect would require the */ +/* broader session-state work (see #485/#489/#494). */ +/* -------------------------------------------------------------------------- */ + +/* Returns 1 if packet_id is currently awaiting PUBREL, 0 otherwise. */ +static int BrokerInboundQos2_Contains(BrokerClient* bc, word16 packet_id) +{ + if (bc == NULL || packet_id == 0) { + return 0; + } +#ifdef WOLFMQTT_STATIC_MEMORY + { + int i; + for (i = 0; i < BROKER_MAX_INBOUND_QOS2; i++) { + if (bc->qos2_pending[i] == packet_id) { + return 1; + } + } + } +#else + { + BrokerInboundQos2* cur = bc->qos2_pending; + while (cur != NULL) { + if (cur->packet_id == packet_id) { + return 1; + } + cur = cur->next; + } + } +#endif + return 0; +} + +/* Add packet_id to the awaiting-PUBREL set. Returns MQTT_CODE_SUCCESS on + * success, MQTT_CODE_ERROR_OUT_OF_BUFFER if the per-client cap is reached, + * or MQTT_CODE_ERROR_MEMORY if the underlying allocator fails. Idempotent: + * a second add of an already-present packet_id is a no-op success. */ +static int BrokerInboundQos2_Add(BrokerClient* bc, word16 packet_id) +{ + if (bc == NULL || packet_id == 0) { + return MQTT_CODE_ERROR_BAD_ARG; + } + if (BrokerInboundQos2_Contains(bc, packet_id)) { + return MQTT_CODE_SUCCESS; + } +#ifdef WOLFMQTT_STATIC_MEMORY + { + int i; + for (i = 0; i < BROKER_MAX_INBOUND_QOS2; i++) { + if (bc->qos2_pending[i] == 0) { + bc->qos2_pending[i] = packet_id; + return MQTT_CODE_SUCCESS; + } + } + return MQTT_CODE_ERROR_OUT_OF_BUFFER; + } +#else + { + BrokerInboundQos2* node; + /* Enforce the same per-client cap in dynamic-memory builds so a + * misbehaving client cannot grow the list to ~65 535 entries. */ + if (bc->qos2_pending_count >= BROKER_MAX_INBOUND_QOS2) { + return MQTT_CODE_ERROR_OUT_OF_BUFFER; + } + node = (BrokerInboundQos2*)WOLFMQTT_MALLOC(sizeof(*node)); + if (node == NULL) { + return MQTT_CODE_ERROR_MEMORY; + } + node->packet_id = packet_id; + node->next = bc->qos2_pending; + bc->qos2_pending = node; + bc->qos2_pending_count++; + return MQTT_CODE_SUCCESS; + } +#endif +} + +/* Remove packet_id from the awaiting-PUBREL set. No-op if not present. */ +static void BrokerInboundQos2_Remove(BrokerClient* bc, word16 packet_id) +{ + if (bc == NULL || packet_id == 0) { + return; + } +#ifdef WOLFMQTT_STATIC_MEMORY + { + int i; + for (i = 0; i < BROKER_MAX_INBOUND_QOS2; i++) { + if (bc->qos2_pending[i] == packet_id) { + bc->qos2_pending[i] = 0; + return; + } + } + } +#else + { + BrokerInboundQos2* prev = NULL; + BrokerInboundQos2* cur = bc->qos2_pending; + while (cur != NULL) { + if (cur->packet_id == packet_id) { + if (prev == NULL) { + bc->qos2_pending = cur->next; + } + else { + prev->next = cur->next; + } + WOLFMQTT_FREE(cur); + if (bc->qos2_pending_count > 0) { + bc->qos2_pending_count--; + } + return; + } + prev = cur; + cur = cur->next; + } + } +#endif +} + +/* Clear all entries (called on client free / disconnect). */ +static void BrokerInboundQos2_Clear(BrokerClient* bc) +{ + if (bc == NULL) { + return; + } +#ifdef WOLFMQTT_STATIC_MEMORY + XMEMSET(bc->qos2_pending, 0, sizeof(bc->qos2_pending)); +#else + { + BrokerInboundQos2* cur = bc->qos2_pending; + while (cur != NULL) { + BrokerInboundQos2* next = cur->next; + WOLFMQTT_FREE(cur); + cur = next; + } + bc->qos2_pending = NULL; + bc->qos2_pending_count = 0; + } +#endif +} + static void BrokerClient_Free(BrokerClient* bc) { if (bc == NULL) { return; } + BrokerInboundQos2_Clear(bc); #ifdef ENABLE_MQTT_WEBSOCKET if (bc->ws_ctx != NULL) { @@ -3330,6 +3477,7 @@ static int BrokerHandle_Publish(BrokerClient* bc, int rx_len, MqttPublishResp resp; byte* payload = NULL; char* topic = NULL; + int qos2_duplicate = 0; #ifdef WOLFMQTT_STATIC_MEMORY char topic_buf[BROKER_MAX_TOPIC_LEN]; #endif @@ -3346,7 +3494,10 @@ static int BrokerHandle_Publish(BrokerClient* bc, int rx_len, } /* [MQTT-3.3.2-2] PUBLISH topic must not contain wildcard characters. - * Return MALFORMED_DATA so dispatch closes the connection. */ + * Run before any state-mutating logic so a malformed PUBLISH cannot + * briefly populate the QoS 2 dedup set. Set rc and jump to cleanup so + * dispatch closes the connection AND any v5 props/topic allocations + * are freed. */ if (pub.topic_name && pub.topic_name_len > 0) { word16 i; for (i = 0; i < pub.topic_name_len; i++) { @@ -3354,7 +3505,36 @@ static int BrokerHandle_Publish(BrokerClient* bc, int rx_len, WBLOG_ERR(broker, "broker: PUBLISH topic contains wildcard sock=%d", (int)bc->sock); - return MQTT_CODE_ERROR_MALFORMED_DATA; + rc = MQTT_CODE_ERROR_MALFORMED_DATA; + goto publish_cleanup; + } + } + } + + /* [MQTT-4.3.3] QoS 2 duplicate detection. If we already PUBREC'd this + * packet_id and are still waiting for PUBREL, treat the inbound PUBLISH + * as a retransmission: send another PUBREC but DO NOT re-deliver the + * application message to subscribers and DO NOT re-store the retained + * payload. */ + if (pub.qos == MQTT_QOS_2) { + if (BrokerInboundQos2_Contains(bc, pub.packet_id)) { + WBLOG_DBG(broker, + "broker: QoS2 duplicate PUBLISH sock=%d packet_id=%u " + "[MQTT-4.3.3]", (int)bc->sock, pub.packet_id); + qos2_duplicate = 1; + } + else { + int add_rc = BrokerInboundQos2_Add(bc, pub.packet_id); + if (add_rc != MQTT_CODE_SUCCESS) { + /* Either per-client cap reached or allocation failure. + * Treat as a protocol-level error so dispatch closes the + * connection. Log the underlying rc so operators can tell + * "client misbehaved" from "broker out of memory". */ + WBLOG_ERR(broker, + "broker: QoS2 inbound add failed sock=%d packet_id=%u " + "rc=%d", (int)bc->sock, pub.packet_id, add_rc); + rc = MQTT_CODE_ERROR_MALFORMED_DATA; + goto publish_cleanup; } } } @@ -3383,8 +3563,9 @@ static int BrokerHandle_Publish(BrokerClient* bc, int rx_len, payload = pub.buffer; #ifdef WOLFMQTT_BROKER_RETAINED - /* Handle retained messages */ - if (topic != NULL && pub.retain) { + /* Handle retained messages — skipped for QoS 2 duplicates: the original + * PUBLISH already updated the retained store. */ + if (!qos2_duplicate && topic != NULL && pub.retain) { if (pub.total_len == 0) { BrokerRetained_Delete(broker, topic); } @@ -3411,7 +3592,10 @@ static int BrokerHandle_Publish(BrokerClient* bc, int rx_len, } #endif /* WOLFMQTT_BROKER_RETAINED */ - if (topic != NULL && (payload != NULL || pub.total_len == 0)) { + /* Fan-out is skipped for QoS 2 duplicates: subscribers already received + * the application message from the original PUBLISH ([MQTT-4.3.3]). */ + if (!qos2_duplicate && + topic != NULL && (payload != NULL || pub.total_len == 0)) { /* Fan out to matching subscribers */ #ifdef WOLFMQTT_STATIC_MEMORY { @@ -3484,6 +3668,7 @@ static int BrokerHandle_Publish(BrokerClient* bc, int rx_len, } } +publish_cleanup: #ifdef WOLFMQTT_V5 if (pub.props) { (void)MqttProps_Free(pub.props); @@ -3515,6 +3700,11 @@ static int BrokerHandle_PublishRel(BrokerClient* bc, int rx_len) return rc; } + /* [MQTT-4.3.3] QoS 2 step 3: discard the stored Packet Identifier so a + * later PUBLISH with the same ID is treated as a fresh delivery. PUBREL + * for an unknown ID is idempotent — we still PUBCOMP it. */ + BrokerInboundQos2_Remove(bc, resp.packet_id); + #ifdef WOLFMQTT_V5 if (resp.props) { (void)MqttProps_Free(resp.props); diff --git a/tests/test_broker_connect.c b/tests/test_broker_connect.c index 19ffb07b0..d682f6031 100644 --- a/tests/test_broker_connect.c +++ b/tests/test_broker_connect.c @@ -47,18 +47,43 @@ #include "tests/unit_test.h" /* Mock socket constants */ -#define MOCK_LISTEN_SOCK 100 -#define MOCK_CLIENT_SOCK 101 -#define MOCK_BUF_SZ 1024 - -/* Per-test mock state. Reset by reset_mock_state() before each scenario. */ -static byte g_in_buf[MOCK_BUF_SZ]; -static size_t g_in_len; -static size_t g_in_pos; -static byte g_out_buf[MOCK_BUF_SZ]; -static size_t g_out_len; -static int g_client_accepted; -static int g_client_closed; +#define MOCK_LISTEN_SOCK 100 +#define MOCK_CLIENT_SOCK_BASE 101 /* sock = base + index */ +#define MOCK_BUF_SZ 1024 +#define MOCK_MAX_CLIENTS 4 + +/* Per-mock-client state. Multi-client tests (e.g. QoS 2 dedup) need a + * subscriber and a publisher running through the same broker instance. */ +typedef struct MockClient { + byte in_buf[MOCK_BUF_SZ]; + size_t in_len; + size_t in_pos; + byte out_buf[MOCK_BUF_SZ]; + size_t out_len; + int closed; +} MockClient; + +static MockClient g_clients[MOCK_MAX_CLIENTS]; +static int g_clients_active; /* how many clients accept() will hand out */ +static int g_accept_count; /* incremented per successful accept() */ + +/* Legacy single-client tests use g_in_buf / g_out_buf / g_client_closed + * directly. Map them to client 0 so existing code keeps working. */ +#define g_in_buf (g_clients[0].in_buf) +#define g_in_len (g_clients[0].in_len) +#define g_in_pos (g_clients[0].in_pos) +#define g_out_buf (g_clients[0].out_buf) +#define g_out_len (g_clients[0].out_len) +#define g_client_closed (g_clients[0].closed) + +static int sock_to_idx(BROKER_SOCKET_T sock) +{ + int idx = (int)(sock - MOCK_CLIENT_SOCK_BASE); + if (idx < 0 || idx >= MOCK_MAX_CLIENTS) { + return -1; + } + return idx; +} /* -------------------------------------------------------------------------- */ /* Mock network callbacks */ @@ -76,9 +101,10 @@ static int mock_accept(void* ctx, BROKER_SOCKET_T listen_sock, BROKER_SOCKET_T* client_sock) { (void)ctx; (void)listen_sock; - if (!g_client_accepted) { - g_client_accepted = 1; - *client_sock = MOCK_CLIENT_SOCK; + if (g_accept_count < g_clients_active) { + *client_sock = + (BROKER_SOCKET_T)(MOCK_CLIENT_SOCK_BASE + g_accept_count); + g_accept_count++; return MQTT_CODE_SUCCESS; } return MQTT_CODE_ERROR_TIMEOUT; @@ -88,38 +114,50 @@ static int mock_read(void* ctx, BROKER_SOCKET_T sock, byte* buf, int buf_len, int timeout_ms) { int avail; + int idx = sock_to_idx(sock); + MockClient* mc; (void)ctx; (void)timeout_ms; - if (sock != MOCK_CLIENT_SOCK || g_client_closed) { + if (idx < 0) { return MQTT_CODE_ERROR_TIMEOUT; } - if (g_in_pos >= g_in_len) { + mc = &g_clients[idx]; + if (mc->closed || mc->in_pos >= mc->in_len) { return MQTT_CODE_ERROR_TIMEOUT; } - avail = (int)(g_in_len - g_in_pos); + avail = (int)(mc->in_len - mc->in_pos); if (buf_len > avail) { buf_len = avail; } - XMEMCPY(buf, g_in_buf + g_in_pos, (size_t)buf_len); - g_in_pos += (size_t)buf_len; + XMEMCPY(buf, mc->in_buf + mc->in_pos, (size_t)buf_len); + mc->in_pos += (size_t)buf_len; return buf_len; } static int mock_write(void* ctx, BROKER_SOCKET_T sock, const byte* buf, int buf_len, int timeout_ms) { - (void)ctx; (void)sock; (void)timeout_ms; - if (g_out_len + (size_t)buf_len > sizeof(g_out_buf)) { + int idx = sock_to_idx(sock); + MockClient* mc; + (void)ctx; (void)timeout_ms; + if (idx < 0) { + return MQTT_CODE_ERROR_NETWORK; + } + mc = &g_clients[idx]; + if (mc->out_len + (size_t)buf_len > sizeof(mc->out_buf)) { return MQTT_CODE_ERROR_NETWORK; } - XMEMCPY(g_out_buf + g_out_len, buf, (size_t)buf_len); - g_out_len += (size_t)buf_len; + XMEMCPY(mc->out_buf + mc->out_len, buf, (size_t)buf_len); + mc->out_len += (size_t)buf_len; return buf_len; } static int mock_close(void* ctx, BROKER_SOCKET_T sock) { - (void)ctx; (void)sock; - g_client_closed = 1; + int idx = sock_to_idx(sock); + (void)ctx; + if (idx >= 0) { + g_clients[idx].closed = 1; + } return MQTT_CODE_SUCCESS; } @@ -127,16 +165,32 @@ static int mock_close(void* ctx, BROKER_SOCKET_T sock) /* Test fixture */ /* -------------------------------------------------------------------------- */ +static void reset_mock_clients(int n_clients) +{ + int i; + for (i = 0; i < MOCK_MAX_CLIENTS; i++) { + XMEMSET(&g_clients[i], 0, sizeof(g_clients[i])); + } + g_clients_active = n_clients; + g_accept_count = 0; +} + +/* Append bytes to a client's input queue (i.e., what it appears to send to + * the broker). May be called multiple times to feed packets in stages. */ +static void mock_client_input_append(int idx, const byte* buf, size_t len) +{ + MockClient* mc = &g_clients[idx]; + if (mc->in_len + len > sizeof(mc->in_buf)) return; + XMEMCPY(mc->in_buf + mc->in_len, buf, len); + mc->in_len += len; +} + static void reset_mock_state(const byte* connect_buf, size_t connect_len) { - XMEMSET(g_in_buf, 0, sizeof(g_in_buf)); - XMEMSET(g_out_buf, 0, sizeof(g_out_buf)); - XMEMCPY(g_in_buf, connect_buf, connect_len); - g_in_len = connect_len; - g_in_pos = 0; - g_out_len = 0; - g_client_accepted = 0; - g_client_closed = 0; + reset_mock_clients(1); + if (connect_buf && connect_len > 0) { + mock_client_input_append(0, connect_buf, connect_len); + } } static void install_mock_net(MqttBrokerNet* net) @@ -530,6 +584,430 @@ TEST(connect_v5_emptyid_clean0_accepted) } #endif /* WOLFMQTT_V5 */ +/* -------------------------------------------------------------------------- */ +/* QoS 2 inbound duplicate-dedup tests [MQTT-4.3.3] */ +/* -------------------------------------------------------------------------- */ + +/* Walk a captured packet stream and count packets whose type-nibble matches + * `target`. Parses each fixed header's variable-length remaining length so a + * stream of multiple packets is handled correctly. Stops scanning on any + * malformed VBI or arithmetic overflow rather than guessing past it. */ +static int count_packets_of_type(const byte* buf, size_t len, byte target) +{ + size_t pos = 0; + int count = 0; + while (pos < len) { + byte type = (byte)((buf[pos] >> 4) & 0x0F); + size_t remain = 0; + size_t mult = 1; + size_t hdr_len = 1; + int vbi_complete = 0; + while (pos + hdr_len < len && hdr_len <= 5) { + byte b = buf[pos + hdr_len]; + remain += (size_t)(b & 0x7F) * mult; + hdr_len++; + if ((b & 0x80) == 0) { + vbi_complete = 1; + break; + } + mult *= 128; + } + if (!vbi_complete) { + /* malformed VBI or buffer ran out mid-VBI: hard stop */ + break; + } + if (type == target) { + count++; + } + /* Overflow-safe truncation check: hdr_len + remain must fit in + * (len - pos) without wrapping. */ + if (remain > len - pos - hdr_len) { + break; + } + pos += hdr_len + remain; + } + return count; +} + +/* [MQTT-4.3.3] / Method B: when the broker receives a duplicate QoS 2 + * PUBLISH carrying a packet ID that's still awaiting PUBREL, it MUST send + * another PUBREC to the publisher but MUST NOT re-deliver the application + * message to subscribers. This test wires up a subscriber and a publisher + * through the same broker, has the publisher send the same QoS 2 PUBLISH + * twice (the second with DUP=1), then send PUBREL, and verifies: + * + * subscriber out: exactly one forwarded PUBLISH + * publisher out: two PUBRECs (one per inbound PUBLISH) and one PUBCOMP + * + * Pre-fix the broker fanned out twice and the subscriber would see two + * forwarded PUBLISHes, breaking exactly-once delivery. */ +TEST(qos2_duplicate_publish_dedup) +{ + MqttBroker broker; + MqttBrokerNet net; + int i; + int sub_pubs; + int pub_pubrecs; + int pub_pubcomps; + + /* CONNECT for subscriber, ClientId "A" (clean=1, level=4). */ + static const byte connect_sub[] = { + 0x10, 0x0D, + 0x00, 0x04, 'M', 'Q', 'T', 'T', + 0x04, + 0x02, + 0x00, 0x3C, + 0x00, 0x01, 'A' + }; + /* CONNECT for publisher, ClientId "B". */ + static const byte connect_pub[] = { + 0x10, 0x0D, + 0x00, 0x04, 'M', 'Q', 'T', 'T', + 0x04, + 0x02, + 0x00, 0x3C, + 0x00, 0x01, 'B' + }; + /* SUBSCRIBE packet_id=1, filter "x", QoS 2. */ + static const byte subscribe_x[] = { + 0x82, 0x06, + 0x00, 0x01, + 0x00, 0x01, 'x', + 0x02 + }; + /* PUBLISH QoS 2, packet_id=7, topic "x", payload "first". + * remain = topic_len(2) + topic(1) + packet_id(2) + payload(5) = 10 */ + static const byte publish[] = { + 0x34, 0x0A, + 0x00, 0x01, 'x', + 0x00, 0x07, + 'f', 'i', 'r', 's', 't' + }; + /* Duplicate PUBLISH with DUP=1, same packet_id and payload. */ + static const byte publish_dup[] = { + 0x3C, 0x0A, + 0x00, 0x01, 'x', + 0x00, 0x07, + 'f', 'i', 'r', 's', 't' + }; + /* PUBREL packet_id=7. */ + static const byte pubrel[] = { + 0x62, 0x02, + 0x00, 0x07 + }; + + install_mock_net(&net); + XMEMSET(&broker, 0, sizeof(broker)); + ASSERT_EQ(MQTT_CODE_SUCCESS, MqttBroker_Init(&broker, &net)); + ASSERT_EQ(MQTT_CODE_SUCCESS, MqttBroker_Start(&broker)); + + reset_mock_clients(2); + /* Subscriber: CONNECT then SUBSCRIBE. */ + mock_client_input_append(0, connect_sub, sizeof(connect_sub)); + mock_client_input_append(0, subscribe_x, sizeof(subscribe_x)); + /* Publisher: CONNECT, PUBLISH, duplicate PUBLISH, PUBREL. */ + mock_client_input_append(1, connect_pub, sizeof(connect_pub)); + mock_client_input_append(1, publish, sizeof(publish)); + mock_client_input_append(1, publish_dup, sizeof(publish_dup)); + mock_client_input_append(1, pubrel, sizeof(pubrel)); + + /* Run enough Step() calls for the broker to: accept both clients, + * process subscriber's CONNECT+SUBSCRIBE (write CONNACK+SUBACK), + * process publisher's CONNECT (write CONNACK), process PUBLISH (fan + * out, write PUBREC), process duplicate PUBLISH (write PUBREC, no + * fan-out), process PUBREL (write PUBCOMP). */ + for (i = 0; i < 32; i++) { + MqttBroker_Step(&broker); + } + + sub_pubs = count_packets_of_type(g_clients[0].out_buf, + g_clients[0].out_len, MQTT_PACKET_TYPE_PUBLISH); + pub_pubrecs = count_packets_of_type(g_clients[1].out_buf, + g_clients[1].out_len, MQTT_PACKET_TYPE_PUBLISH_REC); + pub_pubcomps = count_packets_of_type(g_clients[1].out_buf, + g_clients[1].out_len, MQTT_PACKET_TYPE_PUBLISH_COMP); + + ASSERT_EQ(1, sub_pubs); /* dedup: only the first PUBLISH forwarded */ + ASSERT_EQ(2, pub_pubrecs); /* one PUBREC per inbound PUBLISH */ + ASSERT_EQ(1, pub_pubcomps); /* one PUBCOMP per PUBREL */ + + MqttBroker_Stop(&broker); + MqttBroker_Free(&broker); +} + +/* A PUBLISH with DUP=1 whose packet_id is NOT in the dedup set must be + * treated as a fresh delivery. The DUP flag is informational; correctness + * is determined by the per-client dedup state, not the wire flag. This is + * the recovering-client / cross-restart case: after the broker drops state + * (no inflight persistence today), a client retransmitting an in-flight + * PUBLISH with DUP=1 should still get its message delivered. */ +TEST(qos2_phantom_dup_publish_is_fresh) +{ + MqttBroker broker; + MqttBrokerNet net; + int i; + int sub_pubs; + int pub_pubrecs; + + static const byte connect_sub[] = { + 0x10, 0x0D, 0x00, 0x04, 'M', 'Q', 'T', 'T', + 0x04, 0x02, 0x00, 0x3C, 0x00, 0x01, 'A' + }; + static const byte connect_pub[] = { + 0x10, 0x0D, 0x00, 0x04, 'M', 'Q', 'T', 'T', + 0x04, 0x02, 0x00, 0x3C, 0x00, 0x01, 'B' + }; + static const byte subscribe_x[] = { + 0x82, 0x06, 0x00, 0x01, 0x00, 0x01, 'x', 0x02 + }; + /* PUBLISH QoS 2 with DUP=1 set, but packet_id never appeared before. + * remain = 3+2+5 = 10 */ + static const byte publish_dup_only[] = { + 0x3C, 0x0A, 0x00, 0x01, 'x', 0x00, 0x07, + 'f', 'i', 'r', 's', 't' + }; + + install_mock_net(&net); + XMEMSET(&broker, 0, sizeof(broker)); + ASSERT_EQ(MQTT_CODE_SUCCESS, MqttBroker_Init(&broker, &net)); + ASSERT_EQ(MQTT_CODE_SUCCESS, MqttBroker_Start(&broker)); + + reset_mock_clients(2); + mock_client_input_append(0, connect_sub, sizeof(connect_sub)); + mock_client_input_append(0, subscribe_x, sizeof(subscribe_x)); + mock_client_input_append(1, connect_pub, sizeof(connect_pub)); + mock_client_input_append(1, publish_dup_only, sizeof(publish_dup_only)); + + for (i = 0; i < 16; i++) { + MqttBroker_Step(&broker); + } + + sub_pubs = count_packets_of_type(g_clients[0].out_buf, + g_clients[0].out_len, MQTT_PACKET_TYPE_PUBLISH); + pub_pubrecs = count_packets_of_type(g_clients[1].out_buf, + g_clients[1].out_len, MQTT_PACKET_TYPE_PUBLISH_REC); + + /* Subscriber gets one forwarded PUBLISH; publisher gets one PUBREC. + * The DUP flag does NOT suppress fan-out — only an actual matching + * dedup-set entry does. */ + ASSERT_EQ(1, sub_pubs); + ASSERT_EQ(1, pub_pubrecs); + + MqttBroker_Stop(&broker); + MqttBroker_Free(&broker); +} + +/* After PUBREL clears the awaiting-PUBREL state, a subsequent PUBLISH with + * the same packet ID is a fresh delivery, not a duplicate. Pin the state + * removal so a regression in BrokerInboundQos2_Remove would surface. */ +TEST(qos2_publish_after_pubrel_is_fresh) +{ + MqttBroker broker; + MqttBrokerNet net; + int i; + int sub_pubs; + + static const byte connect_sub[] = { + 0x10, 0x0D, 0x00, 0x04, 'M', 'Q', 'T', 'T', + 0x04, 0x02, 0x00, 0x3C, 0x00, 0x01, 'A' + }; + static const byte connect_pub[] = { + 0x10, 0x0D, 0x00, 0x04, 'M', 'Q', 'T', 'T', + 0x04, 0x02, 0x00, 0x3C, 0x00, 0x01, 'B' + }; + static const byte subscribe_x[] = { + 0x82, 0x06, 0x00, 0x01, 0x00, 0x01, 'x', 0x02 + }; + static const byte publish[] = { + 0x34, 0x0A, 0x00, 0x01, 'x', 0x00, 0x07, + 'f', 'i', 'r', 's', 't' + }; + static const byte pubrel[] = { + 0x62, 0x02, 0x00, 0x07 + }; + /* Second PUBLISH reuses packet_id=7 AFTER PUBREL has cleared it. This + * is now a fresh delivery (no DUP flag). remain = 3+2+6 = 11 */ + static const byte publish_again[] = { + 0x34, 0x0B, 0x00, 0x01, 'x', 0x00, 0x07, + 's', 'e', 'c', 'o', 'n', 'd' + }; + + install_mock_net(&net); + XMEMSET(&broker, 0, sizeof(broker)); + ASSERT_EQ(MQTT_CODE_SUCCESS, MqttBroker_Init(&broker, &net)); + ASSERT_EQ(MQTT_CODE_SUCCESS, MqttBroker_Start(&broker)); + + reset_mock_clients(2); + mock_client_input_append(0, connect_sub, sizeof(connect_sub)); + mock_client_input_append(0, subscribe_x, sizeof(subscribe_x)); + mock_client_input_append(1, connect_pub, sizeof(connect_pub)); + mock_client_input_append(1, publish, sizeof(publish)); + mock_client_input_append(1, pubrel, sizeof(pubrel)); + mock_client_input_append(1, publish_again, sizeof(publish_again)); + + for (i = 0; i < 32; i++) { + MqttBroker_Step(&broker); + } + + sub_pubs = count_packets_of_type(g_clients[0].out_buf, + g_clients[0].out_len, MQTT_PACKET_TYPE_PUBLISH); + + /* Expect TWO forwarded PUBLISHes: the dedup state was cleared by the + * PUBREL, so the second PUBLISH with packet_id=7 is treated as fresh. */ + ASSERT_EQ(2, sub_pubs); + + MqttBroker_Stop(&broker); + MqttBroker_Free(&broker); +} + +/* Build a minimal QoS 2 PUBLISH wire packet with topic "x" and payload "p" + * for the given packet_id. Used by the cap-reached and clear-state tests + * which need many in-flight QoS 2 packets without the boilerplate of + * spelling out each one. Returns the encoded length (always 8). */ +static size_t build_qos2_pub(byte* out, word16 packet_id) +{ + /* remain = topic_len(2) + topic(1) + packet_id(2) + payload(1) = 6 */ + out[0] = 0x34; + out[1] = 0x06; + out[2] = 0x00; out[3] = 0x01; out[4] = 'x'; + out[5] = (byte)(packet_id >> 8); + out[6] = (byte)(packet_id & 0xFF); + out[7] = 'p'; + return 8; +} + +/* The per-client cap on in-flight QoS 2 packet IDs (BROKER_MAX_INBOUND_QOS2, + * default 16) MUST disconnect a client that exceeds it. Without the cap, a + * misbehaving client could exhaust broker memory by sending many distinct + * QoS 2 PUBLISH packets without ever sending the matching PUBRELs. */ +TEST(qos2_inbound_cap_reached_disconnects) +{ + MqttBroker broker; + MqttBrokerNet net; + int i; + int pub_pubrecs; + int closed_after; + static const byte connect_pub[] = { + 0x10, 0x0D, 0x00, 0x04, 'M', 'Q', 'T', 'T', + 0x04, 0x02, 0x00, 0x3C, 0x00, 0x01, 'B' + }; + byte pub_buf[8]; + + install_mock_net(&net); + XMEMSET(&broker, 0, sizeof(broker)); + ASSERT_EQ(MQTT_CODE_SUCCESS, MqttBroker_Init(&broker, &net)); + ASSERT_EQ(MQTT_CODE_SUCCESS, MqttBroker_Start(&broker)); + + reset_mock_clients(1); + mock_client_input_append(0, connect_pub, sizeof(connect_pub)); + /* Feed BROKER_MAX_INBOUND_QOS2 + 1 distinct in-flight PUBLISHes. The + * first cap accepted; the (cap+1)th must trigger malformed-data close. */ + for (i = 1; i <= BROKER_MAX_INBOUND_QOS2 + 1; i++) { + size_t n = build_qos2_pub(pub_buf, (word16)i); + mock_client_input_append(0, pub_buf, n); + } + + for (i = 0; i < 32; i++) { + MqttBroker_Step(&broker); + } + + pub_pubrecs = count_packets_of_type(g_clients[0].out_buf, + g_clients[0].out_len, MQTT_PACKET_TYPE_PUBLISH_REC); + closed_after = g_clients[0].closed; + + /* The first BROKER_MAX_INBOUND_QOS2 PUBLISHes get a PUBREC each; the + * (cap+1)th is rejected before the PUBREC send, so no PUBREC for it. */ + ASSERT_EQ(BROKER_MAX_INBOUND_QOS2, pub_pubrecs); + ASSERT_TRUE(closed_after); + + MqttBroker_Stop(&broker); + MqttBroker_Free(&broker); +} + +/* Disconnecting a client with non-empty inbound QoS 2 state must free that + * state. We can't directly inspect freed pointers from the test, but ASan/ + * valgrind in CI catch a regression where BrokerInboundQos2_Clear becomes a + * no-op. This test exercises the cleanup path so a sanitizer build fails on + * a leak rather than the bug going unnoticed. */ +TEST(qos2_state_freed_on_client_disconnect) +{ + MqttBroker broker; + MqttBrokerNet net; + int i; + static const byte connect_pub[] = { + 0x10, 0x0D, 0x00, 0x04, 'M', 'Q', 'T', 'T', + 0x04, 0x02, 0x00, 0x3C, 0x00, 0x01, 'B' + }; + /* Normal DISCONNECT packet — drives the broker through the + * clean-disconnect cleanup path. */ + static const byte disconnect[] = { 0xE0, 0x00 }; + byte pub_buf[8]; + + install_mock_net(&net); + XMEMSET(&broker, 0, sizeof(broker)); + ASSERT_EQ(MQTT_CODE_SUCCESS, MqttBroker_Init(&broker, &net)); + ASSERT_EQ(MQTT_CODE_SUCCESS, MqttBroker_Start(&broker)); + + reset_mock_clients(1); + mock_client_input_append(0, connect_pub, sizeof(connect_pub)); + /* Three in-flight QoS 2 PUBLISHes with no matching PUBRELs. */ + for (i = 1; i <= 3; i++) { + size_t n = build_qos2_pub(pub_buf, (word16)i); + mock_client_input_append(0, pub_buf, n); + } + mock_client_input_append(0, disconnect, sizeof(disconnect)); + + for (i = 0; i < 16; i++) { + MqttBroker_Step(&broker); + } + + /* Sanity: client did get processed and is now closed. The actual + * leak-check is the responsibility of the sanitizer harness. */ + ASSERT_TRUE(g_clients[0].closed); + + MqttBroker_Stop(&broker); + MqttBroker_Free(&broker); +} + +/* PUBREL for an unknown packet ID is idempotent: the broker MUST still + * respond with PUBCOMP. The dedup-set's Remove is a no-op for unknown IDs. */ +TEST(qos2_pubrel_unknown_id_still_pubcomps) +{ + MqttBroker broker; + MqttBrokerNet net; + int i; + int pub_pubcomps; + + static const byte connect_pub[] = { + 0x10, 0x0D, 0x00, 0x04, 'M', 'Q', 'T', 'T', + 0x04, 0x02, 0x00, 0x3C, 0x00, 0x01, 'B' + }; + /* PUBREL packet_id=99 with no preceding PUBLISH. */ + static const byte pubrel[] = { + 0x62, 0x02, 0x00, 0x63 + }; + + install_mock_net(&net); + XMEMSET(&broker, 0, sizeof(broker)); + ASSERT_EQ(MQTT_CODE_SUCCESS, MqttBroker_Init(&broker, &net)); + ASSERT_EQ(MQTT_CODE_SUCCESS, MqttBroker_Start(&broker)); + + reset_mock_clients(1); + mock_client_input_append(0, connect_pub, sizeof(connect_pub)); + mock_client_input_append(0, pubrel, sizeof(pubrel)); + + for (i = 0; i < 8; i++) { + MqttBroker_Step(&broker); + } + + pub_pubcomps = count_packets_of_type(g_clients[0].out_buf, + g_clients[0].out_len, MQTT_PACKET_TYPE_PUBLISH_COMP); + ASSERT_EQ(1, pub_pubcomps); + + MqttBroker_Stop(&broker); + MqttBroker_Free(&broker); +} + /* -------------------------------------------------------------------------- */ /* Runner */ /* -------------------------------------------------------------------------- */ @@ -552,6 +1030,12 @@ int main(int argc, char** argv) RUN_TEST(connect_v5_emptyid_assigned_id_emitted); RUN_TEST(connect_v5_emptyid_clean0_accepted); #endif + RUN_TEST(qos2_duplicate_publish_dedup); + RUN_TEST(qos2_phantom_dup_publish_is_fresh); + RUN_TEST(qos2_publish_after_pubrel_is_fresh); + RUN_TEST(qos2_inbound_cap_reached_disconnects); + RUN_TEST(qos2_state_freed_on_client_disconnect); + RUN_TEST(qos2_pubrel_unknown_id_still_pubcomps); TEST_SUITE_END(); TEST_RUNNER_END(); diff --git a/wolfmqtt/mqtt_broker.h b/wolfmqtt/mqtt_broker.h index d53eda7ac..68c657456 100644 --- a/wolfmqtt/mqtt_broker.h +++ b/wolfmqtt/mqtt_broker.h @@ -113,6 +113,13 @@ #ifndef BROKER_MAX_PENDING_WILLS #define BROKER_MAX_PENDING_WILLS 4 #endif +/* Maximum concurrent inbound QoS 2 packet IDs awaiting PUBREL per client. + * Used to dedup duplicate PUBLISHes per [MQTT-4.3.3] (Method B). 16 covers + * any reasonable client; a misbehaving client that exceeds this gets a + * malformed-packet rejection. */ +#ifndef BROKER_MAX_INBOUND_QOS2 + #define BROKER_MAX_INBOUND_QOS2 16 +#endif /* -------------------------------------------------------------------------- */ /* Feature toggles (opt-out: define WOLFMQTT_BROKER_NO_xxx to disable) */ @@ -188,6 +195,19 @@ typedef struct BrokerWsCtx { } BrokerWsCtx; #endif /* ENABLE_MQTT_WEBSOCKET */ +/* -------------------------------------------------------------------------- */ +/* Inbound QoS 2 dedup state */ +/* -------------------------------------------------------------------------- */ +/* Per-client set of QoS 2 packet IDs that have been received and PUBREC'd + * but not yet PUBREL'd. Used to skip the fan-out for duplicate PUBLISHes + * per [MQTT-4.3.3] / Method B. */ +#ifndef WOLFMQTT_STATIC_MEMORY +typedef struct BrokerInboundQos2 { + word16 packet_id; + struct BrokerInboundQos2* next; +} BrokerInboundQos2; +#endif + /* -------------------------------------------------------------------------- */ /* Broker client tracking */ /* -------------------------------------------------------------------------- */ @@ -237,6 +257,17 @@ typedef struct BrokerClient { MqttNet net; MqttClient client; struct MqttBroker* broker; /* back-pointer to parent broker context */ + /* [MQTT-4.3.3] Inbound QoS 2 packet IDs that have been PUBREC'd but + * not yet PUBREL'd. A duplicate PUBLISH carrying one of these IDs is + * acked again (PUBREC) but NOT re-fanned-out to subscribers. The + * BROKER_MAX_INBOUND_QOS2 cap is enforced in both memory modes; a + * client that exceeds it is disconnected with malformed-packet error. */ +#ifdef WOLFMQTT_STATIC_MEMORY + word16 qos2_pending[BROKER_MAX_INBOUND_QOS2]; /* 0 = empty slot */ +#else + BrokerInboundQos2* qos2_pending; + int qos2_pending_count; +#endif #ifdef ENABLE_MQTT_TLS byte tls_handshake_done; #endif From f2f191319c9eec65257150dbc3bc81bebba9f6c8 Mon Sep 17 00:00:00 2001 From: Eric Blankenhorn Date: Mon, 4 May 2026 13:43:09 -0500 Subject: [PATCH 08/32] Fix Packet Identifier In-Use Collision Is Not Rejected --- src/mqtt_client.c | 20 +++++- tests/test_mqtt_client.c | 150 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 169 insertions(+), 1 deletion(-) diff --git a/src/mqtt_client.c b/src/mqtt_client.c index be1bbb493..c536c8e3f 100644 --- a/src/mqtt_client.c +++ b/src/mqtt_client.c @@ -415,7 +415,14 @@ int MqttClient_RespList_Add(MqttClient *client, newResp, MqttPacket_TypeDesc(packet_type), packet_type, packet_id); #endif - /* verify newResp is not already in the list */ + /* Verify newResp is not already in the list, and enforce MQTT Packet + * Identifier in-use uniqueness: the spec (3.1.1 section 2.3.1, 5.0 section 2.2.1) + * requires a new QoS-related Control Packet to use a Packet Identifier + * that is not currently in use. The identifier becomes reusable only + * after the corresponding acknowledgement flow completes and the entry + * is removed from this list. A packet_id of 0 is used for packet types + * that do not carry a Packet Identifier (CONNECT_ACK, PING_RESP, AUTH) + * and is excluded from the collision check. */ for (tmpResp = client->firstPendResp; tmpResp != NULL; tmpResp = tmpResp->next) @@ -426,6 +433,17 @@ int MqttClient_RespList_Add(MqttClient *client, #endif return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_BAD_ARG); } + if (packet_id != 0 && tmpResp->packet_id == packet_id) { + #ifdef WOLFMQTT_DEBUG_CLIENT + PRINTF("Pending Response packet_id %d already in use " + "(existing type %s (%d), new type %s (%d))", + packet_id, + MqttPacket_TypeDesc(tmpResp->packet_type), + tmpResp->packet_type, + MqttPacket_TypeDesc(packet_type), packet_type); + #endif + return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_PACKET_ID); + } } /* Initialize new response */ diff --git a/tests/test_mqtt_client.c b/tests/test_mqtt_client.c index 4d20714b9..3a6d9a3e6 100644 --- a/tests/test_mqtt_client.c +++ b/tests/test_mqtt_client.c @@ -526,6 +526,152 @@ TEST(publish_null_publish) ASSERT_EQ(MQTT_CODE_ERROR_BAD_ARG, rc); } +/* Regression test for MQTT Packet Identifier in-use collision check. The + * MQTT spec (3.1.1 section 2.3.1, 5.0 section 2.2.1) requires that a new QoS-related + * Control Packet use a Packet Identifier that is not currently in use; + * the identifier only becomes reusable after the matching acknowledgement + * flow completes. Before the fix, MqttClient_RespList_Add only checked + * that the same MqttPendResp object pointer was not already in the list — + * it did not reject a different pending entry that reused an in-flight + * Packet Identifier. The repro requires both MULTITHREAD (so the pending + * response list is in use) and NONBLOCK (so the write-only publish leaves + * the pendResp in the list across the call boundary). */ +#if defined(WOLFMQTT_MULTITHREAD) && defined(WOLFMQTT_NONBLOCK) +TEST(publish_writeonly_rejects_duplicate_in_flight_packet_id) +{ + int rc; + MqttPublish publish1, publish2; + static byte payload1[] = "hello1"; + static byte payload2[] = "hello2"; + + rc = test_init_client(); + ASSERT_EQ(MQTT_CODE_SUCCESS, rc); + + /* Mock writes accept everything so the publish state machine reaches + * MQTT_MSG_WAIT and returns MQTT_CODE_CONTINUE while the pendResp is + * still registered. */ + connect_mock_xfer = 0; + XMEMSET(connect_mock_sent, 0, sizeof(connect_mock_sent)); + test_net.write = mock_net_write_accept; + + /* First publish: QoS 1, packet_id=7. After this returns, the + * pendResp for PUBLISH_ACK with packet_id=7 remains in the list. */ + XMEMSET(&publish1, 0, sizeof(publish1)); + publish1.qos = MQTT_QOS_1; + publish1.packet_id = 7; + publish1.topic_name = "test/topic1"; + publish1.buffer = payload1; + publish1.total_len = (word32)(sizeof(payload1) - 1); + publish1.buffer_len = publish1.total_len; + + rc = MqttClient_Publish_WriteOnly(&test_client, &publish1, NULL); + ASSERT_EQ(MQTT_CODE_CONTINUE, rc); + + /* Second publish reusing the same packet_id must be rejected. Without + * the fix this returned MQTT_CODE_CONTINUE and silently registered a + * second pendResp with the same Packet Identifier. */ + XMEMSET(&publish2, 0, sizeof(publish2)); + publish2.qos = MQTT_QOS_1; + publish2.packet_id = 7; + publish2.topic_name = "test/topic2"; + publish2.buffer = payload2; + publish2.total_len = (word32)(sizeof(payload2) - 1); + publish2.buffer_len = publish2.total_len; + + rc = MqttClient_Publish_WriteOnly(&test_client, &publish2, NULL); + ASSERT_EQ(MQTT_CODE_ERROR_PACKET_ID, rc); + + /* A different packet_id is allowed even while packet_id=7 is in use. */ + publish2.packet_id = 8; + publish2.stat.write = MQTT_MSG_BEGIN; + publish2.buffer_pos = 0; + rc = MqttClient_Publish_WriteOnly(&test_client, &publish2, NULL); + ASSERT_EQ(MQTT_CODE_CONTINUE, rc); + + /* After the in-flight entry is released, the same Packet Identifier + * becomes reusable. */ + rc = MqttClient_CancelMessage(&test_client, (MqttObject*)&publish1); + ASSERT_EQ(MQTT_CODE_SUCCESS, rc); + + XMEMSET(&publish1, 0, sizeof(publish1)); + publish1.qos = MQTT_QOS_1; + publish1.packet_id = 7; + publish1.topic_name = "test/topic1"; + publish1.buffer = payload1; + publish1.total_len = (word32)(sizeof(payload1) - 1); + publish1.buffer_len = publish1.total_len; + rc = MqttClient_Publish_WriteOnly(&test_client, &publish1, NULL); + ASSERT_EQ(MQTT_CODE_CONTINUE, rc); + + /* Cleanup remaining pending responses. */ + (void)MqttClient_CancelMessage(&test_client, (MqttObject*)&publish1); + (void)MqttClient_CancelMessage(&test_client, (MqttObject*)&publish2); +} + +/* Cross-packet-type collision: an in-flight SUBSCRIBE_ACK and a new QoS 1 + * publish must not share a Packet Identifier. The MQTT spec treats the + * in-use set as global across all packet types that carry a Packet + * Identifier, so the check in MqttClient_RespList_Add must reject the + * collision regardless of whether the existing entry is a PUBLISH_ACK, + * SUBSCRIBE_ACK, UNSUBSCRIBE_ACK, etc. This test guards against a future + * narrowing of the check to a single packet_type family. */ +static int mock_net_read_continue(void *context, byte* buf, int buf_len, + int timeout_ms) +{ + (void)context; (void)buf; (void)buf_len; (void)timeout_ms; + /* Return 0 bytes — under WOLFMQTT_NONBLOCK the socket layer translates + * this into MQTT_CODE_CONTINUE so MqttClient_Subscribe returns with + * its pendResp still registered. */ + return 0; +} + +TEST(subscribe_in_flight_blocks_publish_with_same_packet_id) +{ + int rc; + MqttSubscribe subscribe; + MqttTopic topics[1]; + MqttPublish publish; + static byte payload[] = "payload"; + + rc = test_init_client(); + ASSERT_EQ(MQTT_CODE_SUCCESS, rc); + + test_net.write = mock_net_write_accept; + test_net.read = mock_net_read_continue; + + /* Issue a SUBSCRIBE that writes successfully but cannot complete the + * SUBACK read; the pendResp for SUBSCRIBE_ACK + packet_id=42 is left + * in the list. */ + XMEMSET(&subscribe, 0, sizeof(subscribe)); + XMEMSET(topics, 0, sizeof(topics)); + topics[0].topic_filter = "test/topic"; + topics[0].qos = MQTT_QOS_0; + subscribe.packet_id = 42; + subscribe.topic_count = 1; + subscribe.topics = topics; + + rc = MqttClient_Subscribe(&test_client, &subscribe); + ASSERT_EQ(MQTT_CODE_CONTINUE, rc); + + /* A QoS 1 publish reusing packet_id=42 must be rejected even though + * the in-flight entry is for SUBSCRIBE_ACK, not PUBLISH_ACK. */ + XMEMSET(&publish, 0, sizeof(publish)); + publish.qos = MQTT_QOS_1; + publish.packet_id = 42; + publish.topic_name = "test/publish"; + publish.buffer = payload; + publish.total_len = (word32)(sizeof(payload) - 1); + publish.buffer_len = publish.total_len; + + rc = MqttClient_Publish_WriteOnly(&test_client, &publish, NULL); + ASSERT_EQ(MQTT_CODE_ERROR_PACKET_ID, rc); + + /* Cleanup. */ + (void)MqttClient_CancelMessage(&test_client, (MqttObject*)&subscribe); + (void)MqttClient_CancelMessage(&test_client, (MqttObject*)&publish); +} +#endif /* WOLFMQTT_MULTITHREAD && WOLFMQTT_NONBLOCK */ + /* ============================================================================ * MqttClient_WaitMessage Tests * ============================================================================ */ @@ -647,6 +793,10 @@ void run_mqtt_client_tests(void) /* MqttClient_Publish tests */ RUN_TEST(publish_null_client); RUN_TEST(publish_null_publish); +#if defined(WOLFMQTT_MULTITHREAD) && defined(WOLFMQTT_NONBLOCK) + RUN_TEST(publish_writeonly_rejects_duplicate_in_flight_packet_id); + RUN_TEST(subscribe_in_flight_blocks_publish_with_same_packet_id); +#endif /* MqttClient_WaitMessage tests */ RUN_TEST(wait_message_null_client); From 713f55dba5077e72f12d719beec99dea01ad3724 Mon Sep 17 00:00:00 2001 From: Eric Blankenhorn Date: Mon, 4 May 2026 13:51:37 -0500 Subject: [PATCH 09/32] Fix Packet Identifier Zero Acceptance on Receive Paths --- src/mqtt_packet.c | 66 ++++++++++---------- tests/test_mqtt_packet.c | 129 +++++++++++++++++++++++++++++++-------- 2 files changed, 136 insertions(+), 59 deletions(-) diff --git a/src/mqtt_packet.c b/src/mqtt_packet.c index 5cff3cdff..d91154d2e 100644 --- a/src/mqtt_packet.c +++ b/src/mqtt_packet.c @@ -473,8 +473,22 @@ int MqttDecode_String(byte *buf, const char **pstr, word16 *pstr_len, word32 buf return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_OUT_OF_BUFFER); } buf += len; - if (str_len > 0 && !Utf8WellFormed(buf, str_len)) { - return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_MALFORMED_DATA); + if (str_len > 0) { + /* [MQTT-1.5.3-1] Reject ill-formed UTF-8 (RFC 3629). */ + if (!Utf8WellFormed(buf, str_len)) { + return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_MALFORMED_DATA); + } + /* [MQTT-1.5.3-2] / [MQTT-1.5.4-2]: an MQTT UTF-8 encoded string + * MUST NOT include the null character (U+0000). Although U+0000 + * is well-formed UTF-8, it is forbidden in MQTT string fields — + * downstream C-string handling would otherwise be tricked by an + * embedded NUL truncating the value (e.g., a topic "se\0cret" + * would route to subscribers of "se"). The CONNECT Password + * field is Binary Data per [MQTT-3.1.3.5] and bypasses this + * helper; binary fields tolerate embedded NULs. */ + if (XMEMCHR(buf, 0x00, str_len) != NULL) { + return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_MALFORMED_DATA); + } } if (pstr_len) { *pstr_len = str_len; @@ -1109,39 +1123,6 @@ int MqttEncode_Connect(byte *tx_buf, int tx_buf_len, MqttConnect *mc_connect) } #ifdef WOLFMQTT_BROKER -/* Decode the CONNECT Password field. Password is Binary Data per - * MQTT-3.1.3.5, not a UTF-8 string, so the [MQTT-1.5.3-2] / [MQTT-1.5.4-2] - * U+0000 prohibition does not formally apply. The defensive NUL check is - * applied here because wolfMQTT compares stored passwords with - * XSTRLEN/XSTRCMP, so a binary password with an embedded NUL would be - * silently truncated and could enable an auth bypass. Decoupling this - * from MqttDecode_String keeps the spec-compliant UTF-8 path separate - * from the broker-specific binary-data validation. */ -static int MqttDecode_Password(byte *buf, const char **ppassword, - word16 *ppassword_len, word32 buf_len) -{ - int len; - word16 pass_len; - len = MqttDecode_Num(buf, &pass_len, buf_len); - if (len < 0) { - return len; - } - if ((word32)pass_len > buf_len - (word32)len) { - return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_OUT_OF_BUFFER); - } - buf += len; - if (pass_len > 0 && XMEMCHR(buf, 0x00, pass_len) != NULL) { - return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_MALFORMED_DATA); - } - if (ppassword_len) { - *ppassword_len = pass_len; - } - if (ppassword) { - *ppassword = (char*)buf; - } - return len + pass_len; -} - int MqttDecode_Connect(byte *rx_buf, int rx_buf_len, MqttConnect *mc_connect) { int header_len, remain_len; @@ -1696,6 +1677,11 @@ int MqttDecode_Publish(byte *rx_buf, int rx_buf_len, MqttPublish *publish) if (tmp < 0) { return tmp; } + /* [MQTT-2.3.1-1] PUBLISH packets with QoS > 0 must carry a non-zero + * Packet Identifier. */ + if (publish->packet_id == 0) { + return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_PACKET_ID); + } variable_len += tmp; if (variable_len + header_len <= rx_buf_len) { rx_payload += MQTT_DATA_LEN_SIZE; @@ -2080,6 +2066,11 @@ int MqttDecode_Subscribe(byte *rx_buf, int rx_buf_len, MqttSubscribe *subscribe) if (tmp < 0) { return tmp; } + /* [MQTT-2.3.1-1] SUBSCRIBE packets must carry a non-zero + * Packet Identifier. */ + if (subscribe->packet_id == 0) { + return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_PACKET_ID); + } rx_payload += tmp; #ifdef WOLFMQTT_V5 @@ -2373,6 +2364,11 @@ int MqttDecode_Unsubscribe(byte *rx_buf, int rx_buf_len, MqttUnsubscribe *unsubs if (tmp < 0) { return tmp; } + /* [MQTT-2.3.1-1] UNSUBSCRIBE packets must carry a non-zero + * Packet Identifier. */ + if (unsubscribe->packet_id == 0) { + return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_PACKET_ID); + } rx_payload += tmp; #ifdef WOLFMQTT_V5 diff --git a/tests/test_mqtt_packet.c b/tests/test_mqtt_packet.c index 9ad1ea68f..c595afc80 100644 --- a/tests/test_mqtt_packet.c +++ b/tests/test_mqtt_packet.c @@ -995,6 +995,60 @@ TEST(decode_publish_rejects_nul_in_topic) ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); } +/* [MQTT-2.3.1-1] PUBLISH with QoS > 0 must carry a non-zero Packet + * Identifier. The encoder rejects packet_id=0 already; this guards the + * symmetric decode-side check so a peer cannot smuggle in a malformed + * packet that downstream logic would treat as an absent / unset id. */ +TEST(decode_publish_qos1_packet_id_zero_rejected) +{ + /* PUBLISH | QoS 1 = 0x32, remain_len=7, topic "t", packet_id=0, "xy" */ + byte buf[] = { 0x32, 7, + 0x00, 0x01, 't', + 0x00, 0x00, + 'x', 'y' }; + MqttPublish pub; + int rc; + + XMEMSET(&pub, 0, sizeof(pub)); + rc = MqttDecode_Publish(buf, (int)sizeof(buf), &pub); + ASSERT_EQ(MQTT_CODE_ERROR_PACKET_ID, rc); +} + +TEST(decode_publish_qos2_packet_id_zero_rejected) +{ + /* PUBLISH | QoS 2 = 0x34, remain_len=7, topic "t", packet_id=0, "xy" */ + byte buf[] = { 0x34, 7, + 0x00, 0x01, 't', + 0x00, 0x00, + 'x', 'y' }; + MqttPublish pub; + int rc; + + XMEMSET(&pub, 0, sizeof(pub)); + rc = MqttDecode_Publish(buf, (int)sizeof(buf), &pub); + ASSERT_EQ(MQTT_CODE_ERROR_PACKET_ID, rc); +} + +/* QoS 2 with non-zero Packet Identifier is the matching positive case so a + * future regression that turns the zero check into "always reject" would + * trip here as well as in decode_publish_qos1_valid. */ +TEST(decode_publish_qos2_packet_id_one_valid) +{ + byte buf[] = { 0x34, 7, + 0x00, 0x01, 't', + 0x00, 0x01, + 'x', 'y' }; + MqttPublish pub; + int rc; + + XMEMSET(&pub, 0, sizeof(pub)); + rc = MqttDecode_Publish(buf, (int)sizeof(buf), &pub); + ASSERT_TRUE(rc > 0); + ASSERT_EQ(MQTT_QOS_2, pub.qos); + ASSERT_EQ(1, pub.packet_id); +} + + #ifdef WOLFMQTT_V5 /* Hand-validated MQTT v5 PUBLISH packet (independent oracle, not produced by * MqttEncode_Publish) so encode and decode cannot hide a shared bug: @@ -2136,29 +2190,6 @@ TEST(decode_connect_rejects_nul_in_username) ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); } -/* [MQTT-1.5.3-2]: an embedded NUL in the password must be rejected. - * Same auth-bypass mechanism as the username test, applied to the - * password field. */ -TEST(decode_connect_rejects_nul_in_password) -{ - byte buf[] = { - 0x10, 0x16, /* CONNECT, remain_len = 22 */ - 0x00, 0x04, 'M', 'Q', 'T', 'T', - 0x04, - 0xC2, /* clean_session + USER + PASS */ - 0x00, 0x3C, - 0x00, 0x02, 'c', '1', - 0x00, 0x01, 'u', /* username "u" */ - 0x00, 0x03, 'p', 0x00, 'w' /* password with NUL */ - }; - MqttConnect dec; - int rc; - - XMEMSET(&dec, 0, sizeof(dec)); - rc = MqttDecode_Connect(buf, (int)sizeof(buf), &dec); - ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); -} - /* [MQTT-1.5.3-2] / [MQTT-4.7.3-2]: a Will Topic with embedded NUL must * be rejected. The same C-string truncation that affects PUBLISH topics * applies to Will Topics persisted by the broker. */ @@ -2297,6 +2328,29 @@ TEST(decode_subscribe_rejects_nul_in_filter) ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); } +/* [MQTT-2.3.1-1] SUBSCRIBE must carry a non-zero Packet Identifier on the + * receive path as well as the transmit path. */ +TEST(decode_subscribe_packet_id_zero_rejected) +{ + byte rx_buf[] = { + 0x82, 0x06, + 0x00, 0x00, /* packet_id = 0 */ + 0x00, 0x01, + 0x61, + 0x01 + }; + MqttSubscribe sub; + MqttTopic topic_arr[1]; + int rc; + + XMEMSET(&sub, 0, sizeof(sub)); + XMEMSET(topic_arr, 0, sizeof(topic_arr)); + sub.topics = topic_arr; + rc = MqttDecode_Subscribe(rx_buf, (int)sizeof(rx_buf), &sub); + ASSERT_EQ(MQTT_CODE_ERROR_PACKET_ID, rc); +} + + #ifdef WOLFMQTT_V5 /* [MQTT-3.8.3] v5 SUBSCRIBE options byte carries QoS (bits 0-1), No Local * (bit 2), Retain As Published (bit 3), and Retain Handling (bits 4-5). @@ -2354,6 +2408,26 @@ TEST(decode_unsubscribe_rejects_nul_in_filter) rc = MqttDecode_Unsubscribe(rx_buf, (int)sizeof(rx_buf), &unsub); ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); } + +/* [MQTT-2.3.1-1] UNSUBSCRIBE must carry a non-zero Packet Identifier on + * the receive path as well as the transmit path. */ +TEST(decode_unsubscribe_packet_id_zero_rejected) +{ + byte rx_buf[] = { + 0xA2, 0x05, + 0x00, 0x00, /* packet_id = 0 */ + 0x00, 0x01, 'a' + }; + MqttUnsubscribe unsub; + MqttTopic topic_arr[1]; + int rc; + + XMEMSET(&unsub, 0, sizeof(unsub)); + XMEMSET(topic_arr, 0, sizeof(topic_arr)); + unsub.topics = topic_arr; + rc = MqttDecode_Unsubscribe(rx_buf, (int)sizeof(rx_buf), &unsub); + ASSERT_EQ(MQTT_CODE_ERROR_PACKET_ID, rc); +} #endif /* WOLFMQTT_BROKER */ /* ============================================================================ @@ -3116,6 +3190,9 @@ void run_mqtt_packet_tests(void) RUN_TEST(decode_publish_qos0_zero_payload); RUN_TEST(decode_publish_malformed_variable_exceeds_remain); RUN_TEST(decode_publish_rejects_nul_in_topic); + RUN_TEST(decode_publish_qos1_packet_id_zero_rejected); + RUN_TEST(decode_publish_qos2_packet_id_zero_rejected); + RUN_TEST(decode_publish_qos2_packet_id_one_valid); #ifdef WOLFMQTT_V5 RUN_TEST(decode_publish_v5_content_type_property); RUN_TEST(decode_publish_v5_rejects_nul_in_string_property); @@ -3180,7 +3257,9 @@ void run_mqtt_packet_tests(void) RUN_TEST(decode_connect_v311_with_lwt); RUN_TEST(decode_connect_rejects_nul_in_client_id); RUN_TEST(decode_connect_rejects_nul_in_username); - RUN_TEST(decode_connect_rejects_nul_in_password); + /* Note: decode_connect_v311_binary_password covers the password path — + * Password is Binary Data per [MQTT-3.1.3.5] and decoding is routed + * around MqttDecode_String, so the U+0000 ban does not apply there. */ RUN_TEST(decode_connect_rejects_nul_in_will_topic); #ifdef WOLFMQTT_V5 RUN_TEST(decode_connect_v5_rejects_nul_in_client_id); @@ -3190,12 +3269,14 @@ void run_mqtt_packet_tests(void) RUN_TEST(decode_subscribe_v311_single_topic); RUN_TEST(decode_subscribe_v311_qos3_reserved); RUN_TEST(decode_subscribe_rejects_nul_in_filter); + RUN_TEST(decode_subscribe_packet_id_zero_rejected); #ifdef WOLFMQTT_V5 RUN_TEST(decode_subscribe_v5_options_byte_qos_extracted); #endif /* MqttDecode_Unsubscribe */ RUN_TEST(decode_unsubscribe_rejects_nul_in_filter); + RUN_TEST(decode_unsubscribe_packet_id_zero_rejected); #endif /* QoS 2 ack arithmetic */ From c5127fe8416fe13e3c2ebda9d71b957484f7d286 Mon Sep 17 00:00:00 2001 From: Eric Blankenhorn Date: Mon, 4 May 2026 14:25:19 -0500 Subject: [PATCH 10/32] Cleanup --- src/mqtt_broker.c | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/mqtt_broker.c b/src/mqtt_broker.c index b0590da59..16bd245e2 100644 --- a/src/mqtt_broker.c +++ b/src/mqtt_broker.c @@ -2859,7 +2859,9 @@ static int BrokerHandle_Connect(BrokerClient* bc, int rx_len, MqttConnectAck ack; MqttMessage lwt; word16 id_len = 0; +#ifdef WOLFMQTT_V5 int auto_assigned = 0; +#endif XMEMSET(&mc, 0, sizeof(mc)); XMEMSET(&ack, 0, sizeof(ack)); @@ -3027,7 +3029,9 @@ static int BrokerHandle_Connect(BrokerClient* bc, int rx_len, } goto send_connack; } + #ifdef WOLFMQTT_V5 auto_assigned = 1; + #endif } WBLOG_INFO(broker, "broker: CONNECT proto=%u clean=%d will=%d client_id=%s", From 58253a8d8bc3fbe7b36c1202e2eb288a0fec26b7 Mon Sep 17 00:00:00 2001 From: Eric Blankenhorn Date: Mon, 4 May 2026 15:02:44 -0500 Subject: [PATCH 11/32] Fix Packet Identifier Reuse State Is Not Centrally Modeled --- src/mqtt_client.c | 76 ++++++++++++ tests/test_mqtt_client.c | 249 +++++++++++++++++++++++++++++++++++++++ wolfmqtt/mqtt_client.h | 22 ++++ 3 files changed, 347 insertions(+) diff --git a/src/mqtt_client.c b/src/mqtt_client.c index c536c8e3f..6c041d634 100644 --- a/src/mqtt_client.c +++ b/src/mqtt_client.c @@ -3081,6 +3081,82 @@ int MqttClient_NetDisconnect(MqttClient *client) return MqttSocket_Disconnect(client); } +/* [MQTT-2.3.1-1] / MQTT 3.1.1 section 2.3.1: a new QoS-related Control + * Packet must use a Packet Identifier that is not currently in use; the + * value only becomes reusable after the corresponding acknowledgement + * flow completes. This is the central allocator implementing that rule. + * + * The pending response list (under WOLFMQTT_MULTITHREAD) is the + * authoritative in-use set: an entry is present from the moment a + * QoS-related packet is registered and is removed only after the matching + * ack is processed. We walk it to skip any value that is still in flight + * and advance the per-client counter to find the next unused id. + * + * Under WOLFMQTT_MULTITHREAD lockClient is held across both the counter + * advance and the in-flight walk. Splitting them would allow two + * concurrent callers to increment past the same pre-value, see an empty + * resp list (neither has registered yet), and both return the same id — + * the very collision this allocator exists to prevent. + * + * Returns 0 when client is NULL, lockClient cannot be acquired, or the + * full 1..0xFFFF range is in flight. In all three cases the caller must + * back off rather than reuse a value. */ +word16 MqttClient_NextPacketId(MqttClient *client) +{ + word32 tries; + word16 result = 0; + + if (client == NULL) { + return 0; + } + +#ifdef WOLFMQTT_MULTITHREAD + /* Lock failure: surface as the documented sentinel. Returning an + * unverified candidate would silently violate the in-use guarantee. */ + if (wm_SemLock(&client->lockClient) != 0) { + return 0; + } +#endif + + for (tries = 0; tries < (word32)MAX_PACKET_ID + 1U; tries++) { + word16 candidate; + + client->next_packet_id++; + if (client->next_packet_id == 0) { + client->next_packet_id = 1; /* skip 0 on wrap */ + } + candidate = client->next_packet_id; + +#ifdef WOLFMQTT_MULTITHREAD + { + MqttPendResp *tmpResp; + int collision = 0; + for (tmpResp = client->firstPendResp; + tmpResp != NULL; + tmpResp = tmpResp->next) + { + if (tmpResp->packet_id == candidate) { + collision = 1; + break; + } + } + if (collision) { + continue; /* try the next value */ + } + } +#endif + + result = candidate; + break; + } + +#ifdef WOLFMQTT_MULTITHREAD + wm_SemUnlock(&client->lockClient); +#endif + + return result; +} + int MqttClient_GetProtocolVersion(MqttClient *client) { #ifdef WOLFMQTT_V5 diff --git a/tests/test_mqtt_client.c b/tests/test_mqtt_client.c index 3a6d9a3e6..48d50cbb2 100644 --- a/tests/test_mqtt_client.c +++ b/tests/test_mqtt_client.c @@ -672,6 +672,240 @@ TEST(subscribe_in_flight_blocks_publish_with_same_packet_id) } #endif /* WOLFMQTT_MULTITHREAD && WOLFMQTT_NONBLOCK */ +/* ============================================================================ + * MqttClient_NextPacketId Tests + * ============================================================================ */ + +/* Central allocator must reject NULL clients with the documented sentinel + * (0) rather than crashing or wrapping a NULL into a counter increment. */ +TEST(next_packet_id_null_returns_zero) +{ + ASSERT_EQ(0, MqttClient_NextPacketId(NULL)); +} + +/* Sequential calls must return non-zero values that are distinct from the + * previous one. The contract intentionally does not pin the starting + * value or require strict +1 stepping — a future change could legally + * skip values (e.g., to randomize, or because an in-flight id collided) + * without breaking callers. */ +TEST(next_packet_id_increments) +{ + word16 a, b, c; + int rc; + + rc = test_init_client(); + ASSERT_EQ(MQTT_CODE_SUCCESS, rc); + + a = MqttClient_NextPacketId(&test_client); + b = MqttClient_NextPacketId(&test_client); + c = MqttClient_NextPacketId(&test_client); + + ASSERT_TRUE(a != 0); + ASSERT_TRUE(b != 0); + ASSERT_TRUE(c != 0); + ASSERT_TRUE(a != b); + ASSERT_TRUE(b != c); + ASSERT_TRUE(a != c); +} + +/* [MQTT-2.3.1-1] / 3.1.1 section 2.3.1: 0 is reserved (means "no Packet + * Identifier"), so the allocator must skip it on wrap. Pre-set the counter + * just below the wrap point to drive the wrap branch. */ +TEST(next_packet_id_skips_zero_on_wrap) +{ + int rc; + + rc = test_init_client(); + ASSERT_EQ(MQTT_CODE_SUCCESS, rc); + + /* Next increment will overflow to 0; allocator must skip to 1. */ + test_client.next_packet_id = 0xFFFF; + ASSERT_EQ(1, MqttClient_NextPacketId(&test_client)); + + /* Subsequent call advances normally. */ + ASSERT_EQ(2, MqttClient_NextPacketId(&test_client)); +} + +#if defined(WOLFMQTT_MULTITHREAD) && !defined(_WIN32) && !defined(USE_WINDOWS_API) +#include +#include + +#define ALLOCATOR_THREAD_COUNT 8 +#define ALLOCATOR_PER_THREAD 4000 +#define ALLOCATOR_TOTAL (ALLOCATOR_THREAD_COUNT * ALLOCATOR_PER_THREAD) + +/* Start gate so all threads enter the allocator loop at roughly the same + * time. Without this, pthread_create finishes serially and the test + * effectively measures sequential calls — the very thing the original + * racy version handled correctly. Spinning on a shared flag forces the + * threads to contend on the increment from the start. */ +static volatile int allocator_start; + +typedef struct { + MqttClient *client; + word16 ids[ALLOCATOR_PER_THREAD]; + int saw_zero; +} allocator_thread_arg_t; + +static void* allocator_worker(void *arg) +{ + allocator_thread_arg_t *ta = (allocator_thread_arg_t*)arg; + int i; + while (!allocator_start) { + /* yield to be friendly on oversubscribed CI runners */ + sched_yield(); + } + ta->saw_zero = 0; + for (i = 0; i < ALLOCATOR_PER_THREAD; i++) { + word16 id = MqttClient_NextPacketId(ta->client); + if (id == 0) { + ta->saw_zero = 1; + } + ta->ids[i] = id; + } + return NULL; +} + +/* HIGH-1 regression: prior to holding lockClient across the counter + * advance, two threads racing through the prologue could both publish the + * same next_packet_id and both return the same value. ALLOCATOR_TOTAL is + * well below the 0xFFFF cap, so a correct allocator must hand out + * ALLOCATOR_TOTAL distinct non-zero ids — any duplicate signals the race + * has reappeared. */ +TEST(next_packet_id_concurrent_no_duplicates) +{ + int rc; + pthread_t threads[ALLOCATOR_THREAD_COUNT]; + allocator_thread_arg_t args[ALLOCATOR_THREAD_COUNT]; + static byte seen[0x10000]; + int t, i; + int spawned = 0; + int spawn_failed = 0; + + rc = test_init_client(); + ASSERT_EQ(MQTT_CODE_SUCCESS, rc); + + allocator_start = 0; + for (t = 0; t < ALLOCATOR_THREAD_COUNT; t++) { + args[t].client = &test_client; + args[t].saw_zero = 0; + rc = pthread_create(&threads[t], NULL, allocator_worker, &args[t]); + if (rc != 0) { + spawn_failed = 1; + break; + } + spawned++; + } + + /* Release any threads we did spawn so their spin-gate exits and we can + * join them — even on the failure path. Skipping the gate would leak a + * spinning thread that consumes a core for the remainder of the run. */ + allocator_start = 1; + for (t = 0; t < spawned; t++) { + (void)pthread_join(threads[t], NULL); + } + ASSERT_EQ(0, spawn_failed); + + XMEMSET(seen, 0, sizeof(seen)); + for (t = 0; t < ALLOCATOR_THREAD_COUNT; t++) { + ASSERT_EQ(0, args[t].saw_zero); + for (i = 0; i < ALLOCATOR_PER_THREAD; i++) { + word16 id = args[t].ids[i]; + if (seen[id]) { + FAIL("duplicate packet_id returned by concurrent allocator"); + } + seen[id] = 1; + } + } + /* seen[0] must remain 0 — confirms no thread observed the 0 sentinel. */ + ASSERT_EQ(0, (int)seen[0]); +} +#endif /* WOLFMQTT_MULTITHREAD && POSIX */ + +#ifdef WOLFMQTT_MULTITHREAD +/* When every legal packet_id (1..0xFFFF) is in flight the allocator must + * surface the documented "back off" sentinel (0) rather than wrapping + * around forever or returning a colliding value. Constructs a synthetic + * pendResp chain covering the full range and asserts the return. */ +TEST(next_packet_id_returns_zero_when_saturated) +{ + int rc; + word32 i; + word32 count = (word32)MAX_PACKET_ID; + MqttPendResp *entries; + + rc = test_init_client(); + ASSERT_EQ(MQTT_CODE_SUCCESS, rc); + + entries = (MqttPendResp*)XMALLOC(sizeof(MqttPendResp) * count, NULL, + DYNAMIC_TYPE_TMP_BUFFER); + ASSERT_NOT_NULL(entries); + + /* Build a chain with packet_id = 1..MAX_PACKET_ID. The allocator only + * reads packet_id and walks `next`, so other fields can stay zeroed. */ + XMEMSET(entries, 0, sizeof(MqttPendResp) * count); + for (i = 0; i < count; i++) { + entries[i].packet_id = (word16)(i + 1); + entries[i].next = (i + 1 < count) ? &entries[i + 1] : NULL; + entries[i].prev = (i > 0) ? &entries[i - 1] : NULL; + } + test_client.firstPendResp = &entries[0]; + test_client.lastPendResp = &entries[count - 1]; + + ASSERT_EQ(0, MqttClient_NextPacketId(&test_client)); + + /* Detach the synthetic chain before teardown — DeInit must not walk + * into stack memory we are about to free. */ + test_client.firstPendResp = NULL; + test_client.lastPendResp = NULL; + XFREE(entries, NULL, DYNAMIC_TYPE_TMP_BUFFER); +} +#endif /* WOLFMQTT_MULTITHREAD */ + +#if defined(WOLFMQTT_MULTITHREAD) && defined(WOLFMQTT_NONBLOCK) +/* The allocator must consult the in-flight set (the pending response list) + * and skip any value that is currently registered. We register an + * in-flight QoS 1 publish at packet_id=5 via the public write-only API, + * then rewind the counter so the natural next allocation would be 5; the + * allocator must walk past it and return 6. */ +TEST(next_packet_id_skips_in_flight) +{ + int rc; + word16 allocated; + MqttPublish publish; + static byte payload[] = "p"; + + rc = test_init_client(); + ASSERT_EQ(MQTT_CODE_SUCCESS, rc); + + test_net.write = mock_net_write_accept; + + /* Park a pendResp with packet_id=5 in the resp list. */ + XMEMSET(&publish, 0, sizeof(publish)); + publish.qos = MQTT_QOS_1; + publish.packet_id = 5; + publish.topic_name = "t"; + publish.buffer = payload; + publish.total_len = (word32)(sizeof(payload) - 1); + publish.buffer_len = publish.total_len; + rc = MqttClient_Publish_WriteOnly(&test_client, &publish, NULL); + ASSERT_EQ(MQTT_CODE_CONTINUE, rc); + + /* Rewind so the next ++ yields 5. The allocator must skip 5 (in-flight) + * and return 6. */ + test_client.next_packet_id = 4; + allocated = MqttClient_NextPacketId(&test_client); + ASSERT_EQ(6, allocated); + + /* Once the in-flight entry is released, 5 becomes reusable again. */ + rc = MqttClient_CancelMessage(&test_client, (MqttObject*)&publish); + ASSERT_EQ(MQTT_CODE_SUCCESS, rc); + test_client.next_packet_id = 4; + allocated = MqttClient_NextPacketId(&test_client); + ASSERT_EQ(5, allocated); +} +#endif /* WOLFMQTT_MULTITHREAD && WOLFMQTT_NONBLOCK */ + /* ============================================================================ * MqttClient_WaitMessage Tests * ============================================================================ */ @@ -798,6 +1032,21 @@ void run_mqtt_client_tests(void) RUN_TEST(subscribe_in_flight_blocks_publish_with_same_packet_id); #endif + /* MqttClient_NextPacketId tests */ + RUN_TEST(next_packet_id_null_returns_zero); + RUN_TEST(next_packet_id_increments); + RUN_TEST(next_packet_id_skips_zero_on_wrap); +#if defined(WOLFMQTT_MULTITHREAD) && !defined(_WIN32) && \ + !defined(USE_WINDOWS_API) + RUN_TEST(next_packet_id_concurrent_no_duplicates); +#endif +#ifdef WOLFMQTT_MULTITHREAD + RUN_TEST(next_packet_id_returns_zero_when_saturated); +#endif +#if defined(WOLFMQTT_MULTITHREAD) && defined(WOLFMQTT_NONBLOCK) + RUN_TEST(next_packet_id_skips_in_flight); +#endif + /* MqttClient_WaitMessage tests */ RUN_TEST(wait_message_null_client); diff --git a/wolfmqtt/mqtt_client.h b/wolfmqtt/mqtt_client.h index ea30cc635..03bb4f686 100644 --- a/wolfmqtt/mqtt_client.h +++ b/wolfmqtt/mqtt_client.h @@ -218,6 +218,7 @@ typedef struct _MqttClient { #if defined(WOLFMQTT_NONBLOCK) && defined(WOLFMQTT_DEBUG_CLIENT) int lastRc; #endif + word16 next_packet_id; /* allocator state for MqttClient_NextPacketId */ } MqttClient; #ifdef WOLFMQTT_SN @@ -581,6 +582,27 @@ WOLFMQTT_API int MqttClient_GetProtocolVersion(MqttClient *client); */ WOLFMQTT_API const char* MqttClient_GetProtocolVersionString(MqttClient *client); +/*! \brief Allocate the next currently-unused MQTT Packet Identifier for + * this client. + * + * Per [MQTT-2.3.1-1] / MQTT 3.1.1 section 2.3.1, a new QoS-related Control + * Packet (PUBLISH with QoS > 0, SUBSCRIBE, UNSUBSCRIBE) must use a Packet + * Identifier that is not currently in use; the value only becomes + * reusable after the corresponding acknowledgement flow completes. This + * function is the central allocator: it advances a per-client counter, + * skips zero, and (under WOLFMQTT_MULTITHREAD) skips any value that is + * currently tracked in the pending response list. + * + * \param client Pointer to MqttClient structure + * \return Non-zero Packet Identifier on success, + * 0 if the client pointer is NULL, + * the in-use set is full (all 65535 values active), or + * (under WOLFMQTT_MULTITHREAD) the client lock cannot be + * acquired. In all cases the caller must back off rather + * than reuse a value. + */ +WOLFMQTT_API word16 MqttClient_NextPacketId(MqttClient *client); + #ifndef WOLFMQTT_NO_ERROR_STRINGS /*! \brief Performs lookup of the WOLFMQTT_API return values * \param return_code The return value from a WOLFMQTT_API function From 4d9145b89b85fe8feb2d974efb8b52e95fc8138b Mon Sep 17 00:00:00 2001 From: Eric Blankenhorn Date: Mon, 4 May 2026 16:31:33 -0500 Subject: [PATCH 12/32] Fix Password Flag 0 Extra Payload Is Accepted --- src/mqtt_packet.c | 11 +++- tests/test_mqtt_packet.c | 106 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 113 insertions(+), 4 deletions(-) diff --git a/src/mqtt_packet.c b/src/mqtt_packet.c index d91154d2e..fac795a9e 100644 --- a/src/mqtt_packet.c +++ b/src/mqtt_packet.c @@ -1332,7 +1332,16 @@ int MqttDecode_Connect(byte *rx_buf, int rx_buf_len, MqttConnect *mc_connect) rx_payload += tmp + plen; } - (void)rx_payload; + /* [MQTT-3.1.2-20] / [MQTT-3.1.2-22] and the payload-shape rules in + * 3.1.3: only fields whose CONNECT flags are set may appear in the + * payload. After decoding every flag-gated field the consumed length + * must equal Remaining Length exactly. Trailing bytes mean the wire + * carries fields the flags say are absent (e.g. Password Flag=0 with + * a password-shaped suffix), which the receiver must reject as + * malformed instead of silently dropping. */ + if ((rx_payload - rx_buf) != header_len + remain_len) { + return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_MALFORMED_DATA); + } /* Return total length of packet */ return header_len + remain_len; diff --git a/tests/test_mqtt_packet.c b/tests/test_mqtt_packet.c index c595afc80..d1d692953 100644 --- a/tests/test_mqtt_packet.c +++ b/tests/test_mqtt_packet.c @@ -2168,6 +2168,34 @@ TEST(decode_connect_rejects_nul_in_client_id) ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); } +/* [MQTT-3.1.2-20] Password Flag=0 means no password field is present in the + * payload. A peer that sets User Name Flag=1, Password Flag=0 but appends + * password-shaped bytes after the user name produces a CONNECT whose + * Remaining Length covers more than the flagged fields. The decoder must + * reject the trailing bytes as malformed instead of silently treating the + * packet as username-only-with-extra-trailer. Hand-built wire mirrors the + * issue's reproducer exactly. */ +TEST(decode_connect_password_flag_zero_with_extra_payload_rejected) +{ + byte buf[] = { + 0x10, 0x1D, /* CONNECT, remain_len = 29 */ + 0x00, 0x04, 'M', 'Q', 'T', 'T', /* protocol name */ + 0x04, /* protocol level = 4 (v3.1.1) */ + 0x82, /* flags: clean_session|user_name */ + 0x00, 0x3C, /* keep alive = 60 */ + 0x00, 0x03, 'c', 'i', 'd', /* client_id "cid" */ + 0x00, 0x04, 'u', 's', 'e', 'r', /* username "user" */ + 0x00, 0x06, 's', 'e', 'c', 'r', /* extra password-shaped bytes */ + 'e', 't' /* "secret" — must not be accepted */ + }; + MqttConnect dec; + int rc; + + XMEMSET(&dec, 0, sizeof(dec)); + rc = MqttDecode_Connect(buf, (int)sizeof(buf), &dec); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); +} + /* [MQTT-1.5.3-2]: an embedded NUL in the username must be rejected. * Otherwise BrokerStrCompare() (which uses XSTRLEN) will treat * "us\0er" as "us" and accept it against a configured "us" credential. */ @@ -2190,6 +2218,29 @@ TEST(decode_connect_rejects_nul_in_username) ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); } +/* User Name Flag=0 cannot legally be followed by a username (and any + * trailing bytes therefore include none of the flagged fields). The + * payload-consumption check rejects the trailer regardless of which flag + * the bytes are shaped like. */ +TEST(decode_connect_username_flag_zero_with_extra_payload_rejected) +{ + byte buf[] = { + 0x10, 0x15, /* CONNECT, remain_len = 21 */ + 0x00, 0x04, 'M', 'Q', 'T', 'T', + 0x04, + 0x02, /* flags: clean_session only */ + 0x00, 0x3C, + 0x00, 0x03, 'c', 'i', 'd', /* client_id */ + 0x00, 0x04, 'u', 's', 'e', 'r' /* username-shaped extra bytes */ + }; + MqttConnect dec; + int rc; + + XMEMSET(&dec, 0, sizeof(dec)); + rc = MqttDecode_Connect(buf, (int)sizeof(buf), &dec); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); +} + /* [MQTT-1.5.3-2] / [MQTT-4.7.3-2]: a Will Topic with embedded NUL must * be rejected. The same C-string truncation that affects PUBLISH topics * applies to Will Topics persisted by the broker. */ @@ -2216,12 +2267,57 @@ TEST(decode_connect_rejects_nul_in_will_topic) ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); } +/* Will Flag=0 with will-topic-shaped trailing bytes is the symmetric case + * for the LWT half of the payload. Pins the same consumed-length invariant + * for the will fields. */ +TEST(decode_connect_will_flag_zero_with_extra_payload_rejected) +{ + byte buf[] = { + 0x10, 0x17, /* CONNECT, remain_len = 23 */ + 0x00, 0x04, 'M', 'Q', 'T', 'T', + 0x04, + 0x02, /* flags: clean_session only */ + 0x00, 0x3C, + 0x00, 0x03, 'c', 'i', 'd', + 0x00, 0x06, 'w', '/', 't', 'o', 'p', 'i' /* extra "w/topi" */ + }; + MqttConnect dec; + int rc; + + XMEMSET(&dec, 0, sizeof(dec)); + rc = MqttDecode_Connect(buf, (int)sizeof(buf), &dec); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); +} + +/* Single-byte trailing garbage (no flag fields beyond client_id) catches + * an off-by-one form of the consumption check. The non-malformed case + * (decode_connect_v311_no_credentials above) is the partner that prevents + * a "reject everything" mutation. */ +TEST(decode_connect_trailing_garbage_rejected) +{ + byte buf[] = { + 0x10, 0x10, /* CONNECT, remain_len = 16 */ + 0x00, 0x04, 'M', 'Q', 'T', 'T', + 0x04, + 0x02, + 0x00, 0x3C, + 0x00, 0x03, 'c', 'i', 'd', + 0xFF /* one trailing junk byte */ + }; + MqttConnect dec; + int rc; + + XMEMSET(&dec, 0, sizeof(dec)); + rc = MqttDecode_Connect(buf, (int)sizeof(buf), &dec); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); +} + #ifdef WOLFMQTT_V5 /* MQTT v5 has a different decode path than v3.1.1 (properties walked * before client_id, separate LWT properties), but the NUL rejection - * lives inside MqttDecode_String / MqttDecode_Password and so applies - * uniformly. This pins coverage on the v5 branch so a future refactor - * cannot quietly bypass the check on one protocol level. */ + * lives inside MqttDecode_String and so applies uniformly. This pins + * coverage on the v5 branch so a future refactor cannot quietly bypass + * the check on one protocol level. */ TEST(decode_connect_v5_rejects_nul_in_client_id) { byte buf[] = { @@ -3261,6 +3357,10 @@ void run_mqtt_packet_tests(void) * Password is Binary Data per [MQTT-3.1.3.5] and decoding is routed * around MqttDecode_String, so the U+0000 ban does not apply there. */ RUN_TEST(decode_connect_rejects_nul_in_will_topic); + RUN_TEST(decode_connect_password_flag_zero_with_extra_payload_rejected); + RUN_TEST(decode_connect_username_flag_zero_with_extra_payload_rejected); + RUN_TEST(decode_connect_will_flag_zero_with_extra_payload_rejected); + RUN_TEST(decode_connect_trailing_garbage_rejected); #ifdef WOLFMQTT_V5 RUN_TEST(decode_connect_v5_rejects_nul_in_client_id); #endif From d2d08520d65597e5a35d990a61c22f1b149d9038 Mon Sep 17 00:00:00 2001 From: Eric Blankenhorn Date: Mon, 4 May 2026 16:34:36 -0500 Subject: [PATCH 13/32] Cleanup --- tests/test_mqtt_client.c | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/test_mqtt_client.c b/tests/test_mqtt_client.c index 48d50cbb2..780749a68 100644 --- a/tests/test_mqtt_client.c +++ b/tests/test_mqtt_client.c @@ -837,8 +837,7 @@ TEST(next_packet_id_returns_zero_when_saturated) rc = test_init_client(); ASSERT_EQ(MQTT_CODE_SUCCESS, rc); - entries = (MqttPendResp*)XMALLOC(sizeof(MqttPendResp) * count, NULL, - DYNAMIC_TYPE_TMP_BUFFER); + entries = (MqttPendResp*)WOLFMQTT_MALLOC(sizeof(MqttPendResp) * count); ASSERT_NOT_NULL(entries); /* Build a chain with packet_id = 1..MAX_PACKET_ID. The allocator only @@ -858,7 +857,7 @@ TEST(next_packet_id_returns_zero_when_saturated) * into stack memory we are about to free. */ test_client.firstPendResp = NULL; test_client.lastPendResp = NULL; - XFREE(entries, NULL, DYNAMIC_TYPE_TMP_BUFFER); + WOLFMQTT_FREE(entries); } #endif /* WOLFMQTT_MULTITHREAD */ From ca4b78a59ea17f7d1a762fbe678c64f763ecb21a Mon Sep 17 00:00:00 2001 From: Eric Blankenhorn Date: Mon, 4 May 2026 16:56:01 -0500 Subject: [PATCH 14/32] Password Flag Without User Name Flag Is Accepted --- src/mqtt_packet.c | 12 ++++++++ tests/test_mqtt_packet.c | 62 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/src/mqtt_packet.c b/src/mqtt_packet.c index fac795a9e..ae438b75a 100644 --- a/src/mqtt_packet.c +++ b/src/mqtt_packet.c @@ -1178,6 +1178,18 @@ int MqttDecode_Connect(byte *rx_buf, int rx_buf_len, MqttConnect *mc_connect) mc_connect->username = NULL; mc_connect->password = NULL; + /* [MQTT-3.1.2-22] (v3.1.1 only) If the User Name Flag is 0, the + * Password Flag MUST be 0. MQTT v5 section 3.1.2.9 explicitly relaxes + * this — "This version of the protocol allows the sending of a + * Password with no User Name, where MQTT v3.1.1 did not." — so the + * check is gated on the protocol level. mc_connect->protocol_level was + * just populated above. */ + if (mc_connect->protocol_level == MQTT_CONNECT_PROTOCOL_LEVEL_4 && + (packet.flags & MQTT_CONNECT_FLAG_PASSWORD) && + !(packet.flags & MQTT_CONNECT_FLAG_USERNAME)) { + return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_MALFORMED_DATA); + } + tmp = MqttDecode_Num((byte*)&packet.keep_alive, &mc_connect->keep_alive_sec, MQTT_DATA_LEN_SIZE); if (tmp < 0) { diff --git a/tests/test_mqtt_packet.c b/tests/test_mqtt_packet.c index d1d692953..13185838b 100644 --- a/tests/test_mqtt_packet.c +++ b/tests/test_mqtt_packet.c @@ -2289,6 +2289,32 @@ TEST(decode_connect_will_flag_zero_with_extra_payload_rejected) ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); } +/* [MQTT-3.1.2-22] If the User Name Flag is 0, the Password Flag MUST be 0. + * The encoder already enforces this; the decoder must too. Wire mirrors + * issue #512's reproducer: flags 0x42 = clean_session | password, with + * client_id "cid" followed by a "secret" password field. Catches a + * regression that drops the receive-side flag-pair check and silently + * accepts a password without a user name. */ +TEST(decode_connect_password_flag_without_username_flag_rejected) +{ + byte buf[] = { + 0x10, 0x17, /* CONNECT, remain_len = 23 */ + 0x00, 0x04, 'M', 'Q', 'T', 'T', + 0x04, /* protocol level = 4 (v3.1.1) */ + 0x42, /* flags: clean_session|password */ + 0x00, 0x3C, /* keep alive = 60 */ + 0x00, 0x03, 'c', 'i', 'd', /* client_id "cid" */ + 0x00, 0x06, 's', 'e', 'c', 'r', /* password "secret" */ + 'e', 't' + }; + MqttConnect dec; + int rc; + + XMEMSET(&dec, 0, sizeof(dec)); + rc = MqttDecode_Connect(buf, (int)sizeof(buf), &dec); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); +} + /* Single-byte trailing garbage (no flag fields beyond client_id) catches * an off-by-one form of the consumption check. The non-malformed case * (decode_connect_v311_no_credentials above) is the partner that prevents @@ -2338,6 +2364,40 @@ TEST(decode_connect_v5_rejects_nul_in_client_id) ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); MqttProps_Free(dec.props); } + +/* MQTT v5 section 3.1.2.9 explicitly allows Password without User Name — + * "This version of the protocol allows the sending of a Password with no + * User Name, where MQTT v3.1.1 did not." Pins the protocol-level gate on + * the [MQTT-3.1.2-22] check: a future change that drops the level guard + * would reject this valid v5 wire and trip this test. The companion + * negative case is decode_connect_password_flag_without_username_flag_- + * rejected above (level=4). */ +TEST(decode_connect_v5_password_without_username_accepted) +{ + byte buf[] = { + 0x10, 0x18, /* CONNECT, remain_len = 24 */ + 0x00, 0x04, 'M', 'Q', 'T', 'T', + 0x05, /* protocol level = 5 */ + 0x42, /* flags: clean_session|password */ + 0x00, 0x3C, /* keep alive */ + 0x00, /* properties length = 0 */ + 0x00, 0x03, 'c', 'i', 'd', /* client_id "cid" */ + 0x00, 0x06, 's', 'e', 'c', 'r', /* password "secret" */ + 'e', 't' + }; + MqttConnect dec; + int rc; + + XMEMSET(&dec, 0, sizeof(dec)); + dec.protocol_level = MQTT_CONNECT_PROTOCOL_LEVEL_5; + rc = MqttDecode_Connect(buf, (int)sizeof(buf), &dec); + ASSERT_TRUE(rc > 0); + ASSERT_EQ(MQTT_CONNECT_PROTOCOL_LEVEL_5, dec.protocol_level); + ASSERT_NULL(dec.username); + ASSERT_NOT_NULL(dec.password); + ASSERT_EQ(0, XMEMCMP(dec.password, "secret", 6)); + MqttProps_Free(dec.props); +} #endif /* WOLFMQTT_V5 */ #endif /* WOLFMQTT_BROKER */ @@ -3360,9 +3420,11 @@ void run_mqtt_packet_tests(void) RUN_TEST(decode_connect_password_flag_zero_with_extra_payload_rejected); RUN_TEST(decode_connect_username_flag_zero_with_extra_payload_rejected); RUN_TEST(decode_connect_will_flag_zero_with_extra_payload_rejected); + RUN_TEST(decode_connect_password_flag_without_username_flag_rejected); RUN_TEST(decode_connect_trailing_garbage_rejected); #ifdef WOLFMQTT_V5 RUN_TEST(decode_connect_v5_rejects_nul_in_client_id); + RUN_TEST(decode_connect_v5_password_without_username_accepted); #endif /* MqttDecode_Subscribe */ From ee3600046c463d220a571c1da8dc8aaed043a1f9 Mon Sep 17 00:00:00 2001 From: Eric Blankenhorn Date: Mon, 4 May 2026 17:09:36 -0500 Subject: [PATCH 15/32] Fix CONNECT Flags Cross-Constraint Violations Are Accepted --- src/mqtt_packet.c | 23 ++++++++++ tests/test_mqtt_packet.c | 93 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+) diff --git a/src/mqtt_packet.c b/src/mqtt_packet.c index ae438b75a..e354b6a4f 100644 --- a/src/mqtt_packet.c +++ b/src/mqtt_packet.c @@ -1178,6 +1178,29 @@ int MqttDecode_Connect(byte *rx_buf, int rx_buf_len, MqttConnect *mc_connect) mc_connect->username = NULL; mc_connect->password = NULL; + /* [MQTT-3.1.2-3] CONNECT flags bit 0 is reserved and MUST be 0. + * Applies to both v3.1.1 and v5. */ + if (packet.flags & MQTT_CONNECT_FLAG_RESERVED) { + return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_MALFORMED_DATA); + } + + /* [MQTT-3.1.2-13] / [MQTT-3.1.2-15] If the Will Flag is 0, Will QoS + * MUST be 0 and Will Retain MUST be 0. Applies to both v3.1.1 and v5 + * (v5 section 3.1.2.6 / 3.1.2.5 carry the same constraint). */ + if (!(packet.flags & MQTT_CONNECT_FLAG_WILL_FLAG) && + (packet.flags & (MQTT_CONNECT_FLAG_WILL_QOS_MASK | + MQTT_CONNECT_FLAG_WILL_RETAIN))) { + return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_MALFORMED_DATA); + } + + /* [MQTT-3.1.2-14] Will QoS = 3 is reserved and MUST NOT be used. + * Only meaningful when Will Flag = 1; the Will-Flag-0 check above + * already rejects nonzero QoS bits in that case. */ + if ((packet.flags & MQTT_CONNECT_FLAG_WILL_FLAG) && + MQTT_CONNECT_FLAG_GET_QOS(packet.flags) == MQTT_QOS_3) { + return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_MALFORMED_DATA); + } + /* [MQTT-3.1.2-22] (v3.1.1 only) If the User Name Flag is 0, the * Password Flag MUST be 0. MQTT v5 section 3.1.2.9 explicitly relaxes * this — "This version of the protocol allows the sending of a diff --git a/tests/test_mqtt_packet.c b/tests/test_mqtt_packet.c index 13185838b..a111357e5 100644 --- a/tests/test_mqtt_packet.c +++ b/tests/test_mqtt_packet.c @@ -2289,6 +2289,95 @@ TEST(decode_connect_will_flag_zero_with_extra_payload_rejected) ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); } +/* [MQTT-3.1.2-3] CONNECT flags bit 0 is reserved and MUST be 0. Applies + * regardless of protocol level. Flags 0x03 = reserved | clean_session. */ +TEST(decode_connect_reserved_flag_bit_rejected) +{ + byte buf[] = { + 0x10, 0x0F, /* CONNECT, remain_len = 15 */ + 0x00, 0x04, 'M', 'Q', 'T', 'T', + 0x04, /* protocol level = 4 */ + 0x03, /* flags: reserved | clean_session */ + 0x00, 0x3C, + 0x00, 0x03, 'c', 'i', 'd' + }; + MqttConnect dec; + int rc; + + XMEMSET(&dec, 0, sizeof(dec)); + rc = MqttDecode_Connect(buf, (int)sizeof(buf), &dec); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); +} + +/* [MQTT-3.1.2-13] If Will Flag is 0, Will QoS MUST be 0. Flags 0x0A = + * clean_session | will_qos=1 with Will Flag clear. */ +TEST(decode_connect_will_qos_with_will_flag_zero_rejected) +{ + byte buf[] = { + 0x10, 0x0F, /* CONNECT, remain_len = 15 */ + 0x00, 0x04, 'M', 'Q', 'T', 'T', + 0x04, + 0x0A, /* will_qos=1 but will_flag=0 */ + 0x00, 0x3C, + 0x00, 0x03, 'c', 'i', 'd' + }; + MqttConnect dec; + int rc; + + XMEMSET(&dec, 0, sizeof(dec)); + rc = MqttDecode_Connect(buf, (int)sizeof(buf), &dec); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); +} + +/* [MQTT-3.1.2-15] If Will Flag is 0, Will Retain MUST be 0. Flags 0x22 = + * clean_session | will_retain with Will Flag clear. */ +TEST(decode_connect_will_retain_with_will_flag_zero_rejected) +{ + byte buf[] = { + 0x10, 0x0F, /* CONNECT, remain_len = 15 */ + 0x00, 0x04, 'M', 'Q', 'T', 'T', + 0x04, + 0x22, /* will_retain but will_flag=0 */ + 0x00, 0x3C, + 0x00, 0x03, 'c', 'i', 'd' + }; + MqttConnect dec; + int rc; + + XMEMSET(&dec, 0, sizeof(dec)); + rc = MqttDecode_Connect(buf, (int)sizeof(buf), &dec); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); +} + +/* [MQTT-3.1.2-14] Will QoS = 3 is reserved. Flags 0x1E set the full QoS + * mask (bits 4-3 = 0b11) along with Will Flag and Clean Session. The + * earlier Will-Flag-0 check would not catch this — only the QoS-value + * check fires. Provides full Will fields so a regression that drops the + * QoS=3 check returns success rather than tripping a downstream + * OUT_OF_BUFFER. */ +TEST(decode_connect_will_qos3_rejected) +{ + byte buf[] = { + 0x10, 0x1C, /* CONNECT, remain_len = 28 */ + 0x00, 0x04, 'M', 'Q', 'T', 'T', + 0x04, + 0x1E, /* clean | will_flag | will_qos=3 */ + 0x00, 0x3C, + 0x00, 0x03, 'c', 'i', 'd', + 0x00, 0x06, 'w', '/', 't', 'o', 'p', 'c', /* will topic */ + 0x00, 0x03, 'b', 'y', 'e' /* will payload */ + }; + MqttConnect dec; + MqttMessage lwt; + int rc; + + XMEMSET(&dec, 0, sizeof(dec)); + XMEMSET(&lwt, 0, sizeof(lwt)); + dec.lwt_msg = &lwt; + rc = MqttDecode_Connect(buf, (int)sizeof(buf), &dec); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); +} + /* [MQTT-3.1.2-22] If the User Name Flag is 0, the Password Flag MUST be 0. * The encoder already enforces this; the decoder must too. Wire mirrors * issue #512's reproducer: flags 0x42 = clean_session | password, with @@ -3420,6 +3509,10 @@ void run_mqtt_packet_tests(void) RUN_TEST(decode_connect_password_flag_zero_with_extra_payload_rejected); RUN_TEST(decode_connect_username_flag_zero_with_extra_payload_rejected); RUN_TEST(decode_connect_will_flag_zero_with_extra_payload_rejected); + RUN_TEST(decode_connect_reserved_flag_bit_rejected); + RUN_TEST(decode_connect_will_qos_with_will_flag_zero_rejected); + RUN_TEST(decode_connect_will_retain_with_will_flag_zero_rejected); + RUN_TEST(decode_connect_will_qos3_rejected); RUN_TEST(decode_connect_password_flag_without_username_flag_rejected); RUN_TEST(decode_connect_trailing_garbage_rejected); #ifdef WOLFMQTT_V5 From b865dd3a43f3a3aaeea083c6325092d323aff9fd Mon Sep 17 00:00:00 2001 From: Eric Blankenhorn Date: Mon, 4 May 2026 17:22:12 -0500 Subject: [PATCH 16/32] Fix Empty SUBSCRIBE and UNSUBSCRIBE Payloads Are Accepted --- src/mqtt_packet.c | 14 ++++++ tests/test_mqtt_packet.c | 101 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+) diff --git a/src/mqtt_packet.c b/src/mqtt_packet.c index e354b6a4f..4c05370b3 100644 --- a/src/mqtt_packet.c +++ b/src/mqtt_packet.c @@ -2175,6 +2175,13 @@ int MqttDecode_Subscribe(byte *rx_buf, int rx_buf_len, MqttSubscribe *subscribe) topic->qos = (MqttQoS)(options & 0x03); subscribe->topic_count++; } + + /* [MQTT-3.8.3-3] The payload of a SUBSCRIBE packet MUST contain at + * least one Topic Filter / QoS pair. v5 §3.8.3 carries the same + * minimum-cardinality requirement. */ + if (subscribe->topic_count == 0) { + return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_MALFORMED_DATA); + } } (void)rx_payload; @@ -2467,6 +2474,13 @@ int MqttDecode_Unsubscribe(byte *rx_buf, int rx_buf_len, MqttUnsubscribe *unsubs rx_payload += tmp; unsubscribe->topic_count++; } + + /* [MQTT-3.10.3-2] The Payload of an UNSUBSCRIBE packet MUST + * contain at least one Topic Filter. v5 §3.10.3 carries the same + * minimum-cardinality requirement. */ + if (unsubscribe->topic_count == 0) { + return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_MALFORMED_DATA); + } } (void)rx_payload; diff --git a/tests/test_mqtt_packet.c b/tests/test_mqtt_packet.c index a111357e5..83cc5f384 100644 --- a/tests/test_mqtt_packet.c +++ b/tests/test_mqtt_packet.c @@ -2573,6 +2573,55 @@ TEST(decode_subscribe_rejects_nul_in_filter) ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); } +/* [MQTT-3.8.3-3] The payload of a SUBSCRIBE packet MUST contain at least + * one Topic Filter / QoS pair. Wire is exactly the issue's repro: type + * byte 0x82, remain_len=2, just the Packet Identifier with no topic + * elements. Without the fix the decoder returned 4 (the packet length) + * with topic_count=0. */ +TEST(decode_subscribe_empty_payload_rejected) +{ + byte rx_buf[] = { + 0x82, 0x02, + 0x00, 0x01 /* packet_id only — no topics */ + }; + MqttSubscribe sub; + MqttTopic topic_arr[1]; + int rc; + + XMEMSET(&sub, 0, sizeof(sub)); + XMEMSET(topic_arr, 0, sizeof(topic_arr)); + sub.topics = topic_arr; + rc = MqttDecode_Subscribe(rx_buf, (int)sizeof(rx_buf), &sub); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); +} + +#ifdef WOLFMQTT_V5 +/* v5 §3.8.3 carries the same minimum-cardinality requirement as + * [MQTT-3.8.3-3]. The v5 path is distinct: it consumes a Properties VBI + * before reaching the topic loop. Wire is remain_len=3 = packet_id + + * props_len=0, so the topic loop runs zero iterations. Without this + * test, a future refactor of the v5 properties block could silently stop + * the empty-payload guard from firing on v5 while v3.1.1 stays covered. */ +TEST(decode_subscribe_v5_empty_payload_rejected) +{ + byte rx_buf[] = { + 0x82, 0x03, + 0x00, 0x01, /* packet_id */ + 0x00 /* properties length = 0 */ + }; + MqttSubscribe sub; + MqttTopic topic_arr[1]; + int rc; + + XMEMSET(&sub, 0, sizeof(sub)); + XMEMSET(topic_arr, 0, sizeof(topic_arr)); + sub.topics = topic_arr; + sub.protocol_level = MQTT_CONNECT_PROTOCOL_LEVEL_5; + rc = MqttDecode_Subscribe(rx_buf, (int)sizeof(rx_buf), &sub); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); +} +#endif /* WOLFMQTT_V5 */ + /* [MQTT-2.3.1-1] SUBSCRIBE must carry a non-zero Packet Identifier on the * receive path as well as the transmit path. */ TEST(decode_subscribe_packet_id_zero_rejected) @@ -2654,6 +2703,50 @@ TEST(decode_unsubscribe_rejects_nul_in_filter) ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); } +/* [MQTT-3.10.3-2] The Payload of an UNSUBSCRIBE packet MUST contain at + * least one Topic Filter. Wire matches the issue's repro: type byte + * 0xA2, remain_len=2, just the Packet Identifier with no topic elements. + * Without the fix the decoder returned 4 with topic_count=0. */ +TEST(decode_unsubscribe_empty_payload_rejected) +{ + byte rx_buf[] = { + 0xA2, 0x02, + 0x00, 0x01 /* packet_id only — no topics */ + }; + MqttUnsubscribe unsub; + MqttTopic topic_arr[1]; + int rc; + + XMEMSET(&unsub, 0, sizeof(unsub)); + XMEMSET(topic_arr, 0, sizeof(topic_arr)); + unsub.topics = topic_arr; + rc = MqttDecode_Unsubscribe(rx_buf, (int)sizeof(rx_buf), &unsub); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); +} + +#ifdef WOLFMQTT_V5 +/* v5 §3.10.3 carries the same minimum-cardinality requirement as + * [MQTT-3.10.3-2]. Wire is remain_len=3 = packet_id + props_len=0. */ +TEST(decode_unsubscribe_v5_empty_payload_rejected) +{ + byte rx_buf[] = { + 0xA2, 0x03, + 0x00, 0x01, /* packet_id */ + 0x00 /* properties length = 0 */ + }; + MqttUnsubscribe unsub; + MqttTopic topic_arr[1]; + int rc; + + XMEMSET(&unsub, 0, sizeof(unsub)); + XMEMSET(topic_arr, 0, sizeof(topic_arr)); + unsub.topics = topic_arr; + unsub.protocol_level = MQTT_CONNECT_PROTOCOL_LEVEL_5; + rc = MqttDecode_Unsubscribe(rx_buf, (int)sizeof(rx_buf), &unsub); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); +} +#endif /* WOLFMQTT_V5 */ + /* [MQTT-2.3.1-1] UNSUBSCRIBE must carry a non-zero Packet Identifier on * the receive path as well as the transmit path. */ TEST(decode_unsubscribe_packet_id_zero_rejected) @@ -3525,6 +3618,10 @@ void run_mqtt_packet_tests(void) RUN_TEST(decode_subscribe_v311_qos3_reserved); RUN_TEST(decode_subscribe_rejects_nul_in_filter); RUN_TEST(decode_subscribe_packet_id_zero_rejected); + RUN_TEST(decode_subscribe_empty_payload_rejected); +#ifdef WOLFMQTT_V5 + RUN_TEST(decode_subscribe_v5_empty_payload_rejected); +#endif #ifdef WOLFMQTT_V5 RUN_TEST(decode_subscribe_v5_options_byte_qos_extracted); #endif @@ -3532,6 +3629,10 @@ void run_mqtt_packet_tests(void) /* MqttDecode_Unsubscribe */ RUN_TEST(decode_unsubscribe_rejects_nul_in_filter); RUN_TEST(decode_unsubscribe_packet_id_zero_rejected); + RUN_TEST(decode_unsubscribe_empty_payload_rejected); +#ifdef WOLFMQTT_V5 + RUN_TEST(decode_unsubscribe_v5_empty_payload_rejected); +#endif #endif /* QoS 2 ack arithmetic */ From 43b9264276bdbc496f2cb7e3bcbff11fbee56863 Mon Sep 17 00:00:00 2001 From: Eric Blankenhorn Date: Tue, 5 May 2026 07:24:23 -0500 Subject: [PATCH 17/32] Fix PINGREQ, PINGRESP, and DISCONNECT Nonzero Remaining Length Is Accepted --- src/mqtt_broker.c | 20 +++++ src/mqtt_packet.c | 14 ++++ tests/test_broker_connect.c | 149 ++++++++++++++++++++++++++++++++++++ tests/test_mqtt_packet.c | 96 +++++++++++++++++++++++ 4 files changed, 279 insertions(+) diff --git a/src/mqtt_broker.c b/src/mqtt_broker.c index 16bd245e2..43700dafd 100644 --- a/src/mqtt_broker.c +++ b/src/mqtt_broker.c @@ -3966,9 +3966,29 @@ static int BrokerClient_Process(MqttBroker* broker, BrokerClient* bc) break; } case MQTT_PACKET_TYPE_PING_REQ: + /* MQTT 3.1.1 §3.12 / v5 §3.12: PINGREQ is fixed-header- + * only — Remaining Length MUST be 0. Reject malformed + * PINGREQ before sending PINGRESP. */ + if (bc->client.packet.remain_len != 0) { + BrokerClient_AbnormalClose(broker, bc); + return 0; + } (void)BrokerSend_PingResp(bc); break; case MQTT_PACKET_TYPE_DISCONNECT: + /* MQTT 3.1.1 §3.14: DISCONNECT has no variable header and + * no payload — Remaining Length MUST be 0. v5 §3.14 + * relaxes this to allow an optional Reason Code and + * Properties, so the check is gated on protocol level. */ + #ifdef WOLFMQTT_V5 + if (bc->protocol_level < MQTT_CONNECT_PROTOCOL_LEVEL_5 && + bc->client.packet.remain_len != 0) { + #else + if (bc->client.packet.remain_len != 0) { + #endif + BrokerClient_AbnormalClose(broker, bc); + return 0; + } BrokerClient_ClearWill(bc); /* normal disconnect */ /* Session persistence: keep subs if clean_session=0 */ if (bc->clean_session) { diff --git a/src/mqtt_packet.c b/src/mqtt_packet.c index 4c05370b3..dfd37050f 100644 --- a/src/mqtt_packet.c +++ b/src/mqtt_packet.c @@ -2700,6 +2700,12 @@ int MqttDecode_Ping(byte *rx_buf, int rx_buf_len, MqttPing* ping) return header_len; } + /* MQTT 3.1.1 §3.13 / v5 §3.13: PINGRESP has no variable header and no + * payload, so Remaining Length MUST be 0. */ + if (remain_len != 0) { + return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_MALFORMED_DATA); + } + if (ping) { /* nothing to decode */ } @@ -2814,6 +2820,14 @@ int MqttDecode_Disconnect(byte *rx_buf, int rx_buf_len, MqttDisconnect* disc) return header_len; } + /* MQTT 3.1.1 §3.14: DISCONNECT has no variable header and no payload, + * so Remaining Length MUST be 0. The WOLFMQTT_V5 decoder below + * legitimately accepts remain_len > 0 for the Reason Code and + * Properties. */ + if (remain_len != 0) { + return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_MALFORMED_DATA); + } + if (disc) { /* nothing to decode for v3.1.1 */ } diff --git a/tests/test_broker_connect.c b/tests/test_broker_connect.c index d682f6031..c1a2b8926 100644 --- a/tests/test_broker_connect.c +++ b/tests/test_broker_connect.c @@ -1008,6 +1008,150 @@ TEST(qos2_pubrel_unknown_id_still_pubcomps) MqttBroker_Free(&broker); } +/* MQTT 3.1.1 §3.12 / v5 §3.12: PINGREQ has no variable header and no + * payload, so Remaining Length MUST be 0. Broker dispatch must reject a + * malformed PINGREQ with an abnormal close instead of emitting a + * PINGRESP. Issue #515. + * + * The valid case is paired so a regression that reverses the conditional + * (rejecting valid PINGREQs) trips the positive test. */ +TEST(pingreq_valid_emits_pingresp) +{ + MqttBroker broker; + MqttBrokerNet net; + static const byte connect[] = { + 0x10, 0x0D, + 0x00, 0x04, 'M', 'Q', 'T', 'T', + 0x04, 0x02, 0x00, 0x3C, + 0x00, 0x01, 'A' + }; + static const byte pingreq_valid[] = { 0xC0, 0x00 }; + int i; + + install_mock_net(&net); + XMEMSET(&broker, 0, sizeof(broker)); + ASSERT_EQ(MQTT_CODE_SUCCESS, MqttBroker_Init(&broker, &net)); + ASSERT_EQ(MQTT_CODE_SUCCESS, MqttBroker_Start(&broker)); + + reset_mock_clients(1); + mock_client_input_append(0, connect, sizeof(connect)); + mock_client_input_append(0, pingreq_valid, sizeof(pingreq_valid)); + for (i = 0; i < 8; i++) { + MqttBroker_Step(&broker); + } + + ASSERT_EQ(1, count_packets_of_type(g_out_buf, g_out_len, + MQTT_PACKET_TYPE_PING_RESP)); + ASSERT_FALSE(g_client_closed); + + MqttBroker_Stop(&broker); + MqttBroker_Free(&broker); +} + +TEST(pingreq_nonzero_remain_len_closes_no_pingresp) +{ + MqttBroker broker; + MqttBrokerNet net; + static const byte connect[] = { + 0x10, 0x0D, + 0x00, 0x04, 'M', 'Q', 'T', 'T', + 0x04, 0x02, 0x00, 0x3C, + 0x00, 0x01, 'A' + }; + /* Issue #515 reproducer wire: C0 01 00 — PINGREQ with one trailing + * byte. The fixed-header-only rule makes this malformed. */ + static const byte pingreq_bad[] = { 0xC0, 0x01, 0x00 }; + int i; + + install_mock_net(&net); + XMEMSET(&broker, 0, sizeof(broker)); + ASSERT_EQ(MQTT_CODE_SUCCESS, MqttBroker_Init(&broker, &net)); + ASSERT_EQ(MQTT_CODE_SUCCESS, MqttBroker_Start(&broker)); + + reset_mock_clients(1); + mock_client_input_append(0, connect, sizeof(connect)); + mock_client_input_append(0, pingreq_bad, sizeof(pingreq_bad)); + for (i = 0; i < 8; i++) { + MqttBroker_Step(&broker); + } + + ASSERT_EQ(0, count_packets_of_type(g_out_buf, g_out_len, + MQTT_PACKET_TYPE_PING_RESP)); + ASSERT_TRUE(g_client_closed); + + MqttBroker_Stop(&broker); + MqttBroker_Free(&broker); +} + +/* MQTT 3.1.1 §3.14: DISCONNECT has no variable header and no payload, so + * Remaining Length MUST be 0. The strong observable for "malformed + * DISCONNECT was rejected" is the Last Will: a normal DISCONNECT clears + * the will, but AbnormalClose fires it. Two clients — subscriber on the + * will topic, publisher with an LWT — let us assert the broker dispatched + * the malformed packet through AbnormalClose by observing the will + * delivery. v5 has its own decoder that legitimately accepts Reason Code + * + Properties, so the broker's remain_len check (and this test) is + * v3.1.1-only. */ +#ifndef WOLFMQTT_V5 +TEST(disconnect_v311_nonzero_remain_len_fires_will) +{ + MqttBroker broker; + MqttBrokerNet net; + int i; + /* Subscriber CONNECT (clean=1, ClientId "S") then SUBSCRIBE to "lwt". */ + static const byte sub_connect[] = { + 0x10, 0x0D, + 0x00, 0x04, 'M', 'Q', 'T', 'T', + 0x04, 0x02, 0x00, 0x3C, + 0x00, 0x01, 'S' + }; + static const byte sub_subscribe[] = { + 0x82, 0x08, + 0x00, 0x01, + 0x00, 0x03, 'l', 'w', 't', + 0x00 + }; + /* Publisher CONNECT with LWT: flags = 0x06 = will_flag | clean_session; + * will_qos=0, will_retain=0; will_topic "lwt"; will_payload "bye". */ + static const byte pub_connect[] = { + 0x10, 0x17, + 0x00, 0x04, 'M', 'Q', 'T', 'T', + 0x04, 0x06, 0x00, 0x3C, + 0x00, 0x01, 'P', + 0x00, 0x03, 'l', 'w', 't', + 0x00, 0x03, 'b', 'y', 'e' + }; + /* Issue #515 reproducer wire: E0 01 00 — malformed v3.1.1 DISCONNECT. */ + static const byte disconnect_bad[] = { 0xE0, 0x01, 0x00 }; + + install_mock_net(&net); + XMEMSET(&broker, 0, sizeof(broker)); + ASSERT_EQ(MQTT_CODE_SUCCESS, MqttBroker_Init(&broker, &net)); + ASSERT_EQ(MQTT_CODE_SUCCESS, MqttBroker_Start(&broker)); + + reset_mock_clients(2); + mock_client_input_append(0, sub_connect, sizeof(sub_connect)); + mock_client_input_append(0, sub_subscribe, sizeof(sub_subscribe)); + mock_client_input_append(1, pub_connect, sizeof(pub_connect)); + mock_client_input_append(1, disconnect_bad, sizeof(disconnect_bad)); + for (i = 0; i < 16; i++) { + MqttBroker_Step(&broker); + } + + /* Subscriber must receive a PUBLISH (the will message). The bug case + * dispatches the malformed DISCONNECT through the normal-close path + * which clears the will, so no PUBLISH would reach the subscriber. */ + ASSERT_EQ(1, count_packets_of_type(g_clients[0].out_buf, + g_clients[0].out_len, MQTT_PACKET_TYPE_PUBLISH)); + /* Publisher's connection was closed regardless of which path the + * broker took, so g_client_closed alone wouldn't catch the bug. */ + ASSERT_TRUE(g_clients[1].closed); + + MqttBroker_Stop(&broker); + MqttBroker_Free(&broker); +} +#endif /* !WOLFMQTT_V5 */ + /* -------------------------------------------------------------------------- */ /* Runner */ /* -------------------------------------------------------------------------- */ @@ -1036,6 +1180,11 @@ int main(int argc, char** argv) RUN_TEST(qos2_inbound_cap_reached_disconnects); RUN_TEST(qos2_state_freed_on_client_disconnect); RUN_TEST(qos2_pubrel_unknown_id_still_pubcomps); + RUN_TEST(pingreq_valid_emits_pingresp); + RUN_TEST(pingreq_nonzero_remain_len_closes_no_pingresp); +#ifndef WOLFMQTT_V5 + RUN_TEST(disconnect_v311_nonzero_remain_len_fires_will); +#endif TEST_SUITE_END(); TEST_RUNNER_END(); diff --git a/tests/test_mqtt_packet.c b/tests/test_mqtt_packet.c index 83cc5f384..75600b78f 100644 --- a/tests/test_mqtt_packet.c +++ b/tests/test_mqtt_packet.c @@ -3028,6 +3028,89 @@ TEST(decode_unsuback_malformed_remain_len_one) ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); } +/* ============================================================================ + * MqttDecode_Ping (PINGRESP) and MqttDecode_Disconnect length validation + * + * MQTT 3.1.1 §3.13 / §3.14 and v5 §3.13: PINGRESP has no variable header + * and no payload. v3.1.1 §3.14: DISCONNECT also has none. The decoders + * must reject Remaining Length != 0; otherwise a peer can smuggle in + * trailing bytes that downstream code silently drops. + * ============================================================================ */ + +TEST(decode_pingresp_valid) +{ + byte buf[] = { 0xD0, 0x00 }; + MqttPing ping; + int rc; + + XMEMSET(&ping, 0, sizeof(ping)); + rc = MqttDecode_Ping(buf, (int)sizeof(buf), &ping); + ASSERT_EQ(2, rc); +} + +/* Issue #515 reproducer: PINGRESP with one trailing byte. Without the fix + * the decoder returned 3 (the packet length). */ +TEST(decode_pingresp_nonzero_remain_len_rejected) +{ + byte buf[] = { 0xD0, 0x01, 0x00 }; + MqttPing ping; + int rc; + + XMEMSET(&ping, 0, sizeof(ping)); + rc = MqttDecode_Ping(buf, (int)sizeof(buf), &ping); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); +} + +#if defined(WOLFMQTT_BROKER) && !defined(WOLFMQTT_V5) +TEST(decode_disconnect_v311_valid) +{ + byte buf[] = { 0xE0, 0x00 }; + MqttDisconnect disc; + int rc; + + XMEMSET(&disc, 0, sizeof(disc)); + rc = MqttDecode_Disconnect(buf, (int)sizeof(buf), &disc); + ASSERT_EQ(2, rc); +} + +/* Issue #515 reproducer: v3.1.1 DISCONNECT with one trailing byte. The + * v3.1.1 spec defines DISCONNECT as fixed-header-only; the WOLFMQTT_V5 + * decoder below legitimately accepts a Reason Code and Properties. */ +TEST(decode_disconnect_v311_nonzero_remain_len_rejected) +{ + byte buf[] = { 0xE0, 0x01, 0x00 }; + MqttDisconnect disc; + int rc; + + XMEMSET(&disc, 0, sizeof(disc)); + rc = MqttDecode_Disconnect(buf, (int)sizeof(buf), &disc); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); +} +#endif /* WOLFMQTT_BROKER && !WOLFMQTT_V5 */ + +#ifdef WOLFMQTT_V5 +/* v5 §3.14: DISCONNECT may carry an optional Reason Code (1 byte) and a + * Properties block. Pins the v5 decoder against a regression that would + * tighten the v3.1.1 remain_len rule onto v5 by mistake. Wire is + * remain_len = 2 = reason_code + props_len=0. */ +TEST(decode_disconnect_v5_with_reason_code_accepted) +{ + byte buf[] = { + 0xE0, 0x02, + 0x00, /* reason code = Normal Disc */ + 0x00 /* properties length = 0 */ + }; + MqttDisconnect disc; + int rc; + + XMEMSET(&disc, 0, sizeof(disc)); + rc = MqttDecode_Disconnect(buf, (int)sizeof(buf), &disc); + ASSERT_TRUE(rc > 0); + ASSERT_EQ(0, disc.reason_code); + MqttProps_Free(disc.props); +} +#endif /* WOLFMQTT_V5 */ + /* ============================================================================ * Fixed-header reserved-flag validation [MQTT-2.2.2-2] * @@ -3660,6 +3743,19 @@ void run_mqtt_packet_tests(void) RUN_TEST(decode_unsuback_malformed_remain_len_zero); RUN_TEST(decode_unsuback_malformed_remain_len_one); + /* MqttDecode_Ping (PINGRESP) length validation */ + RUN_TEST(decode_pingresp_valid); + RUN_TEST(decode_pingresp_nonzero_remain_len_rejected); + + /* MqttDecode_Disconnect length validation */ +#if defined(WOLFMQTT_BROKER) && !defined(WOLFMQTT_V5) + RUN_TEST(decode_disconnect_v311_valid); + RUN_TEST(decode_disconnect_v311_nonzero_remain_len_rejected); +#endif +#ifdef WOLFMQTT_V5 + RUN_TEST(decode_disconnect_v5_with_reason_code_accepted); +#endif + /* Fixed-header reserved-flag validation [MQTT-2.2.2-2] */ RUN_TEST(fixed_header_flags_valid_canonical_values); RUN_TEST(fixed_header_flags_valid_zero_required_rejects_nonzero); From 39a0ae0f67263ec1774be42eb1c9b4b7f774ed41 Mon Sep 17 00:00:00 2001 From: Eric Blankenhorn Date: Tue, 5 May 2026 07:41:40 -0500 Subject: [PATCH 18/32] Fix PUBACK, PUBREC, PUBREL, and PUBCOMP Accept Extra Payload in MQTT 3.x --- src/mqtt_packet.c | 24 ++++++- tests/test_mqtt_packet.c | 146 +++++++++++++++++++++++++++++++++++++++ wolfmqtt/mqtt_packet.h | 12 ++++ 3 files changed, 179 insertions(+), 3 deletions(-) diff --git a/src/mqtt_packet.c b/src/mqtt_packet.c index dfd37050f..08755473c 100644 --- a/src/mqtt_packet.c +++ b/src/mqtt_packet.c @@ -1903,9 +1903,27 @@ int MqttDecode_PublishResp(byte* rx_buf, int rx_buf_len, byte type, return header_len; } - /* Validate remain_len (need at least packet_id) */ - if (remain_len < MQTT_DATA_LEN_SIZE) { - return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_MALFORMED_DATA); + /* MQTT 3.1.1 §3.4-3.7: PUBACK/PUBREC/PUBREL/PUBCOMP variable header is + * exactly the two-byte Packet Identifier and there is no payload — + * Remaining Length is fixed at 2. v5 §3.4-3.7 relaxes this to allow + * an optional Reason Code and Properties block, so the longer form is + * only valid when the caller has identified the connection as v5. + * (publish_resp == NULL takes the strict path: with no struct to + * carry reason_code/props, anything beyond the Packet Identifier + * cannot be consumed and is therefore extra payload.) */ +#ifdef WOLFMQTT_V5 + if (publish_resp != NULL && + publish_resp->protocol_level >= MQTT_CONNECT_PROTOCOL_LEVEL_5) { + if (remain_len < MQTT_DATA_LEN_SIZE) { + return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_MALFORMED_DATA); + } + } + else +#endif + { + if (remain_len != MQTT_DATA_LEN_SIZE) { + return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_MALFORMED_DATA); + } } rx_payload = &rx_buf[header_len]; diff --git a/tests/test_mqtt_packet.c b/tests/test_mqtt_packet.c index 75600b78f..2b77dac7d 100644 --- a/tests/test_mqtt_packet.c +++ b/tests/test_mqtt_packet.c @@ -2901,6 +2901,141 @@ TEST(decode_publish_resp_malformed_remain_len_one) ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); } +/* MQTT 3.1.1 §3.4-3.7: PUBACK/PUBREC/PUBREL/PUBCOMP have a fixed + * Remaining Length of 2 (the Packet Identifier only). Any extra byte + * after the Packet Identifier is malformed in v3.x. v5 §3.4-3.7 + * relaxes this with an optional Reason Code and Properties; the + * `protocol_level` field on the response struct selects between the + * strict and relaxed decoders. Issues #516 and #517 — the wire reflects + * the issues' reproducer payloads (extra trailing zero byte). */ +TEST(decode_puback_v311_extra_payload_rejected) +{ + byte buf[] = { 0x40, 0x03, 0x00, 0x07, 0x00 }; + MqttPublishResp resp; + int rc; + + XMEMSET(&resp, 0, sizeof(resp)); + rc = MqttDecode_PublishResp(buf, (int)sizeof(buf), + MQTT_PACKET_TYPE_PUBLISH_ACK, &resp); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); +} + +TEST(decode_pubrec_v311_extra_payload_rejected) +{ + byte buf[] = { 0x50, 0x03, 0x00, 0x07, 0x00 }; + MqttPublishResp resp; + int rc; + + XMEMSET(&resp, 0, sizeof(resp)); + rc = MqttDecode_PublishResp(buf, (int)sizeof(buf), + MQTT_PACKET_TYPE_PUBLISH_REC, &resp); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); +} + +TEST(decode_pubrel_v311_extra_payload_rejected) +{ + byte buf[] = { 0x62, 0x03, 0x00, 0x07, 0x00 }; + MqttPublishResp resp; + int rc; + + XMEMSET(&resp, 0, sizeof(resp)); + rc = MqttDecode_PublishResp(buf, (int)sizeof(buf), + MQTT_PACKET_TYPE_PUBLISH_REL, &resp); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); +} + +TEST(decode_pubcomp_v311_extra_payload_rejected) +{ + byte buf[] = { 0x70, 0x03, 0x00, 0x07, 0x00 }; + MqttPublishResp resp; + int rc; + + XMEMSET(&resp, 0, sizeof(resp)); + rc = MqttDecode_PublishResp(buf, (int)sizeof(buf), + MQTT_PACKET_TYPE_PUBLISH_COMP, &resp); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); +} + +/* Positive cases for PUBREC/PUBREL/PUBCOMP — decode_publish_resp_valid + * already covers PUBACK. Without these, a regression that flips the + * length check into "always reject" would still leave 3/4 packet types + * silently broken with only PUBACK signalling failure. */ +TEST(decode_pubrec_v311_valid) +{ + byte buf[] = { 0x50, 0x02, 0x00, 0x07 }; + MqttPublishResp resp; + int rc; + + XMEMSET(&resp, 0, sizeof(resp)); + rc = MqttDecode_PublishResp(buf, (int)sizeof(buf), + MQTT_PACKET_TYPE_PUBLISH_REC, &resp); + ASSERT_TRUE(rc > 0); + ASSERT_EQ(7, resp.packet_id); +} + +TEST(decode_pubrel_v311_valid) +{ + byte buf[] = { 0x62, 0x02, 0x00, 0x07 }; + MqttPublishResp resp; + int rc; + + XMEMSET(&resp, 0, sizeof(resp)); + rc = MqttDecode_PublishResp(buf, (int)sizeof(buf), + MQTT_PACKET_TYPE_PUBLISH_REL, &resp); + ASSERT_TRUE(rc > 0); + ASSERT_EQ(7, resp.packet_id); +} + +TEST(decode_pubcomp_v311_valid) +{ + byte buf[] = { 0x70, 0x02, 0x00, 0x07 }; + MqttPublishResp resp; + int rc; + + XMEMSET(&resp, 0, sizeof(resp)); + rc = MqttDecode_PublishResp(buf, (int)sizeof(buf), + MQTT_PACKET_TYPE_PUBLISH_COMP, &resp); + ASSERT_TRUE(rc > 0); + ASSERT_EQ(7, resp.packet_id); +} + +/* publish_resp == NULL takes the strict-length path even under + * WOLFMQTT_V5: with no struct to consume reason_code/props, anything + * beyond the Packet Identifier is unreachable extra payload. Pins the + * `publish_resp == NULL` arm of the gate so a refactor that narrows the + * predicate to `protocol_level < 5` cannot regress NULL callers. */ +TEST(decode_puback_null_resp_extra_payload_rejected) +{ + byte buf[] = { 0x40, 0x03, 0x00, 0x07, 0x00 }; + int rc; + + rc = MqttDecode_PublishResp(buf, (int)sizeof(buf), + MQTT_PACKET_TYPE_PUBLISH_ACK, NULL); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); +} + +#ifdef WOLFMQTT_V5 +/* v5 §3.4-3.7 explicitly allow longer PUBACK/PUBREC/PUBREL/PUBCOMP with + * a Reason Code (1 byte) and a Properties block. Pins the v5 gate so the + * v3.x exact-length check doesn't regress onto v5 — the wire is + * remain_len = 4 = packet_id + reason_code + props_len(0). */ +TEST(decode_puback_v5_with_reason_code_accepted) +{ + byte buf[] = { 0x40, 0x04, 0x00, 0x07, 0x00, 0x00 }; + MqttPublishResp resp; + int rc; + + XMEMSET(&resp, 0, sizeof(resp)); + resp.protocol_level = MQTT_CONNECT_PROTOCOL_LEVEL_5; + rc = MqttDecode_PublishResp(buf, (int)sizeof(buf), + MQTT_PACKET_TYPE_PUBLISH_ACK, &resp); + ASSERT_TRUE(rc > 0); + ASSERT_EQ(7, resp.packet_id); + ASSERT_EQ(0, resp.reason_code); + MqttProps_Free(resp.props); +} +#endif /* WOLFMQTT_V5 */ + /* ============================================================================ * MqttEncode_PublishResp fixed-header QoS bits * @@ -3731,6 +3866,17 @@ void run_mqtt_packet_tests(void) RUN_TEST(decode_publish_resp_valid); RUN_TEST(decode_publish_resp_malformed_remain_len_zero); RUN_TEST(decode_publish_resp_malformed_remain_len_one); + RUN_TEST(decode_puback_v311_extra_payload_rejected); + RUN_TEST(decode_pubrec_v311_extra_payload_rejected); + RUN_TEST(decode_pubrel_v311_extra_payload_rejected); + RUN_TEST(decode_pubcomp_v311_extra_payload_rejected); + RUN_TEST(decode_pubrec_v311_valid); + RUN_TEST(decode_pubrel_v311_valid); + RUN_TEST(decode_pubcomp_v311_valid); + RUN_TEST(decode_puback_null_resp_extra_payload_rejected); +#ifdef WOLFMQTT_V5 + RUN_TEST(decode_puback_v5_with_reason_code_accepted); +#endif /* MqttEncode_PublishResp fixed-header QoS bits */ RUN_TEST(encode_publish_rel_has_qos1_flag); diff --git a/wolfmqtt/mqtt_packet.h b/wolfmqtt/mqtt_packet.h index e464651a6..b4cc40beb 100644 --- a/wolfmqtt/mqtt_packet.h +++ b/wolfmqtt/mqtt_packet.h @@ -690,6 +690,18 @@ WOLFMQTT_API int MqttDecode_Publish(byte *rx_buf, int rx_buf_len, MqttPublish *publish); WOLFMQTT_API int MqttEncode_PublishResp(byte* tx_buf, int tx_buf_len, byte type, MqttPublishResp *publish_resp); +/*! \brief Decode a PUBACK / PUBREC / PUBREL / PUBCOMP packet. + * + * \note Per MQTT 3.1.1 §3.4-§3.7 the variable header is exactly the + * two-byte Packet Identifier with no payload; Remaining Length must be + * 2. The decoder rejects any extra trailing bytes with + * MQTT_CODE_ERROR_MALFORMED_DATA. MQTT v5 §3.4-§3.7 allows an optional + * Reason Code and Properties block — the longer form is accepted only + * when publish_resp is non-NULL and publish_resp->protocol_level is + * MQTT_CONNECT_PROTOCOL_LEVEL_5 or higher. Callers integrating against + * non-spec brokers that emit extra bytes for v3.x acks must either fix + * the peer or set protocol_level to 5 before calling. + */ WOLFMQTT_API int MqttDecode_PublishResp(byte* rx_buf, int rx_buf_len, byte type, MqttPublishResp *publish_resp); WOLFMQTT_API int MqttEncode_Subscribe(byte *tx_buf, int tx_buf_len, From 58206a00196ab2123c505e9a5045ca1c5ae1c735 Mon Sep 17 00:00:00 2001 From: Eric Blankenhorn Date: Tue, 5 May 2026 08:01:21 -0500 Subject: [PATCH 19/32] Fix SUBSCRIBE Requested QoS Options Are Accepted Without Strict Validation --- src/mqtt_packet.c | 25 +++++++ tests/test_mqtt_packet.c | 145 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 159 insertions(+), 11 deletions(-) diff --git a/src/mqtt_packet.c b/src/mqtt_packet.c index 08755473c..a88021bb7 100644 --- a/src/mqtt_packet.c +++ b/src/mqtt_packet.c @@ -2190,6 +2190,31 @@ int MqttDecode_Subscribe(byte *rx_buf, int rx_buf_len, MqttSubscribe *subscribe) return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_OUT_OF_BUFFER); } options = *rx_payload++; + /* MQTT 3.1.1 §3.8.3.1: bits 2-7 of the SUBSCRIBE options byte + * are reserved and MUST be 0; Requested QoS (bits 0-1) MUST + * be 0, 1, or 2. v5 §3.8.3.1 redefines bits 2-5 as No Local, + * Retain As Published, and Retain Handling — bits 6-7 stay + * reserved, Retain Handling = 3 is also reserved, and QoS = 3 + * remains invalid. The fixed-header [MQTT-3.8.1-1] reserved- + * flag check has already run by this point; this check covers + * the per-topic options byte the broker would otherwise be + * forced to silently normalize. */ + #ifdef WOLFMQTT_V5 + if (subscribe->protocol_level >= MQTT_CONNECT_PROTOCOL_LEVEL_5) { + if ((options & 0xC0) != 0 || + (options & 0x03) > MQTT_QOS_2 || + ((options >> 4) & 0x03) == 0x03) { + return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_MALFORMED_DATA); + } + } + else + #endif + { + if ((options & 0xFC) != 0 || + (options & 0x03) > MQTT_QOS_2) { + return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_MALFORMED_DATA); + } + } topic->qos = (MqttQoS)(options & 0x03); subscribe->topic_count++; } diff --git a/tests/test_mqtt_packet.c b/tests/test_mqtt_packet.c index 2b77dac7d..f7412bf08 100644 --- a/tests/test_mqtt_packet.c +++ b/tests/test_mqtt_packet.c @@ -2523,13 +2523,11 @@ TEST(decode_subscribe_v311_single_topic) ASSERT_EQ(0, XMEMCMP(topic_arr[0].topic_filter, "a", 1)); } -/* Options byte QoS bits (0-1) = 0b11 is reserved. The decoder forwards the - * raw value verbatim (options & 0x03 == 3), so the broker's - * BrokerHandle_Subscribe cap at MQTT_QOS_2 is the only thing preventing - * QoS=3 from propagating into BrokerSubs_Add / the SUBACK return code. - * This test pins the precondition: if the decoder ever starts rejecting - * QoS=3, the broker cap becomes dead code and this test will flag it. */ -TEST(decode_subscribe_v311_qos3_reserved) +/* MQTT 3.1.1 §3.8.3.1: Requested QoS bits (0-1) = 0b11 is reserved and + * MUST be rejected (issue #518). Pre-fix the decoder forwarded the raw + * value and relied on the broker's defensive QoS cap; the broker cap is + * now dead code on the decoded path but kept for safety. */ +TEST(decode_subscribe_v311_qos3_rejected) { byte rx_buf[] = { 0x82, 0x06, @@ -2546,9 +2544,53 @@ TEST(decode_subscribe_v311_qos3_reserved) XMEMSET(topic_arr, 0, sizeof(topic_arr)); sub.topics = topic_arr; rc = MqttDecode_Subscribe(rx_buf, (int)sizeof(rx_buf), &sub); - ASSERT_TRUE(rc > 0); - ASSERT_EQ(1, sub.topic_count); - ASSERT_EQ(MQTT_QOS_3, topic_arr[0].qos); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); +} + +/* MQTT 3.1.1 §3.8.3.1: bits 2-7 of the options byte are reserved and + * MUST be 0. Wire matches issue #518's reproducer: high six bits set + * (0xFC) with low bits = QoS 0. The unmasked v3.x decoder used to drop + * the reserved bits and accept QoS 0 silently. */ +TEST(decode_subscribe_v311_options_reserved_bits_qos0_rejected) +{ + byte rx_buf[] = { + 0x82, 0x06, + 0x00, 0x01, + 0x00, 0x01, + 0x61, + 0xFC + }; + MqttSubscribe sub; + MqttTopic topic_arr[1]; + int rc; + + XMEMSET(&sub, 0, sizeof(sub)); + XMEMSET(topic_arr, 0, sizeof(topic_arr)); + sub.topics = topic_arr; + rc = MqttDecode_Subscribe(rx_buf, (int)sizeof(rx_buf), &sub); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); +} + +/* Same as above, but with low bits = QoS 1. Pairs with the QoS-0 case to + * pin the reserved-bit check independently of the QoS check. */ +TEST(decode_subscribe_v311_options_reserved_bits_qos1_rejected) +{ + byte rx_buf[] = { + 0x82, 0x06, + 0x00, 0x01, + 0x00, 0x01, + 0x61, + 0xFD + }; + MqttSubscribe sub; + MqttTopic topic_arr[1]; + int rc; + + XMEMSET(&sub, 0, sizeof(sub)); + XMEMSET(topic_arr, 0, sizeof(topic_arr)); + sub.topics = topic_arr; + rc = MqttDecode_Subscribe(rx_buf, (int)sizeof(rx_buf), &sub); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); } /* [MQTT-1.5.3-2] / [MQTT-4.7.3-2]: a topic filter containing U+0000 must @@ -2676,6 +2718,82 @@ TEST(decode_subscribe_v5_options_byte_qos_extracted) ASSERT_EQ(1, sub.topic_count); ASSERT_EQ(MQTT_QOS_1, topic_arr[0].qos); } + +/* MQTT v5 §3.8.3.1: Requested QoS = 3 is reserved. The v3.1.1 test + * above takes a different branch (protocol_level = 0), so without this + * test the v5 `(options & 0x03) > MQTT_QOS_2` clause is only covered + * transitively through the broader options-byte check. A refactor that + * dropped the v5 QoS check on the assumption that the reserved-bits or + * Retain-Handling checks subsume it would slip past CI silently. */ +TEST(decode_subscribe_v5_qos3_rejected) +{ + byte rx_buf[] = { + 0x82, 0x07, + 0x00, 0x01, + 0x00, + 0x00, 0x01, + 0x61, + 0x03 /* QoS = 3, other bits clear */ + }; + MqttSubscribe sub; + MqttTopic topic_arr[1]; + int rc; + + XMEMSET(&sub, 0, sizeof(sub)); + XMEMSET(topic_arr, 0, sizeof(topic_arr)); + sub.topics = topic_arr; + sub.protocol_level = MQTT_CONNECT_PROTOCOL_LEVEL_5; + rc = MqttDecode_Subscribe(rx_buf, (int)sizeof(rx_buf), &sub); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); +} + +/* MQTT v5 §3.8.3.1: Retain Handling = 3 is reserved and MUST be + * rejected. Bits 4-5 = 0b11 sets that condition. */ +TEST(decode_subscribe_v5_retain_handling_3_rejected) +{ + byte rx_buf[] = { + 0x82, 0x07, + 0x00, 0x01, + 0x00, + 0x00, 0x01, + 0x61, + 0x30 /* Retain Handling = 0b11 */ + }; + MqttSubscribe sub; + MqttTopic topic_arr[1]; + int rc; + + XMEMSET(&sub, 0, sizeof(sub)); + XMEMSET(topic_arr, 0, sizeof(topic_arr)); + sub.topics = topic_arr; + sub.protocol_level = MQTT_CONNECT_PROTOCOL_LEVEL_5; + rc = MqttDecode_Subscribe(rx_buf, (int)sizeof(rx_buf), &sub); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); +} + +/* MQTT v5 §3.8.3.1: bits 6-7 of the options byte are reserved and MUST + * be 0. Bits 0-5 are otherwise valid (QoS 0, RH=0, RAP=0, NL=0). */ +TEST(decode_subscribe_v5_options_reserved_bits_rejected) +{ + byte rx_buf[] = { + 0x82, 0x07, + 0x00, 0x01, + 0x00, + 0x00, 0x01, + 0x61, + 0xC0 /* reserved bits 6-7 set */ + }; + MqttSubscribe sub; + MqttTopic topic_arr[1]; + int rc; + + XMEMSET(&sub, 0, sizeof(sub)); + XMEMSET(topic_arr, 0, sizeof(topic_arr)); + sub.topics = topic_arr; + sub.protocol_level = MQTT_CONNECT_PROTOCOL_LEVEL_5; + rc = MqttDecode_Subscribe(rx_buf, (int)sizeof(rx_buf), &sub); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); +} #endif /* WOLFMQTT_V5 */ /* ============================================================================ @@ -3833,7 +3951,9 @@ void run_mqtt_packet_tests(void) /* MqttDecode_Subscribe */ RUN_TEST(decode_subscribe_v311_single_topic); - RUN_TEST(decode_subscribe_v311_qos3_reserved); + RUN_TEST(decode_subscribe_v311_qos3_rejected); + RUN_TEST(decode_subscribe_v311_options_reserved_bits_qos0_rejected); + RUN_TEST(decode_subscribe_v311_options_reserved_bits_qos1_rejected); RUN_TEST(decode_subscribe_rejects_nul_in_filter); RUN_TEST(decode_subscribe_packet_id_zero_rejected); RUN_TEST(decode_subscribe_empty_payload_rejected); @@ -3842,6 +3962,9 @@ void run_mqtt_packet_tests(void) #endif #ifdef WOLFMQTT_V5 RUN_TEST(decode_subscribe_v5_options_byte_qos_extracted); + RUN_TEST(decode_subscribe_v5_qos3_rejected); + RUN_TEST(decode_subscribe_v5_retain_handling_3_rejected); + RUN_TEST(decode_subscribe_v5_options_reserved_bits_rejected); #endif /* MqttDecode_Unsubscribe */ From 546ddc9fc112f3db72362c161c29217c059d2116 Mon Sep 17 00:00:00 2001 From: Eric Blankenhorn Date: Tue, 5 May 2026 08:13:36 -0500 Subject: [PATCH 20/32] Test DISCONNECT Fixed Header Reserved Bits Are Not Validated --- tests/test_broker_connect.c | 62 +++++++++++++++++++++++++++++++++++++ tests/test_mqtt_packet.c | 32 +++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/tests/test_broker_connect.c b/tests/test_broker_connect.c index c1a2b8926..dce730238 100644 --- a/tests/test_broker_connect.c +++ b/tests/test_broker_connect.c @@ -1152,6 +1152,67 @@ TEST(disconnect_v311_nonzero_remain_len_fires_will) } #endif /* !WOLFMQTT_V5 */ +/* MQTT 3.1.1 §3.14.1 / [MQTT-2.2.2-2]: DISCONNECT fixed-header low + * nibble MUST be 0000. The broker dispatch enforces this via the + * MqttPacket_FixedHeaderFlagsValid pre-check that runs before per-type + * handlers, so a malformed DISCONNECT (e.g. 0xE1) takes the abnormal- + * close path. Same LWT observable as the nonzero-remain-len test: + * abnormal close fires the will, normal close clears it. Issue #519. */ +TEST(disconnect_invalid_fixed_header_flags_fires_will) +{ + MqttBroker broker; + MqttBrokerNet net; + int i; + static const byte sub_connect[] = { + 0x10, 0x0D, + 0x00, 0x04, 'M', 'Q', 'T', 'T', + 0x04, 0x02, 0x00, 0x3C, + 0x00, 0x01, 'S' + }; + static const byte sub_subscribe[] = { + 0x82, 0x08, + 0x00, 0x01, + 0x00, 0x03, 'l', 'w', 't', + 0x00 + }; + static const byte pub_connect[] = { + 0x10, 0x17, + 0x00, 0x04, 'M', 'Q', 'T', 'T', + 0x04, 0x06, 0x00, 0x3C, + 0x00, 0x01, 'P', + 0x00, 0x03, 'l', 'w', 't', + 0x00, 0x03, 'b', 'y', 'e' + }; + /* Issue #519 reproducer wire: 0xE1 — DISCONNECT type with reserved + * bit 0 set. */ + static const byte disconnect_bad[] = { 0xE1, 0x00 }; + + install_mock_net(&net); + XMEMSET(&broker, 0, sizeof(broker)); + ASSERT_EQ(MQTT_CODE_SUCCESS, MqttBroker_Init(&broker, &net)); + ASSERT_EQ(MQTT_CODE_SUCCESS, MqttBroker_Start(&broker)); + + reset_mock_clients(2); + mock_client_input_append(0, sub_connect, sizeof(sub_connect)); + mock_client_input_append(0, sub_subscribe, sizeof(sub_subscribe)); + mock_client_input_append(1, pub_connect, sizeof(pub_connect)); + mock_client_input_append(1, disconnect_bad, sizeof(disconnect_bad)); + for (i = 0; i < 16; i++) { + MqttBroker_Step(&broker); + } + + /* Subscriber receives the will because the broker took the abnormal- + * close path. The malformed-bug case would have routed through the + * normal DISCONNECT branch, clearing the will and producing 0 + * PUBLISH packets to the subscriber. */ + ASSERT_EQ(1, count_packets_of_type(g_clients[0].out_buf, + g_clients[0].out_len, MQTT_PACKET_TYPE_PUBLISH)); + ASSERT_TRUE(g_clients[1].closed); + + MqttBroker_Stop(&broker); + MqttBroker_Free(&broker); +} + /* -------------------------------------------------------------------------- */ /* Runner */ /* -------------------------------------------------------------------------- */ @@ -1185,6 +1246,7 @@ int main(int argc, char** argv) #ifndef WOLFMQTT_V5 RUN_TEST(disconnect_v311_nonzero_remain_len_fires_will); #endif + RUN_TEST(disconnect_invalid_fixed_header_flags_fires_will); TEST_SUITE_END(); TEST_RUNNER_END(); diff --git a/tests/test_mqtt_packet.c b/tests/test_mqtt_packet.c index f7412bf08..0dbe902df 100644 --- a/tests/test_mqtt_packet.c +++ b/tests/test_mqtt_packet.c @@ -3339,9 +3339,39 @@ TEST(decode_disconnect_v311_nonzero_remain_len_rejected) rc = MqttDecode_Disconnect(buf, (int)sizeof(buf), &disc); ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); } + +/* MQTT 3.1.1 §3.14.1 / [MQTT-2.2.2-2]: DISCONNECT fixed-header low + * nibble MUST be 0000. Issue #519 — wire 0xE1 is the issue's + * reproducer. The check fires inside MqttDecode_FixedHeader via + * MqttPacket_FixedHeaderFlagsValid; this test pins the per-decoder + * surface so a future caller that builds its own header path can't + * silently accept a malformed disconnect. */ +TEST(decode_disconnect_v311_invalid_fixed_header_flags_rejected) +{ + byte buf[] = { 0xE1, 0x00 }; + MqttDisconnect disc; + int rc; + + XMEMSET(&disc, 0, sizeof(disc)); + rc = MqttDecode_Disconnect(buf, (int)sizeof(buf), &disc); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); +} #endif /* WOLFMQTT_BROKER && !WOLFMQTT_V5 */ #ifdef WOLFMQTT_V5 +/* v5 §3.14 keeps the same fixed-header reserved-flag rule. Pins the v5 + * decoder against the same regression on its independent code path. */ +TEST(decode_disconnect_v5_invalid_fixed_header_flags_rejected) +{ + byte buf[] = { 0xE1, 0x00 }; + MqttDisconnect disc; + int rc; + + XMEMSET(&disc, 0, sizeof(disc)); + rc = MqttDecode_Disconnect(buf, (int)sizeof(buf), &disc); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); +} + /* v5 §3.14: DISCONNECT may carry an optional Reason Code (1 byte) and a * Properties block. Pins the v5 decoder against a regression that would * tighten the v3.1.1 remain_len rule onto v5 by mistake. Wire is @@ -4020,8 +4050,10 @@ void run_mqtt_packet_tests(void) #if defined(WOLFMQTT_BROKER) && !defined(WOLFMQTT_V5) RUN_TEST(decode_disconnect_v311_valid); RUN_TEST(decode_disconnect_v311_nonzero_remain_len_rejected); + RUN_TEST(decode_disconnect_v311_invalid_fixed_header_flags_rejected); #endif #ifdef WOLFMQTT_V5 + RUN_TEST(decode_disconnect_v5_invalid_fixed_header_flags_rejected); RUN_TEST(decode_disconnect_v5_with_reason_code_accepted); #endif From 220f5d2d8535cf14e2b9fbf7c265806350d33eb4 Mon Sep 17 00:00:00 2001 From: Eric Blankenhorn Date: Tue, 5 May 2026 08:39:05 -0500 Subject: [PATCH 21/32] Fix Retained Message QoS Is Not Stored --- src/mqtt_broker.c | 35 ++++-- tests/test_broker_connect.c | 236 ++++++++++++++++++++++++++++++++++++ wolfmqtt/mqtt_broker.h | 1 + 3 files changed, 259 insertions(+), 13 deletions(-) diff --git a/src/mqtt_broker.c b/src/mqtt_broker.c index 43700dafd..565039bee 100644 --- a/src/mqtt_broker.c +++ b/src/mqtt_broker.c @@ -129,7 +129,7 @@ static void MqttBroker_ForceZero(void* mem, word32 len) /* No-op stubs when features are compiled out */ #ifndef WOLFMQTT_BROKER_RETAINED - #define BrokerRetained_Store(b, t, p, l, e) (0) + #define BrokerRetained_Store(b, t, p, l, q, e) (0) #define BrokerRetained_Delete(b, t) do {} while(0) #define BrokerRetained_FreeAll(b) do {} while(0) #define BrokerRetained_DeliverToClient(b, c, f, q) do {} while(0) @@ -1975,7 +1975,7 @@ static void BrokerSubs_ReassociateClient(MqttBroker* broker, /* -------------------------------------------------------------------------- */ #ifdef WOLFMQTT_BROKER_RETAINED static int BrokerRetained_Store(MqttBroker* broker, const char* topic, - const byte* payload, word32 payload_len, word32 expiry_sec) + const byte* payload, word32 payload_len, MqttQoS qos, word32 expiry_sec) { BrokerRetainedMsg* msg = NULL; int rc = MQTT_CODE_SUCCESS; @@ -2096,8 +2096,10 @@ static int BrokerRetained_Store(MqttBroker* broker, const char* topic, if (rc == MQTT_CODE_SUCCESS) { msg->store_time = WOLFMQTT_BROKER_GET_TIME_S(); msg->expiry_sec = expiry_sec; - WBLOG_DBG(broker, "broker: retained store topic=%s len=%u expiry=%u", topic, - (unsigned)payload_len, (unsigned)expiry_sec); + msg->qos = qos; + WBLOG_DBG(broker, "broker: retained store topic=%s len=%u qos=%d " + "expiry=%u", + topic, (unsigned)payload_len, (int)qos, (unsigned)expiry_sec); } return rc; } @@ -2489,7 +2491,6 @@ static void BrokerRetained_DeliverToClient(MqttBroker* broker, BrokerClient* bc, const char* filter, MqttQoS sub_qos) { WOLFMQTT_BROKER_TIME_T now; - (void)sub_qos; /* retained always delivered at QoS 0 in this broker */ if (broker == NULL || bc == NULL || filter == NULL) { return; @@ -2513,14 +2514,18 @@ static void BrokerRetained_DeliverToClient(MqttBroker* broker, } if (BrokerTopicMatch(filter, rm->topic)) { MqttPublish out_pub; + MqttQoS eff_qos = (rm->qos < sub_qos) ? rm->qos : sub_qos; int enc_rc; XMEMSET(&out_pub, 0, sizeof(out_pub)); out_pub.topic_name = rm->topic; - out_pub.qos = MQTT_QOS_0; + out_pub.qos = eff_qos; out_pub.retain = 1; out_pub.duplicate = 0; out_pub.buffer = (rm->payload_len > 0) ? rm->payload : NULL; out_pub.total_len = rm->payload_len; + if (eff_qos >= MQTT_QOS_1) { + out_pub.packet_id = BrokerNextPacketId(broker); + } #ifdef WOLFMQTT_V5 out_pub.protocol_level = bc->protocol_level; #endif @@ -2528,8 +2533,8 @@ static void BrokerRetained_DeliverToClient(MqttBroker* broker, BROKER_CLIENT_TX_SZ(bc), &out_pub, 0); if (enc_rc > 0) { WBLOG_DBG(broker, "broker: retained deliver sock=%d topic=%s " - "len=%u", (int)bc->sock, rm->topic, - (unsigned)rm->payload_len); + "len=%u qos=%d", (int)bc->sock, rm->topic, + (unsigned)rm->payload_len, (int)eff_qos); (void)MqttPacket_Write(&bc->client, bc->tx_buf, enc_rc); } } @@ -2559,14 +2564,18 @@ static void BrokerRetained_DeliverToClient(MqttBroker* broker, } if (rm->topic != NULL && BrokerTopicMatch(filter, rm->topic)) { MqttPublish out_pub; + MqttQoS eff_qos = (rm->qos < sub_qos) ? rm->qos : sub_qos; int enc_rc; XMEMSET(&out_pub, 0, sizeof(out_pub)); out_pub.topic_name = rm->topic; - out_pub.qos = MQTT_QOS_0; + out_pub.qos = eff_qos; out_pub.retain = 1; out_pub.duplicate = 0; out_pub.buffer = (rm->payload_len > 0) ? rm->payload : NULL; out_pub.total_len = rm->payload_len; + if (eff_qos >= MQTT_QOS_1) { + out_pub.packet_id = BrokerNextPacketId(broker); + } #ifdef WOLFMQTT_V5 out_pub.protocol_level = bc->protocol_level; #endif @@ -2574,8 +2583,8 @@ static void BrokerRetained_DeliverToClient(MqttBroker* broker, BROKER_CLIENT_TX_SZ(bc), &out_pub, 0); if (enc_rc > 0) { WBLOG_DBG(broker, "broker: retained deliver sock=%d topic=%s " - "len=%u", (int)bc->sock, rm->topic, - (unsigned)rm->payload_len); + "len=%u qos=%d", (int)bc->sock, rm->topic, + (unsigned)rm->payload_len, (int)eff_qos); (void)MqttPacket_Write(&bc->client, bc->tx_buf, enc_rc); } } @@ -2631,7 +2640,7 @@ static void BrokerClient_PublishWillImmediate(MqttBroker* broker, } else { int ret_rc = BrokerRetained_Store(broker, topic, payload, - payload_len, 0); + payload_len, qos, 0); if (ret_rc != MQTT_CODE_SUCCESS) { WBLOG_ERR(broker, "Retained store failed: %s", MqttClient_ReturnCodeToString(ret_rc)); @@ -3586,7 +3595,7 @@ static int BrokerHandle_Publish(BrokerClient* bc, int rx_len, #endif { int ret_rc = BrokerRetained_Store(broker, topic, payload, - pub.total_len, expiry); + pub.total_len, pub.qos, expiry); if (ret_rc != MQTT_CODE_SUCCESS) { WBLOG_ERR(broker, "Retained store failed: %s", MqttClient_ReturnCodeToString(ret_rc)); diff --git a/tests/test_broker_connect.c b/tests/test_broker_connect.c index dce730238..3e17efce8 100644 --- a/tests/test_broker_connect.c +++ b/tests/test_broker_connect.c @@ -1213,6 +1213,234 @@ TEST(disconnect_invalid_fixed_header_flags_fires_will) MqttBroker_Free(&broker); } +/* Parsed details of a single PUBLISH packet on the wire. found = 0 means + * no PUBLISH was present in the buffer; subsequent fields are zero. */ +typedef struct { + int found; + byte first_byte; /* type | DUP | QoS | RETAIN */ + size_t remain_len; /* fixed-header Remaining Length value */ + word16 packet_id; /* 0 if QoS 0 (no packet identifier on wire) */ +} PublishInfo; + +/* Find the first PUBLISH packet (high nibble = 0x3) in `buf` and return + * its first byte plus parsed remaining-length and packet identifier. + * Mirrors count_packets_of_type's VBI walk so any malformed input stops + * cleanly instead of overrunning. */ +static PublishInfo first_publish_info(const byte* buf, size_t len) +{ + PublishInfo info; + size_t pos = 0; + XMEMSET(&info, 0, sizeof(info)); + while (pos < len) { + byte type = (byte)((buf[pos] >> 4) & 0x0F); + size_t remain = 0; + size_t mult = 1; + size_t hdr_len = 1; + int vbi_complete = 0; + while (pos + hdr_len < len && hdr_len <= 5) { + byte b = buf[pos + hdr_len]; + remain += (size_t)(b & 0x7F) * mult; + hdr_len++; + if ((b & 0x80) == 0) { + vbi_complete = 1; + break; + } + mult *= 128; + } + if (!vbi_complete) { + break; + } + if (type == MQTT_PACKET_TYPE_PUBLISH) { + byte qos = (byte)((buf[pos] >> 1) & 0x03); + info.found = 1; + info.first_byte = buf[pos]; + info.remain_len = remain; + if (qos >= MQTT_QOS_1) { + /* Skip the 2-byte topic length + topic to reach the + * 2-byte packet identifier that follows for QoS >= 1. */ + size_t var_off = pos + hdr_len; + if (var_off + 2 <= len) { + word16 topic_len = + (word16)((buf[var_off] << 8) | buf[var_off + 1]); + size_t pid_off = var_off + 2 + topic_len; + if (pid_off + 2 <= len) { + info.packet_id = (word16)( + (buf[pid_off] << 8) | buf[pid_off + 1]); + } + } + } + return info; + } + if (remain > len - pos - hdr_len) { + break; + } + pos += hdr_len + remain; + } + return info; +} + +#ifdef WOLFMQTT_BROKER_RETAINED +/* [MQTT-3.3.1-5] The broker MUST store the Application Message and its + * QoS, so retained delivery can use min(stored QoS, subscriber QoS). + * Issue #520 — pre-fix the retained store had no qos field and + * BrokerRetained_DeliverToClient hard-coded outgoing QoS to 0, downgrading + * QoS≥1 retained payloads. + * + * Wire pattern: publisher CONNECT, then retained PUBLISH at the test's + * stored QoS; subscriber CONNECT (separate client), SUBSCRIBE at the + * test's requested QoS; assert the delivered PUBLISH carries the + * expected min(stored, sub) QoS. */ +static void retained_qos_case(byte stored_qos, byte sub_qos, + byte expected_qos) +{ + MqttBroker broker; + MqttBrokerNet net; + int i; + PublishInfo info; + /* Body shape: topic_len(2) + topic(3) + (packet_id(2) when QoS>=1) + * + payload(1). 5 base bytes plus 2 for packet_id when applicable. */ + size_t expected_remain = (expected_qos >= MQTT_QOS_1) ? 8 : 6; + /* Publisher CONNECT (ClientId "P"). */ + static const byte connect_pub[] = { + 0x10, 0x0D, + 0x00, 0x04, 'M', 'Q', 'T', 'T', + 0x04, 0x02, 0x00, 0x3C, + 0x00, 0x01, 'P' + }; + /* Subscriber CONNECT (ClientId "S"). */ + static const byte connect_sub[] = { + 0x10, 0x0D, + 0x00, 0x04, 'M', 'Q', 'T', 'T', + 0x04, 0x02, 0x00, 0x3C, + 0x00, 0x01, 'S' + }; + /* Retained PUBLISH "r/q" / "x". remain = 2+3+2(packet_id)+1 = 8. + * Wire: byte[0] = 0x30 | retain(0x01) | (stored_qos << 1). */ + byte publish[12]; + /* SUBSCRIBE filter "r/q", QoS = sub_qos. remain = 2+2+3+1 = 8. */ + byte subscribe[10]; + size_t publish_len; + size_t subscribe_len; + + install_mock_net(&net); + XMEMSET(&broker, 0, sizeof(broker)); + ASSERT_EQ(MQTT_CODE_SUCCESS, MqttBroker_Init(&broker, &net)); + ASSERT_EQ(MQTT_CODE_SUCCESS, MqttBroker_Start(&broker)); + + /* Build PUBLISH wire. For QoS 0 the spec omits the packet identifier + * (remain = 6: topic_len + topic + payload). For QoS≥1 the wire + * carries packet_id = 1 (remain = 8). */ + { + byte first = (byte)(0x30 | 0x01 | (byte)(stored_qos << 1)); + if (stored_qos == 0) { + /* QoS 0 PUBLISH: no packet_id. Body = topic_len + topic + + * payload = 2 + 3 + 1 = 6 bytes. */ + const byte tmpl[] = { + 0x00, 0x03, 'r', '/', 'q', + 'x' + }; + publish[0] = first; + publish[1] = (byte)sizeof(tmpl); + XMEMCPY(publish + 2, tmpl, sizeof(tmpl)); + publish_len = 2 + sizeof(tmpl); + } + else { + const byte tmpl[] = { + 0x00, 0x03, 'r', '/', 'q', + 0x00, 0x01, + 'x' + }; + publish[0] = first; + publish[1] = (byte)sizeof(tmpl); + XMEMCPY(publish + 2, tmpl, sizeof(tmpl)); + publish_len = 2 + sizeof(tmpl); + } + } + /* Build SUBSCRIBE wire. */ + { + const byte tmpl[] = { + 0x82, 0x08, + 0x00, 0x01, + 0x00, 0x03, 'r', '/', 'q', + 0x00 /* options placeholder */ + }; + XMEMCPY(subscribe, tmpl, sizeof(tmpl)); + subscribe[9] = sub_qos; + subscribe_len = sizeof(tmpl); + } + + reset_mock_clients(2); + /* Publisher first so the retained store is populated before the + * subscriber attaches. */ + mock_client_input_append(0, connect_pub, sizeof(connect_pub)); + mock_client_input_append(0, publish, publish_len); + mock_client_input_append(1, connect_sub, sizeof(connect_sub)); + mock_client_input_append(1, subscribe, subscribe_len); + for (i = 0; i < 16; i++) { + MqttBroker_Step(&broker); + } + + ASSERT_EQ(1, count_packets_of_type(g_clients[1].out_buf, + g_clients[1].out_len, MQTT_PACKET_TYPE_PUBLISH)); + info = first_publish_info(g_clients[1].out_buf, g_clients[1].out_len); + ASSERT_TRUE(info.found); + /* Expect PUBLISH | retain | (expected_qos << 1), DUP = 0. */ + ASSERT_EQ((int)(0x30 | 0x01 | (expected_qos << 1)), (int)info.first_byte); + /* Pin the wire size so a regression that emits a stale packet_id on a + * downgraded-to-QoS-0 retained delivery (or omits it on a QoS>=1 + * delivery) trips here, not silently. */ + ASSERT_EQ((int)expected_remain, (int)info.remain_len); + if (expected_qos >= MQTT_QOS_1) { + /* [MQTT-2.3.1-1]: QoS>=1 PUBLISH must carry a non-zero packet + * identifier. */ + ASSERT_TRUE(info.packet_id != 0); + } + else { + ASSERT_EQ(0, info.packet_id); + } + + MqttBroker_Stop(&broker); + MqttBroker_Free(&broker); +} + +TEST(retained_qos_stored_1_sub_1_delivers_qos1) +{ + retained_qos_case(MQTT_QOS_1, MQTT_QOS_1, MQTT_QOS_1); +} + +TEST(retained_qos_stored_2_sub_1_delivers_qos1) +{ + retained_qos_case(MQTT_QOS_2, MQTT_QOS_1, MQTT_QOS_1); +} + +TEST(retained_qos_stored_1_sub_0_delivers_qos0) +{ + retained_qos_case(MQTT_QOS_1, MQTT_QOS_0, MQTT_QOS_0); +} + +TEST(retained_qos_stored_0_sub_1_delivers_qos0) +{ + retained_qos_case(MQTT_QOS_0, MQTT_QOS_1, MQTT_QOS_0); +} + +/* Pins the QoS 2 outbound wire shape (first byte 0x35, packet_id + * present). Without this case the QoS 2 outbound branch of + * BrokerRetained_DeliverToClient never produces QoS 2 on the wire of any + * test — the stored=2/sub=1 case caps to QoS 1. */ +TEST(retained_qos_stored_2_sub_2_delivers_qos2) +{ + retained_qos_case(MQTT_QOS_2, MQTT_QOS_2, MQTT_QOS_2); +} + +/* Steepest downgrade: stored QoS 2, subscriber QoS 0 — verifies the + * retained delivery omits the packet identifier and emits the QoS-0 + * wire shape, not a stale identifier from the stored message. */ +TEST(retained_qos_stored_2_sub_0_delivers_qos0) +{ + retained_qos_case(MQTT_QOS_2, MQTT_QOS_0, MQTT_QOS_0); +} +#endif /* WOLFMQTT_BROKER_RETAINED */ + /* -------------------------------------------------------------------------- */ /* Runner */ /* -------------------------------------------------------------------------- */ @@ -1247,6 +1475,14 @@ int main(int argc, char** argv) RUN_TEST(disconnect_v311_nonzero_remain_len_fires_will); #endif RUN_TEST(disconnect_invalid_fixed_header_flags_fires_will); +#ifdef WOLFMQTT_BROKER_RETAINED + RUN_TEST(retained_qos_stored_1_sub_1_delivers_qos1); + RUN_TEST(retained_qos_stored_2_sub_1_delivers_qos1); + RUN_TEST(retained_qos_stored_1_sub_0_delivers_qos0); + RUN_TEST(retained_qos_stored_0_sub_1_delivers_qos0); + RUN_TEST(retained_qos_stored_2_sub_2_delivers_qos2); + RUN_TEST(retained_qos_stored_2_sub_0_delivers_qos0); +#endif TEST_SUITE_END(); TEST_RUNNER_END(); diff --git a/wolfmqtt/mqtt_broker.h b/wolfmqtt/mqtt_broker.h index 68c657456..14232537d 100644 --- a/wolfmqtt/mqtt_broker.h +++ b/wolfmqtt/mqtt_broker.h @@ -310,6 +310,7 @@ typedef struct BrokerRetainedMsg { word32 payload_len; WOLFMQTT_BROKER_TIME_T store_time; /* when stored (seconds) */ word32 expiry_sec; /* v5 message expiry (0=none) */ + MqttQoS qos; /* [MQTT-3.3.1-5] stored QoS */ } BrokerRetainedMsg; #endif /* WOLFMQTT_BROKER_RETAINED */ From 95643d3c2d4b06dbe5c7c6e932c09d8ad770950e Mon Sep 17 00:00:00 2001 From: Eric Blankenhorn Date: Tue, 5 May 2026 09:07:33 -0500 Subject: [PATCH 22/32] Fix SUBACK Return Code issues --- src/mqtt_broker.c | 28 +++++++- src/mqtt_packet.c | 62 +++++++++++++++++ tests/test_broker_connect.c | 94 ++++++++++++++++++++++--- tests/test_mqtt_packet.c | 133 ++++++++++++++++++++++++++++++------ wolfmqtt/mqtt_packet.h | 12 ++++ 5 files changed, 298 insertions(+), 31 deletions(-) diff --git a/src/mqtt_broker.c b/src/mqtt_broker.c index 565039bee..90ad50bc4 100644 --- a/src/mqtt_broker.c +++ b/src/mqtt_broker.c @@ -2776,7 +2776,14 @@ static int BrokerSend_PingResp(BrokerClient* bc) return MqttPacket_Write(&bc->client, bc->tx_buf, 2); } -static int BrokerSend_SubAck(BrokerClient* bc, word16 packet_id, +/* Not WOLFMQTT_API: kept internal but external linkage so the broker + * unit-test harness can call it directly to exercise the + * [MQTT-3.9.3-2] reserved-code rejection branch. The prior prototype + * silences -Wmissing-prototypes; the symbol is intentionally not in + * any public header. */ +int BrokerSend_SubAck(BrokerClient* bc, word16 packet_id, + const byte* return_codes, int return_code_count); +int BrokerSend_SubAck(BrokerClient* bc, word16 packet_id, const byte* return_codes, int return_code_count) { int remain_len; @@ -2787,6 +2794,25 @@ static int BrokerSend_SubAck(BrokerClient* bc, word16 packet_id, return MQTT_CODE_ERROR_BAD_ARG; } + /* [MQTT-3.9.3-2] Refuse to serialize a reserved SUBACK return code. + * The normal broker subscribe path produces only spec-allowed + * values, but this helper is the final boundary — a future caller + * passing a reserved value should fail loudly here rather than emit + * a malformed SUBACK on the wire. */ + { + int i_chk; + for (i_chk = 0; i_chk < return_code_count; i_chk++) { + if (!MqttPacket_SubAckReturnCodeValid(return_codes[i_chk], + bc->protocol_level)) { + WBLOG_ERR(bc->broker, + "broker: SUBACK reserved return code 0x%02X sock=%d " + "[MQTT-3.9.3-2]", + return_codes[i_chk], (int)bc->sock); + return MQTT_CODE_ERROR_MALFORMED_DATA; + } + } + } + WBLOG_INFO(bc->broker, "broker: SUBACK sock=%d packet_id=%u topics=%d", (int)bc->sock, packet_id, return_code_count); remain_len = MQTT_DATA_LEN_SIZE + return_code_count; diff --git a/src/mqtt_packet.c b/src/mqtt_packet.c index a88021bb7..38a4ba17f 100644 --- a/src/mqtt_packet.c +++ b/src/mqtt_packet.c @@ -210,6 +210,46 @@ static int FixedHeaderFlagsExpected(byte type, byte *expected) } } +/* [MQTT-3.9.3-2] Validate a SUBACK return code against the spec-allowed + * set. v3.1.1 §3.9.3 restricts the payload to exactly four values + * {0x00, 0x01, 0x02, 0x80}. v5 §3.9.3 broadens the set to include + * additional Reason Codes (Implementation specific error, Not authorized, + * Topic Filter invalid, Packet Identifier in use, Quota exceeded, + * Shared Subscriptions not supported, Subscription Identifiers not + * supported, Wildcard Subscriptions not supported). Anything outside + * the level-appropriate set is reserved and MUST be treated as + * malformed. protocol_level is taken as a byte (not enum) so callers + * that don't compile WOLFMQTT_V5 can still pass 0 unambiguously. */ +int MqttPacket_SubAckReturnCodeValid(byte code, byte protocol_level) +{ + if (code == MQTT_SUBSCRIBE_ACK_CODE_SUCCESS_MAX_QOS0 || + code == MQTT_SUBSCRIBE_ACK_CODE_SUCCESS_MAX_QOS1 || + code == MQTT_SUBSCRIBE_ACK_CODE_SUCCESS_MAX_QOS2 || + code == MQTT_SUBSCRIBE_ACK_CODE_FAILURE) { + return 1; + } +#ifdef WOLFMQTT_V5 + if (protocol_level >= MQTT_CONNECT_PROTOCOL_LEVEL_5) { + switch (code) { + case MQTT_REASON_IMPL_SPECIFIC_ERR: /* 0x83 */ + case MQTT_REASON_NOT_AUTHORIZED: /* 0x87 */ + case MQTT_REASON_TOPIC_FILTER_INVALID: /* 0x8F */ + case MQTT_REASON_PACKET_ID_IN_USE: /* 0x91 */ + case MQTT_REASON_QUOTA_EXCEEDED: /* 0x97 */ + case MQTT_REASON_SS_NOT_SUPPORTED: /* 0x9E */ + case MQTT_REASON_SUB_ID_NOT_SUP: /* 0xA1 */ + case MQTT_REASON_WILDCARD_SUB_NOT_SUP: /* 0xA2 */ + return 1; + default: + break; + } + } +#else + (void)protocol_level; +#endif + return 0; +} + int MqttPacket_FixedHeaderFlagsValid(byte type_flags) { byte type = (byte)MQTT_PACKET_TYPE_GET(type_flags); @@ -2309,6 +2349,8 @@ int MqttDecode_SubscribeAck(byte* rx_buf, int rx_buf_len, int payload_len = remain_len - (int)(rx_payload - &rx_buf[header_len]); int buf_remain = rx_buf_len - (int)(rx_payload - rx_buf); + byte level = 0; + int i; if (payload_len < 0 || buf_remain < 0) { return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_OUT_OF_BUFFER); } @@ -2317,6 +2359,26 @@ int MqttDecode_SubscribeAck(byte* rx_buf, int rx_buf_len, } if (payload_len > MAX_MQTT_TOPICS) payload_len = MAX_MQTT_TOPICS; + #ifdef WOLFMQTT_V5 + level = subscribe_ack->protocol_level; + #endif + /* [MQTT-3.9.3-2] Reject reserved SUBACK return codes before + * the bytes are copied into the caller's struct so a + * malformed broker response never surfaces to upper-layer + * subscription handling. Under v5 the property block has + * already been allocated above; free it before returning so + * a malformed-broker stream doesn't leak per-SUBACK. */ + for (i = 0; i < payload_len; i++) { + if (!MqttPacket_SubAckReturnCodeValid(rx_payload[i], level)) { + #ifdef WOLFMQTT_V5 + if (subscribe_ack->props != NULL) { + (void)MqttProps_Free(subscribe_ack->props); + subscribe_ack->props = NULL; + } + #endif + return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_MALFORMED_DATA); + } + } XMEMSET(subscribe_ack->return_codes, 0, MAX_MQTT_TOPICS); XMEMCPY(subscribe_ack->return_codes, rx_payload, payload_len); } diff --git a/tests/test_broker_connect.c b/tests/test_broker_connect.c index 3e17efce8..d44e540b9 100644 --- a/tests/test_broker_connect.c +++ b/tests/test_broker_connect.c @@ -1011,7 +1011,7 @@ TEST(qos2_pubrel_unknown_id_still_pubcomps) /* MQTT 3.1.1 §3.12 / v5 §3.12: PINGREQ has no variable header and no * payload, so Remaining Length MUST be 0. Broker dispatch must reject a * malformed PINGREQ with an abnormal close instead of emitting a - * PINGRESP. Issue #515. + * PINGRESP. * * The valid case is paired so a regression that reverses the conditional * (rejecting valid PINGREQs) trips the positive test. */ @@ -1058,8 +1058,8 @@ TEST(pingreq_nonzero_remain_len_closes_no_pingresp) 0x04, 0x02, 0x00, 0x3C, 0x00, 0x01, 'A' }; - /* Issue #515 reproducer wire: C0 01 00 — PINGREQ with one trailing - * byte. The fixed-header-only rule makes this malformed. */ + /* C0 01 00 — PINGREQ with one trailing byte. The fixed-header-only + * rule makes this malformed. */ static const byte pingreq_bad[] = { 0xC0, 0x01, 0x00 }; int i; @@ -1121,7 +1121,7 @@ TEST(disconnect_v311_nonzero_remain_len_fires_will) 0x00, 0x03, 'l', 'w', 't', 0x00, 0x03, 'b', 'y', 'e' }; - /* Issue #515 reproducer wire: E0 01 00 — malformed v3.1.1 DISCONNECT. */ + /* E0 01 00 — malformed v3.1.1 DISCONNECT (nonzero remain_len). */ static const byte disconnect_bad[] = { 0xE0, 0x01, 0x00 }; install_mock_net(&net); @@ -1157,7 +1157,7 @@ TEST(disconnect_v311_nonzero_remain_len_fires_will) * MqttPacket_FixedHeaderFlagsValid pre-check that runs before per-type * handlers, so a malformed DISCONNECT (e.g. 0xE1) takes the abnormal- * close path. Same LWT observable as the nonzero-remain-len test: - * abnormal close fires the will, normal close clears it. Issue #519. */ + * abnormal close fires the will, normal close clears it. */ TEST(disconnect_invalid_fixed_header_flags_fires_will) { MqttBroker broker; @@ -1183,8 +1183,7 @@ TEST(disconnect_invalid_fixed_header_flags_fires_will) 0x00, 0x03, 'l', 'w', 't', 0x00, 0x03, 'b', 'y', 'e' }; - /* Issue #519 reproducer wire: 0xE1 — DISCONNECT type with reserved - * bit 0 set. */ + /* 0xE1 — DISCONNECT type with reserved bit 0 set. */ static const byte disconnect_bad[] = { 0xE1, 0x00 }; install_mock_net(&net); @@ -1279,12 +1278,85 @@ static PublishInfo first_publish_info(const byte* buf, size_t len) return info; } +/* [MQTT-3.9.3-2] The broker SUBACK helper must reject reserved return + * codes before serializing them to the wire. The normal subscribe path + * only ever produces values in {0, 1, 2, 0x80}, so this defense is + * unreachable from production code today; the test calls the helper + * directly with a reserved code to pin the rejection branch. + * + * BrokerSend_SubAck is forward-declared here because it is internal to + * mqtt_broker.c (no public header); the test harness compiles + * mqtt_broker.c into the test binary so the symbol resolves at link. */ +extern int BrokerSend_SubAck(BrokerClient* bc, word16 packet_id, + const byte* return_codes, int return_code_count); + +TEST(broker_suback_reserved_v311_code_rejected) +{ + BrokerClient bc; + MqttBroker broker; + byte tx_buf[16]; + /* Reserved values: anything outside {0, 1, 2, 0x80} for v3.1.1. */ + static const byte reserved_codes[] = { 0x03, 0x7F, 0x81, 0xFF }; + size_t i; + + XMEMSET(&bc, 0, sizeof(bc)); + XMEMSET(&broker, 0, sizeof(broker)); + bc.broker = &broker; + bc.tx_buf = tx_buf; + bc.tx_buf_len = (int)sizeof(tx_buf); + bc.protocol_level = MQTT_CONNECT_PROTOCOL_LEVEL_4; + + for (i = 0; i < sizeof(reserved_codes); i++) { + int rc; + XMEMSET(tx_buf, 0xAA, sizeof(tx_buf)); + rc = BrokerSend_SubAck(&bc, 1, &reserved_codes[i], 1); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); + /* No SUBACK bytes should have hit the buffer — first byte still + * the 0xAA poison. */ + ASSERT_EQ(0xAA, (int)tx_buf[0]); + } +} + +/* Pair: a valid v3.1.1 code (0x80 = Failure) must succeed and overwrite + * the buffer. Without this a "reject everything" mutation of the helper + * would not be caught. The harness has no real network, so the call + * fails at MqttPacket_Write — we only assert that the helper got past + * the validation branch and into encoding (the type byte ends up at + * tx_buf[0]). */ +TEST(broker_suback_valid_v311_failure_code_encoded) +{ + BrokerClient bc; + MqttBroker broker; + byte tx_buf[16]; + byte code = MQTT_SUBSCRIBE_ACK_CODE_FAILURE; + int rc; + + XMEMSET(&bc, 0, sizeof(bc)); + XMEMSET(&broker, 0, sizeof(broker)); + bc.broker = &broker; + bc.tx_buf = tx_buf; + bc.tx_buf_len = (int)sizeof(tx_buf); + bc.protocol_level = MQTT_CONNECT_PROTOCOL_LEVEL_4; + + XMEMSET(tx_buf, 0xAA, sizeof(tx_buf)); + rc = BrokerSend_SubAck(&bc, 1, &code, 1); + /* Validation passed; the actual write fails with no real network, + * but the encoded bytes are already in tx_buf. */ + (void)rc; + ASSERT_EQ(MQTT_PACKET_TYPE_SET(MQTT_PACKET_TYPE_SUBSCRIBE_ACK), + tx_buf[0]); + ASSERT_EQ(0x03, (int)tx_buf[1]); /* remain_len = 3 */ + ASSERT_EQ(0x00, (int)tx_buf[2]); /* packet_id MSB */ + ASSERT_EQ(0x01, (int)tx_buf[3]); /* packet_id LSB */ + ASSERT_EQ(MQTT_SUBSCRIBE_ACK_CODE_FAILURE, tx_buf[4]); +} + #ifdef WOLFMQTT_BROKER_RETAINED /* [MQTT-3.3.1-5] The broker MUST store the Application Message and its * QoS, so retained delivery can use min(stored QoS, subscriber QoS). - * Issue #520 — pre-fix the retained store had no qos field and - * BrokerRetained_DeliverToClient hard-coded outgoing QoS to 0, downgrading - * QoS≥1 retained payloads. + * Pre-fix the retained store had no qos field and + * BrokerRetained_DeliverToClient hard-coded outgoing QoS to 0, + * downgrading QoS≥1 retained payloads. * * Wire pattern: publisher CONNECT, then retained PUBLISH at the test's * stored QoS; subscriber CONNECT (separate client), SUBSCRIBE at the @@ -1475,6 +1547,8 @@ int main(int argc, char** argv) RUN_TEST(disconnect_v311_nonzero_remain_len_fires_will); #endif RUN_TEST(disconnect_invalid_fixed_header_flags_fires_will); + RUN_TEST(broker_suback_reserved_v311_code_rejected); + RUN_TEST(broker_suback_valid_v311_failure_code_encoded); #ifdef WOLFMQTT_BROKER_RETAINED RUN_TEST(retained_qos_stored_1_sub_1_delivers_qos1); RUN_TEST(retained_qos_stored_2_sub_1_delivers_qos1); diff --git a/tests/test_mqtt_packet.c b/tests/test_mqtt_packet.c index 0dbe902df..4fe4526a5 100644 --- a/tests/test_mqtt_packet.c +++ b/tests/test_mqtt_packet.c @@ -2379,11 +2379,11 @@ TEST(decode_connect_will_qos3_rejected) } /* [MQTT-3.1.2-22] If the User Name Flag is 0, the Password Flag MUST be 0. - * The encoder already enforces this; the decoder must too. Wire mirrors - * issue #512's reproducer: flags 0x42 = clean_session | password, with - * client_id "cid" followed by a "secret" password field. Catches a - * regression that drops the receive-side flag-pair check and silently - * accepts a password without a user name. */ + * The encoder already enforces this; the decoder must too. Wire is + * flags 0x42 = clean_session | password, with client_id "cid" followed + * by a "secret" password field. Catches a regression that drops the + * receive-side flag-pair check and silently accepts a password without + * a user name. */ TEST(decode_connect_password_flag_without_username_flag_rejected) { byte buf[] = { @@ -2524,9 +2524,9 @@ TEST(decode_subscribe_v311_single_topic) } /* MQTT 3.1.1 §3.8.3.1: Requested QoS bits (0-1) = 0b11 is reserved and - * MUST be rejected (issue #518). Pre-fix the decoder forwarded the raw - * value and relied on the broker's defensive QoS cap; the broker cap is - * now dead code on the decoded path but kept for safety. */ + * MUST be rejected. Pre-fix the decoder forwarded the raw value and + * relied on the broker's defensive QoS cap; the broker cap is now dead + * code on the decoded path but kept for safety. */ TEST(decode_subscribe_v311_qos3_rejected) { byte rx_buf[] = { @@ -2548,9 +2548,9 @@ TEST(decode_subscribe_v311_qos3_rejected) } /* MQTT 3.1.1 §3.8.3.1: bits 2-7 of the options byte are reserved and - * MUST be 0. Wire matches issue #518's reproducer: high six bits set - * (0xFC) with low bits = QoS 0. The unmasked v3.x decoder used to drop - * the reserved bits and accept QoS 0 silently. */ + * MUST be 0. Wire has the high six bits set (0xFC) with low bits = QoS + * 0. The unmasked v3.x decoder used to drop the reserved bits and + * accept QoS 0 silently. */ TEST(decode_subscribe_v311_options_reserved_bits_qos0_rejected) { byte rx_buf[] = { @@ -2972,6 +2972,91 @@ TEST(decode_suback_malformed_remain_len_one) ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); } +/* [MQTT-3.9.3-2] v3.1.1 SUBACK return codes are restricted to + * {0x00, 0x01, 0x02, 0x80}. Wire carries a reserved value + * (0x03 / 0x7F) in the payload. */ +static void decode_suback_v311_reserved_helper(byte reserved_code) +{ + byte buf[5]; + MqttSubscribeAck ack; + int rc; + + buf[0] = MQTT_PACKET_TYPE_SET(MQTT_PACKET_TYPE_SUBSCRIBE_ACK); + buf[1] = 3; + buf[2] = 0; + buf[3] = 1; + buf[4] = reserved_code; + XMEMSET(&ack, 0, sizeof(ack)); + rc = MqttDecode_SubscribeAck(buf, (int)sizeof(buf), &ack); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); +} + +TEST(decode_suback_v311_reserved_code_0x03_rejected) +{ + decode_suback_v311_reserved_helper(0x03); +} + +TEST(decode_suback_v311_reserved_code_0x7F_rejected) +{ + decode_suback_v311_reserved_helper(0x7F); +} + +/* Pins all four spec-allowed v3.1.1 codes via MqttPacket_SubAckReturnCodeValid + * so a future change to the helper's table catches every entry. */ +TEST(suback_return_code_v311_allowed_set) +{ + ASSERT_TRUE(MqttPacket_SubAckReturnCodeValid(0x00, 0)); + ASSERT_TRUE(MqttPacket_SubAckReturnCodeValid(0x01, 0)); + ASSERT_TRUE(MqttPacket_SubAckReturnCodeValid(0x02, 0)); + ASSERT_TRUE(MqttPacket_SubAckReturnCodeValid(0x80, 0)); + /* Reserved values out of the v3.1.1 set */ + ASSERT_FALSE(MqttPacket_SubAckReturnCodeValid(0x03, 0)); + ASSERT_FALSE(MqttPacket_SubAckReturnCodeValid(0x04, 0)); + ASSERT_FALSE(MqttPacket_SubAckReturnCodeValid(0x7F, 0)); + ASSERT_FALSE(MqttPacket_SubAckReturnCodeValid(0x81, 0)); + ASSERT_FALSE(MqttPacket_SubAckReturnCodeValid(0xFF, 0)); +} + +#ifdef WOLFMQTT_V5 +/* v5 §3.9.3: SUBACK Reason Code set is broader. The decoder must accept + * v5 reason codes that are not in the v3.1.1 set when protocol_level=5. */ +TEST(decode_suback_v5_not_authorized_accepted) +{ + /* Wire: SUBACK type 0x90, remain_len = 4 (packet_id + props_len(0) + * + 1 reason byte), packet_id=1, props_len=0, reason=0x87. */ + byte buf[] = { 0x90, 0x04, 0x00, 0x01, 0x00, 0x87 }; + MqttSubscribeAck ack; + int rc; + + XMEMSET(&ack, 0, sizeof(ack)); + ack.protocol_level = MQTT_CONNECT_PROTOCOL_LEVEL_5; + rc = MqttDecode_SubscribeAck(buf, (int)sizeof(buf), &ack); + ASSERT_TRUE(rc > 0); + ASSERT_EQ(0x87, ack.return_codes[0]); +} + +/* Pin v5's broadened set via the helper. */ +TEST(suback_return_code_v5_allowed_set) +{ + byte v5 = MQTT_CONNECT_PROTOCOL_LEVEL_5; + /* QoS 0/1/2 and the spec-defined v5 reason codes. */ + ASSERT_TRUE(MqttPacket_SubAckReturnCodeValid(0x00, v5)); + ASSERT_TRUE(MqttPacket_SubAckReturnCodeValid(0x80, v5)); + ASSERT_TRUE(MqttPacket_SubAckReturnCodeValid(0x83, v5)); + ASSERT_TRUE(MqttPacket_SubAckReturnCodeValid(0x87, v5)); + ASSERT_TRUE(MqttPacket_SubAckReturnCodeValid(0x8F, v5)); + ASSERT_TRUE(MqttPacket_SubAckReturnCodeValid(0x91, v5)); + ASSERT_TRUE(MqttPacket_SubAckReturnCodeValid(0x97, v5)); + ASSERT_TRUE(MqttPacket_SubAckReturnCodeValid(0x9E, v5)); + ASSERT_TRUE(MqttPacket_SubAckReturnCodeValid(0xA1, v5)); + ASSERT_TRUE(MqttPacket_SubAckReturnCodeValid(0xA2, v5)); + /* Codes that aren't in the v5 SUBACK set either. */ + ASSERT_FALSE(MqttPacket_SubAckReturnCodeValid(0x03, v5)); + ASSERT_FALSE(MqttPacket_SubAckReturnCodeValid(0x81, v5)); + ASSERT_FALSE(MqttPacket_SubAckReturnCodeValid(0xFF, v5)); +} +#endif /* WOLFMQTT_V5 */ + /* ============================================================================ * MqttDecode_PublishResp * ============================================================================ */ @@ -3024,8 +3109,8 @@ TEST(decode_publish_resp_malformed_remain_len_one) * after the Packet Identifier is malformed in v3.x. v5 §3.4-3.7 * relaxes this with an optional Reason Code and Properties; the * `protocol_level` field on the response struct selects between the - * strict and relaxed decoders. Issues #516 and #517 — the wire reflects - * the issues' reproducer payloads (extra trailing zero byte). */ + * strict and relaxed decoders. The wire carries an extra trailing + * zero byte after the Packet Identifier. */ TEST(decode_puback_v311_extra_payload_rejected) { byte buf[] = { 0x40, 0x03, 0x00, 0x07, 0x00 }; @@ -3301,8 +3386,8 @@ TEST(decode_pingresp_valid) ASSERT_EQ(2, rc); } -/* Issue #515 reproducer: PINGRESP with one trailing byte. Without the fix - * the decoder returned 3 (the packet length). */ +/* PINGRESP with one trailing byte — must be rejected as malformed. + * Without the fix the decoder returned 3 (the packet length). */ TEST(decode_pingresp_nonzero_remain_len_rejected) { byte buf[] = { 0xD0, 0x01, 0x00 }; @@ -3326,9 +3411,10 @@ TEST(decode_disconnect_v311_valid) ASSERT_EQ(2, rc); } -/* Issue #515 reproducer: v3.1.1 DISCONNECT with one trailing byte. The - * v3.1.1 spec defines DISCONNECT as fixed-header-only; the WOLFMQTT_V5 - * decoder below legitimately accepts a Reason Code and Properties. */ +/* v3.1.1 DISCONNECT with one trailing byte must be rejected as + * malformed. The v3.1.1 spec defines DISCONNECT as fixed-header-only; + * the WOLFMQTT_V5 decoder below legitimately accepts a Reason Code and + * Properties. */ TEST(decode_disconnect_v311_nonzero_remain_len_rejected) { byte buf[] = { 0xE0, 0x01, 0x00 }; @@ -3341,8 +3427,8 @@ TEST(decode_disconnect_v311_nonzero_remain_len_rejected) } /* MQTT 3.1.1 §3.14.1 / [MQTT-2.2.2-2]: DISCONNECT fixed-header low - * nibble MUST be 0000. Issue #519 — wire 0xE1 is the issue's - * reproducer. The check fires inside MqttDecode_FixedHeader via + * nibble MUST be 0000. Wire 0xE1 sets bit 0 of the reserved nibble. + * The check fires inside MqttDecode_FixedHeader via * MqttPacket_FixedHeaderFlagsValid; this test pins the per-decoder * surface so a future caller that builds its own header path can't * silently accept a malformed disconnect. */ @@ -4014,6 +4100,13 @@ void run_mqtt_packet_tests(void) RUN_TEST(decode_suback_multiple_return_codes); RUN_TEST(decode_suback_malformed_remain_len_zero); RUN_TEST(decode_suback_malformed_remain_len_one); + RUN_TEST(decode_suback_v311_reserved_code_0x03_rejected); + RUN_TEST(decode_suback_v311_reserved_code_0x7F_rejected); + RUN_TEST(suback_return_code_v311_allowed_set); +#ifdef WOLFMQTT_V5 + RUN_TEST(decode_suback_v5_not_authorized_accepted); + RUN_TEST(suback_return_code_v5_allowed_set); +#endif /* MqttDecode_PublishResp */ RUN_TEST(decode_publish_resp_valid); diff --git a/wolfmqtt/mqtt_packet.h b/wolfmqtt/mqtt_packet.h index b4cc40beb..46e429226 100644 --- a/wolfmqtt/mqtt_packet.h +++ b/wolfmqtt/mqtt_packet.h @@ -662,6 +662,18 @@ WOLFMQTT_API int MqttPacket_Read(struct _MqttClient *client, byte* rx_buf, * packet. */ WOLFMQTT_API int MqttPacket_FixedHeaderFlagsValid(byte type_flags); +/*! \brief [MQTT-3.9.3-2] Validate a SUBACK return code / Reason Code. + * \param code The byte to validate. + * \param protocol_level MQTT protocol level (4 = v3.1.1, 5 = v5). v3.1.1 + * restricts SUBACK to {0x00, 0x01, 0x02, 0x80}; + * v5 broadens the set with additional Reason + * Codes (e.g., 0x83, 0x87, 0x8F, 0x91, 0x97, + * 0x9E, 0xA1, 0xA2). + * \return 1 if the code is allowed, 0 if reserved. + */ +WOLFMQTT_API int MqttPacket_SubAckReturnCodeValid(byte code, + byte protocol_level); + /* Packet Element Encoders/Decoders */ WOLFMQTT_API int MqttDecode_Num(byte* buf, word16 *len, word32 buf_len); WOLFMQTT_API int MqttEncode_Num(byte *buf, word16 len); From 7147f2e73bf0e4d84196d9e21a9aee6f30c1cbdb Mon Sep 17 00:00:00 2001 From: Eric Blankenhorn Date: Tue, 5 May 2026 10:14:22 -0500 Subject: [PATCH 23/32] Fix CONNACK Session Present Is Not Set When a Persistent Session Is Resumed --- src/mqtt_broker.c | 47 +++++- tests/test_broker_connect.c | 284 ++++++++++++++++++++++++++++++++++++ 2 files changed, 325 insertions(+), 6 deletions(-) diff --git a/src/mqtt_broker.c b/src/mqtt_broker.c index 90ad50bc4..851ff15f4 100644 --- a/src/mqtt_broker.c +++ b/src/mqtt_broker.c @@ -1916,13 +1916,17 @@ static void BrokerSubs_RemoveByClientId(MqttBroker* broker, #endif } -static void BrokerSubs_ReassociateClient(MqttBroker* broker, +/* Reattach orphaned (or active-takeover) subscriptions for `client_id` to + * `new_bc`. Returns the number of reassociated subscriptions; the caller + * uses a non-zero return as the [MQTT-3.2.2-2] "stored session state was + * resumed" signal for CONNACK Session Present. */ +static int BrokerSubs_ReassociateClient(MqttBroker* broker, const char* client_id, BrokerClient* new_bc) { int count = 0; if (broker == NULL || client_id == NULL || client_id[0] == '\0' || new_bc == NULL) { - return; + return 0; } #ifdef WOLFMQTT_STATIC_MEMORY { @@ -1968,6 +1972,7 @@ static void BrokerSubs_ReassociateClient(MqttBroker* broker, WBLOG_INFO(broker, "broker: reassociated %d subs for client_id=%s", count, client_id); } + return count; } /* -------------------------------------------------------------------------- */ @@ -2894,6 +2899,18 @@ static int BrokerHandle_Connect(BrokerClient* bc, int rx_len, MqttConnectAck ack; MqttMessage lwt; word16 id_len = 0; + /* [MQTT-3.2.2-2] Session Present is set when an accepted + * CleanSession=0 connection finds stored session state for the + * client_id. The MQTT spec defines Session state as more than + * subscriptions (in-flight QoS 1/2 PUBLISH, unacknowledged PUBREL, + * outstanding packet identifiers); this broker only persists + * subscriptions across disconnects today (BrokerSubs_OrphanClient + * keeps them, but per-message QoS 2 state in bc->qos2_in_flight is + * dropped). If broker session persistence ever widens to cover QoS + * state, the source of session_present needs to widen too — a + * BrokerSession_HasStoredState() helper is the natural extension + * point. */ + int session_present = 0; #ifdef WOLFMQTT_V5 int auto_assigned = 0; #endif @@ -3100,7 +3117,10 @@ static int BrokerHandle_Connect(BrokerClient* bc, int rx_len, #endif if (!mc.clean_session) { /* Reassociate old client's subs to new client */ - BrokerSubs_ReassociateClient(broker, bc->client_id, bc); + if (BrokerSubs_ReassociateClient(broker, bc->client_id, bc) + > 0) { + session_present = 1; + } } BrokerSubs_RemoveClient(broker, old); BrokerClient_Remove(broker, old); @@ -3108,7 +3128,10 @@ static int BrokerHandle_Connect(BrokerClient* bc, int rx_len, else if (!mc.clean_session) { /* No existing client, but check for orphaned subs from * a previous session (clean_session=0 reconnect) */ - BrokerSubs_ReassociateClient(broker, bc->client_id, bc); + if (BrokerSubs_ReassociateClient(broker, bc->client_id, bc) + > 0) { + session_present = 1; + } } if (mc.clean_session) { /* Remove any remaining subs for this client_id */ @@ -3246,8 +3269,13 @@ static int BrokerHandle_Connect(BrokerClient* bc, int rx_len, } #endif /* WOLFMQTT_BROKER_AUTH */ - /* Check auth before sending CONNACK */ - ack.flags = 0; + /* Check auth before sending CONNACK. [MQTT-3.2.2-2]: when the + * accepted CleanSession=0 connection finds stored session state, + * Session Present MUST be 1; otherwise it MUST be 0. The flag is + * cleared again below for any path that overrides return_code to a + * non-zero refusal — [MQTT-3.2.2-4] requires Session Present=0 on a + * refused CONNACK. */ + ack.flags = session_present ? MQTT_CONNECT_ACK_FLAG_SESSION_PRESENT : 0; ack.return_code = MQTT_CONNECT_ACK_CODE_ACCEPTED; #ifdef WOLFMQTT_V5 ack.props = NULL; @@ -3341,6 +3369,13 @@ static int BrokerHandle_Connect(BrokerClient* bc, int rx_len, #endif send_connack: + /* [MQTT-3.2.2-4] A refused CONNACK MUST have Session Present = 0. The + * accepted path above already set Session Present from session_present; + * this clear covers any goto-send_connack jump that overrode + * return_code to a refusal after that point. */ + if (ack.return_code != MQTT_CONNECT_ACK_CODE_ACCEPTED) { + ack.flags = 0; + } rc = MqttEncode_ConnectAck(bc->tx_buf, BROKER_CLIENT_TX_SZ(bc), &ack); if (rc > 0) { WBLOG_INFO(broker, "broker: CONNACK send sock=%d code=%d", (int)bc->sock, diff --git a/tests/test_broker_connect.c b/tests/test_broker_connect.c index d44e540b9..160f6c95f 100644 --- a/tests/test_broker_connect.c +++ b/tests/test_broker_connect.c @@ -1278,6 +1278,284 @@ static PublishInfo first_publish_info(const byte* buf, size_t len) return info; } +/* [MQTT-3.2.2-2] When a CleanSession=0 connection is accepted and the + * broker has stored session state for the supplied Client Identifier, + * Session Present in the CONNACK MUST be 1. + * + * Two-phase mock harness: client 0 connects clean=0, subscribes, and + * disconnects (orphaning the sub); client 1 then connects clean=0 with + * the same client_id and must receive CONNACK 20 02 01 00. The accept + * count is gated to 1 across the first phase so client 1's accept + * happens after client 0 has been removed (avoids the takeover branch + * which is the same code path but reaches it differently). */ +TEST(connack_session_present_set_on_resumed_session) +{ + MqttBroker broker; + MqttBrokerNet net; + int i; + static const byte connect0[] = { + 0x10, 0x0D, + 0x00, 0x04, 'M', 'Q', 'T', 'T', + 0x04, 0x00, 0x00, 0x3C, /* clean_session = 0 */ + 0x00, 0x01, 'K' + }; + static const byte subscribe0[] = { + 0x82, 0x06, + 0x00, 0x01, + 0x00, 0x01, 'k', + 0x00 + }; + static const byte disconnect0[] = { 0xE0, 0x00 }; + static const byte connect1[] = { + 0x10, 0x0D, + 0x00, 0x04, 'M', 'Q', 'T', 'T', + 0x04, 0x00, 0x00, 0x3C, + 0x00, 0x01, 'K' + }; + + install_mock_net(&net); + XMEMSET(&broker, 0, sizeof(broker)); + ASSERT_EQ(MQTT_CODE_SUCCESS, MqttBroker_Init(&broker, &net)); + ASSERT_EQ(MQTT_CODE_SUCCESS, MqttBroker_Start(&broker)); + + /* Phase 1: only client 0 is acceptable. Pre-stage client 1's input + * in slot 1 so the read callback sees it once accept hands out the + * second sock. */ + reset_mock_clients(1); + mock_client_input_append(0, connect0, sizeof(connect0)); + mock_client_input_append(0, subscribe0, sizeof(subscribe0)); + mock_client_input_append(0, disconnect0, sizeof(disconnect0)); + mock_client_input_append(1, connect1, sizeof(connect1)); + for (i = 0; i < 16; i++) { + MqttBroker_Step(&broker); + } + ASSERT_TRUE(g_clients[0].closed); + + /* Phase 2: open the second slot and let the broker accept client 1. */ + g_clients_active = 2; + for (i = 0; i < 16; i++) { + MqttBroker_Step(&broker); + } + + /* Client 1's CONNACK must carry Session Present = 1. */ + ASSERT_TRUE(g_clients[1].out_len >= 4); + ASSERT_EQ(0x20, g_clients[1].out_buf[0]); /* CONNACK type | flags */ + ASSERT_EQ(0x02, g_clients[1].out_buf[1]); /* remain_len = 2 */ + ASSERT_EQ(0x01, g_clients[1].out_buf[2]); /* Session Present */ + ASSERT_EQ(0x00, g_clients[1].out_buf[3]); /* return_code = Accepted */ + + MqttBroker_Stop(&broker); + MqttBroker_Free(&broker); +} + +/* [MQTT-3.2.2-2] Session Present must also be set on the takeover + * branch: a CleanSession=0 connect with the same Client Identifier + * while the previous client is still connected reassociates the + * existing subs to the new client, which counts as "stored session + * state" for the new connection. Distinct from the orphan-resume test + * above because BrokerHandle_Connect reaches the helper through the + * `if (old != NULL)` branch with `s->client != NULL`, not the + * `else if` branch with orphaned subs. */ +TEST(connack_session_present_set_on_takeover) +{ + MqttBroker broker; + MqttBrokerNet net; + int i; + static const byte connect0[] = { + 0x10, 0x0D, + 0x00, 0x04, 'M', 'Q', 'T', 'T', + 0x04, 0x00, 0x00, 0x3C, + 0x00, 0x01, 'K' + }; + static const byte subscribe0[] = { + 0x82, 0x06, + 0x00, 0x01, + 0x00, 0x01, 'k', + 0x00 + }; + static const byte connect1[] = { + 0x10, 0x0D, + 0x00, 0x04, 'M', 'Q', 'T', 'T', + 0x04, 0x00, 0x00, 0x3C, + 0x00, 0x01, 'K' + }; + + install_mock_net(&net); + XMEMSET(&broker, 0, sizeof(broker)); + ASSERT_EQ(MQTT_CODE_SUCCESS, MqttBroker_Init(&broker, &net)); + ASSERT_EQ(MQTT_CODE_SUCCESS, MqttBroker_Start(&broker)); + + /* Phase 1: only client 0 connects and subscribes (no DISCONNECT). */ + reset_mock_clients(1); + mock_client_input_append(0, connect0, sizeof(connect0)); + mock_client_input_append(0, subscribe0, sizeof(subscribe0)); + mock_client_input_append(1, connect1, sizeof(connect1)); + for (i = 0; i < 16; i++) { + MqttBroker_Step(&broker); + } + /* Client 0 is still connected — sub registered, no DISCONNECT yet. */ + ASSERT_FALSE(g_clients[0].closed); + + /* Phase 2: client 1 connects with the same client_id. The takeover + * branch reassociates client 0's still-active sub to client 1. */ + g_clients_active = 2; + for (i = 0; i < 16; i++) { + MqttBroker_Step(&broker); + } + + ASSERT_TRUE(g_clients[1].out_len >= 4); + ASSERT_EQ(0x20, g_clients[1].out_buf[0]); + ASSERT_EQ(0x02, g_clients[1].out_buf[1]); + ASSERT_EQ(0x01, g_clients[1].out_buf[2]); /* Session Present = 1 */ + ASSERT_EQ(0x00, g_clients[1].out_buf[3]); + /* Client 0 was kicked off by the takeover. */ + ASSERT_TRUE(g_clients[0].closed); + + MqttBroker_Stop(&broker); + MqttBroker_Free(&broker); +} + +/* Negative case: a clean_session=1 reconnect with the same Client + * Identifier MUST get Session Present = 0 even if there were prior + * subscriptions, because clean_session=1 discards stored state. */ +TEST(connack_session_present_clear_on_clean_session_reconnect) +{ + MqttBroker broker; + MqttBrokerNet net; + int i; + static const byte connect0[] = { + 0x10, 0x0D, + 0x00, 0x04, 'M', 'Q', 'T', 'T', + 0x04, 0x00, 0x00, 0x3C, /* clean_session = 0 */ + 0x00, 0x01, 'K' + }; + static const byte subscribe0[] = { + 0x82, 0x06, + 0x00, 0x01, + 0x00, 0x01, 'k', + 0x00 + }; + static const byte disconnect0[] = { 0xE0, 0x00 }; + static const byte connect1[] = { + 0x10, 0x0D, + 0x00, 0x04, 'M', 'Q', 'T', 'T', + 0x04, 0x02, 0x00, 0x3C, /* clean_session = 1 */ + 0x00, 0x01, 'K' + }; + + install_mock_net(&net); + XMEMSET(&broker, 0, sizeof(broker)); + ASSERT_EQ(MQTT_CODE_SUCCESS, MqttBroker_Init(&broker, &net)); + ASSERT_EQ(MQTT_CODE_SUCCESS, MqttBroker_Start(&broker)); + + reset_mock_clients(1); + mock_client_input_append(0, connect0, sizeof(connect0)); + mock_client_input_append(0, subscribe0, sizeof(subscribe0)); + mock_client_input_append(0, disconnect0, sizeof(disconnect0)); + mock_client_input_append(1, connect1, sizeof(connect1)); + for (i = 0; i < 16; i++) { + MqttBroker_Step(&broker); + } + ASSERT_TRUE(g_clients[0].closed); + + g_clients_active = 2; + for (i = 0; i < 16; i++) { + MqttBroker_Step(&broker); + } + + ASSERT_TRUE(g_clients[1].out_len >= 4); + ASSERT_EQ(0x20, g_clients[1].out_buf[0]); + ASSERT_EQ(0x02, g_clients[1].out_buf[1]); + ASSERT_EQ(0x00, g_clients[1].out_buf[2]); /* Session Present = 0 */ + ASSERT_EQ(0x00, g_clients[1].out_buf[3]); + + MqttBroker_Stop(&broker); + MqttBroker_Free(&broker); +} + +#ifdef WOLFMQTT_V5 +/* v5 covers the same orphan-resume scenario but the CONNACK wire format + * differs (Properties Length VBI between return code and payload). The + * test decodes the CONNACK via MqttDecode_ConnectAck rather than + * pinning fixed byte offsets, so a future change to v5 CONNACK encoding + * doesn't silently regress the Session Present semantic. */ +TEST(connack_session_present_v5_set_on_resumed_session) +{ + MqttBroker broker; + MqttBrokerNet net; + MqttConnectAck ack; + int rc; + int i; + /* v5 CONNECT clean=0, level=5, props_len=0, client_id="K". remain + * = 6 + 1 + 1 + 2 + 1 + 3 = 14. */ + static const byte connect0[] = { + 0x10, 0x0E, + 0x00, 0x04, 'M', 'Q', 'T', 'T', + 0x05, + 0x00, /* clean_start = 0 */ + 0x00, 0x3C, + 0x00, /* properties length = 0 */ + 0x00, 0x01, 'K' + }; + static const byte subscribe0[] = { + /* v5 SUBSCRIBE: type|flags=0x82, remain = 7 + * (packet_id 2 + props_len 1 + topic 3 + options 1). */ + 0x82, 0x07, + 0x00, 0x01, + 0x00, + 0x00, 0x01, 'k', + 0x00 + }; + /* v5 DISCONNECT with Reason Code 0 (Normal disconnection) and no + * properties. remain = 1 + 1 = 2. */ + static const byte disconnect0[] = { 0xE0, 0x02, 0x00, 0x00 }; + static const byte connect1[] = { + 0x10, 0x0E, + 0x00, 0x04, 'M', 'Q', 'T', 'T', + 0x05, + 0x00, + 0x00, 0x3C, + 0x00, + 0x00, 0x01, 'K' + }; + + install_mock_net(&net); + XMEMSET(&broker, 0, sizeof(broker)); + ASSERT_EQ(MQTT_CODE_SUCCESS, MqttBroker_Init(&broker, &net)); + ASSERT_EQ(MQTT_CODE_SUCCESS, MqttBroker_Start(&broker)); + + reset_mock_clients(1); + mock_client_input_append(0, connect0, sizeof(connect0)); + mock_client_input_append(0, subscribe0, sizeof(subscribe0)); + mock_client_input_append(0, disconnect0, sizeof(disconnect0)); + mock_client_input_append(1, connect1, sizeof(connect1)); + for (i = 0; i < 16; i++) { + MqttBroker_Step(&broker); + } + ASSERT_TRUE(g_clients[0].closed); + + g_clients_active = 2; + for (i = 0; i < 16; i++) { + MqttBroker_Step(&broker); + } + + XMEMSET(&ack, 0, sizeof(ack)); + ack.protocol_level = MQTT_CONNECT_PROTOCOL_LEVEL_5; + rc = MqttDecode_ConnectAck(g_clients[1].out_buf, + (int)g_clients[1].out_len, &ack); + ASSERT_TRUE(rc > 0); + ASSERT_EQ(MQTT_CONNECT_ACK_FLAG_SESSION_PRESENT, + (int)(ack.flags & MQTT_CONNECT_ACK_FLAG_SESSION_PRESENT)); + ASSERT_EQ(MQTT_CONNECT_ACK_CODE_ACCEPTED, ack.return_code); + if (ack.props != NULL) { + (void)MqttProps_Free(ack.props); + } + + MqttBroker_Stop(&broker); + MqttBroker_Free(&broker); +} +#endif /* WOLFMQTT_V5 */ + /* [MQTT-3.9.3-2] The broker SUBACK helper must reject reserved return * codes before serializing them to the wire. The normal subscribe path * only ever produces values in {0, 1, 2, 0x80}, so this defense is @@ -1547,6 +1825,12 @@ int main(int argc, char** argv) RUN_TEST(disconnect_v311_nonzero_remain_len_fires_will); #endif RUN_TEST(disconnect_invalid_fixed_header_flags_fires_will); + RUN_TEST(connack_session_present_set_on_resumed_session); + RUN_TEST(connack_session_present_set_on_takeover); + RUN_TEST(connack_session_present_clear_on_clean_session_reconnect); +#ifdef WOLFMQTT_V5 + RUN_TEST(connack_session_present_v5_set_on_resumed_session); +#endif RUN_TEST(broker_suback_reserved_v311_code_rejected); RUN_TEST(broker_suback_valid_v311_failure_code_encoded); #ifdef WOLFMQTT_BROKER_RETAINED From 67f8f8c6e964dfb1ef0a03a62e58ec0c0b8c2c1b Mon Sep 17 00:00:00 2001 From: Eric Blankenhorn Date: Tue, 5 May 2026 11:13:36 -0500 Subject: [PATCH 24/32] Fix topic filtering issues --- src/mqtt_broker.c | 30 ++++++- src/mqtt_packet.c | 80 +++++++++++++++++- tests/test_broker_connect.c | 159 +++++++++++++++++++++++++++++++++++ tests/test_mqtt_packet.c | 163 ++++++++++++++++++++++++++++++++++++ wolfmqtt/mqtt_packet.h | 22 +++++ 5 files changed, 448 insertions(+), 6 deletions(-) diff --git a/src/mqtt_broker.c b/src/mqtt_broker.c index 851ff15f4..a5ff4ba95 100644 --- a/src/mqtt_broker.c +++ b/src/mqtt_broker.c @@ -3440,9 +3440,35 @@ static int BrokerHandle_Subscribe(BrokerClient* bc, int rx_len, if (f && MqttDecode_Num((byte*)f - MQTT_DATA_LEN_SIZE, &flen, MQTT_DATA_LEN_SIZE) == MQTT_DATA_LEN_SIZE) { - int sub_rc = BrokerSubs_Add(broker, bc, f, flen, topic_qos); + int sub_rc = MQTT_CODE_SUCCESS; + byte fail_code = MQTT_SUBSCRIBE_ACK_CODE_FAILURE; + #ifndef WOLFMQTT_BROKER_WILDCARDS + /* [MQTT-3.8.3-2] (v3.1.1 §3.8.3): when the server does not + * support wildcard subscriptions it MUST reject any + * Subscription request whose filter contains a wildcard. + * v5 §3.2.2.3.20 advertises this via the Wildcard + * Subscription Available property and §3.9.3 reserves + * reason code 0xA2 (Wildcard Subscriptions not supported) + * specifically for this case — use it on v5 connections so + * the client gets the actionable diagnostic the spec + * defines. The decoder already validated Topic Filter + * syntax via MqttPacket_TopicFilterValid, so any '#' or + * '+' byte here is necessarily a real wildcard. */ + if (MqttPacket_TopicFilterIsWildcard(f, flen)) { + sub_rc = MQTT_CODE_ERROR_BAD_ARG; + #ifdef WOLFMQTT_V5 + if (bc->protocol_level >= MQTT_CONNECT_PROTOCOL_LEVEL_5) { + fail_code = MQTT_REASON_WILDCARD_SUB_NOT_SUP; + } + #endif + } + if (sub_rc == MQTT_CODE_SUCCESS) + #endif + { + sub_rc = BrokerSubs_Add(broker, bc, f, flen, topic_qos); + } if (sub_rc != MQTT_CODE_SUCCESS) { - granted_qos = (MqttQoS)MQTT_SUBSCRIBE_ACK_CODE_FAILURE; + granted_qos = (MqttQoS)fail_code; } #ifdef WOLFMQTT_BROKER_RETAINED else { diff --git a/src/mqtt_packet.c b/src/mqtt_packet.c index 38a4ba17f..964d67d79 100644 --- a/src/mqtt_packet.c +++ b/src/mqtt_packet.c @@ -250,6 +250,63 @@ int MqttPacket_SubAckReturnCodeValid(byte code, byte protocol_level) return 0; } +/* Validate an MQTT Topic Filter against the syntax rules from + * [MQTT-4.7.3-1] (minimum length one character), [MQTT-4.7.1-2] + * (multi-level wildcard '#' must be either the whole filter or directly + * follow '/', and must be the final character), and [MQTT-4.7.1-3] + * (single-level wildcard '+' must occupy an entire level). v5 §4.7 + * carries the same rules. Returns 1 if the filter is well-formed, 0 if + * it must be treated as malformed. The length is decoded by the caller + * via MqttDecode_String so this helper takes (filter, len) rather than + * a NUL-terminated string. */ +int MqttPacket_TopicFilterValid(const char* filter, word16 len) +{ + word16 i; + if (filter == NULL || len == 0) { + return 0; + } + for (i = 0; i < len; i++) { + char c = filter[i]; + if (c == '#') { + if (i != 0 && filter[i - 1] != '/') { + return 0; + } + if (i != (word16)(len - 1)) { + return 0; + } + } + else if (c == '+') { + if (i != 0 && filter[i - 1] != '/') { + return 0; + } + if (i != (word16)(len - 1) && filter[i + 1] != '/') { + return 0; + } + } + } + return 1; +} + +/* Return 1 if the Topic Filter contains a multi-level ('#') or + * single-level ('+') wildcard, 0 otherwise. The decoder has already + * validated wildcard *placement* via MqttPacket_TopicFilterValid, so a + * matching byte here is necessarily a real wildcard rather than a + * misplaced one. Centralizes wildcard detection so the broker's + * wildcard-disabled policy doesn't have to duplicate the scan. */ +int MqttPacket_TopicFilterIsWildcard(const char* filter, word16 len) +{ + word16 i; + if (filter == NULL) { + return 0; + } + for (i = 0; i < len; i++) { + if (filter[i] == '#' || filter[i] == '+') { + return 1; + } + } + return 0; +} + int MqttPacket_FixedHeaderFlagsValid(byte type_flags) { byte type = (byte)MQTT_PACKET_TYPE_GET(type_flags); @@ -2213,18 +2270,25 @@ int MqttDecode_Subscribe(byte *rx_buf, int rx_buf_len, MqttSubscribe *subscribe) while (rx_payload < rx_end) { MqttTopic *topic; byte options; + word16 filter_len = 0; if (subscribe->topic_count >= MAX_MQTT_TOPICS) { return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_OUT_OF_BUFFER); } topic = &subscribe->topics[subscribe->topic_count]; - tmp = MqttDecode_String(rx_payload, &topic->topic_filter, NULL, - (word32)(rx_end - rx_payload)); + tmp = MqttDecode_String(rx_payload, &topic->topic_filter, + &filter_len, (word32)(rx_end - rx_payload)); if (tmp < 0) { return tmp; } if (rx_payload + tmp > rx_end) { return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_OUT_OF_BUFFER); } + /* [MQTT-4.7.3-1] / [MQTT-4.7.1-2] / [MQTT-4.7.1-3] Reject + * empty filters and malformed wildcard placement. */ + if (!MqttPacket_TopicFilterValid(topic->topic_filter, + filter_len)) { + return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_MALFORMED_DATA); + } rx_payload += tmp; if (rx_payload >= rx_end) { return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_OUT_OF_BUFFER); @@ -2564,18 +2628,26 @@ int MqttDecode_Unsubscribe(byte *rx_buf, int rx_buf_len, MqttUnsubscribe *unsubs while (rx_payload < rx_end) { MqttTopic *topic; + word16 filter_len = 0; if (unsubscribe->topic_count >= MAX_MQTT_TOPICS) { return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_OUT_OF_BUFFER); } topic = &unsubscribe->topics[unsubscribe->topic_count]; - tmp = MqttDecode_String(rx_payload, &topic->topic_filter, NULL, - (word32)(rx_end - rx_payload)); + tmp = MqttDecode_String(rx_payload, &topic->topic_filter, + &filter_len, (word32)(rx_end - rx_payload)); if (tmp < 0) { return tmp; } if (rx_payload + tmp > rx_end) { return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_OUT_OF_BUFFER); } + /* [MQTT-4.7.3-1] / [MQTT-4.7.1-2] / [MQTT-4.7.1-3]: an + * UNSUBSCRIBE Topic Filter must obey the same syntax rules + * as a SUBSCRIBE Topic Filter. */ + if (!MqttPacket_TopicFilterValid(topic->topic_filter, + filter_len)) { + return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_MALFORMED_DATA); + } rx_payload += tmp; unsubscribe->topic_count++; } diff --git a/tests/test_broker_connect.c b/tests/test_broker_connect.c index 160f6c95f..6e7750365 100644 --- a/tests/test_broker_connect.c +++ b/tests/test_broker_connect.c @@ -1556,6 +1556,158 @@ TEST(connack_session_present_v5_set_on_resumed_session) } #endif /* WOLFMQTT_V5 */ +#ifndef WOLFMQTT_BROKER_WILDCARDS +/* [MQTT-3.8.3-2] (v3.1.1 §3.8.3): when the server does not support + * wildcard subscriptions it MUST reject any Subscription request whose + * filter contains '#' or '+'. v5 §3.9.3 reserves reason code 0xA2 + * (Wildcard Subscriptions not supported) for this case, paired with + * the v5 §3.2.2.3.20 Wildcard Subscription Available CONNACK property. + * The decoder still accepts the syntactically-valid wildcard filter; + * rejection lives in the broker's per-topic SUBACK entry. The plain- + * topic case is paired so a "reject everything" mutation also trips. */ +TEST(broker_no_wildcards_suback_failure_for_wildcard_filter) +{ + MqttBroker broker; + MqttBrokerNet net; + int i; + static const byte connect[] = { + 0x10, 0x0D, + 0x00, 0x04, 'M', 'Q', 'T', 'T', + 0x04, 0x02, 0x00, 0x3C, + 0x00, 0x01, 'A' + }; + /* SUBSCRIBE filter "+/r" (valid syntax; wildcard). */ + static const byte subscribe_wild[] = { + 0x82, 0x08, + 0x00, 0x07, + 0x00, 0x03, '+', '/', 'r', + 0x00 + }; + int last_byte; + + install_mock_net(&net); + XMEMSET(&broker, 0, sizeof(broker)); + ASSERT_EQ(MQTT_CODE_SUCCESS, MqttBroker_Init(&broker, &net)); + ASSERT_EQ(MQTT_CODE_SUCCESS, MqttBroker_Start(&broker)); + + reset_mock_clients(1); + mock_client_input_append(0, connect, sizeof(connect)); + mock_client_input_append(0, subscribe_wild, sizeof(subscribe_wild)); + for (i = 0; i < 16; i++) { + MqttBroker_Step(&broker); + } + + /* Output buffer carries CONNACK then SUBACK. SUBACK wire is + * 0x90 0x03 packet_id_hi packet_id_lo return_code. The last byte is + * the per-topic return code: must be Failure (0x80). */ + ASSERT_EQ(1, count_packets_of_type(g_clients[0].out_buf, + g_clients[0].out_len, MQTT_PACKET_TYPE_SUBSCRIBE_ACK)); + last_byte = g_clients[0].out_buf[g_clients[0].out_len - 1]; + ASSERT_EQ(MQTT_SUBSCRIBE_ACK_CODE_FAILURE, last_byte); + ASSERT_FALSE(g_clients[0].closed); + + MqttBroker_Stop(&broker); + MqttBroker_Free(&broker); +} + +/* Pair: a plain (non-wildcard) filter must still be granted under the + * no-wildcards build. Catches a regression that rejects everything. */ +TEST(broker_no_wildcards_suback_grants_plain_filter) +{ + MqttBroker broker; + MqttBrokerNet net; + int i; + static const byte connect[] = { + 0x10, 0x0D, + 0x00, 0x04, 'M', 'Q', 'T', 'T', + 0x04, 0x02, 0x00, 0x3C, + 0x00, 0x01, 'A' + }; + /* SUBSCRIBE filter "x" (no wildcard). */ + static const byte subscribe_plain[] = { + 0x82, 0x06, + 0x00, 0x07, + 0x00, 0x01, 'x', + 0x00 + }; + int last_byte; + + install_mock_net(&net); + XMEMSET(&broker, 0, sizeof(broker)); + ASSERT_EQ(MQTT_CODE_SUCCESS, MqttBroker_Init(&broker, &net)); + ASSERT_EQ(MQTT_CODE_SUCCESS, MqttBroker_Start(&broker)); + + reset_mock_clients(1); + mock_client_input_append(0, connect, sizeof(connect)); + mock_client_input_append(0, subscribe_plain, sizeof(subscribe_plain)); + for (i = 0; i < 16; i++) { + MqttBroker_Step(&broker); + } + + ASSERT_EQ(1, count_packets_of_type(g_clients[0].out_buf, + g_clients[0].out_len, MQTT_PACKET_TYPE_SUBSCRIBE_ACK)); + last_byte = g_clients[0].out_buf[g_clients[0].out_len - 1]; + ASSERT_EQ(MQTT_SUBSCRIBE_ACK_CODE_SUCCESS_MAX_QOS0, last_byte); + + MqttBroker_Stop(&broker); + MqttBroker_Free(&broker); +} + +#ifdef WOLFMQTT_V5 +/* v5 §3.9.3: Wildcard Subscriptions not supported uses Reason Code + * 0xA2 rather than the generic 0x80 Failure that v3.1.1 returns. The + * broker must surface that distinction so v5 clients receive an + * actionable diagnostic. */ +TEST(broker_no_wildcards_suback_v5_reason_code) +{ + MqttBroker broker; + MqttBrokerNet net; + int i; + /* v5 CONNECT, clean=1, level=5, props_len=0, client_id="A". + * remain = 6 + 1 + 1 + 2 + 1 + 3 = 14. */ + static const byte connect[] = { + 0x10, 0x0E, + 0x00, 0x04, 'M', 'Q', 'T', 'T', + 0x05, + 0x02, + 0x00, 0x3C, + 0x00, + 0x00, 0x01, 'A' + }; + /* v5 SUBSCRIBE filter "+/r": type|flags=0x82, remain = 9 + * (packet_id 2 + props_len 1 + topic 5 + options 1). */ + static const byte subscribe_wild[] = { + 0x82, 0x09, + 0x00, 0x07, + 0x00, + 0x00, 0x03, '+', '/', 'r', + 0x00 + }; + int last_byte; + + install_mock_net(&net); + XMEMSET(&broker, 0, sizeof(broker)); + ASSERT_EQ(MQTT_CODE_SUCCESS, MqttBroker_Init(&broker, &net)); + ASSERT_EQ(MQTT_CODE_SUCCESS, MqttBroker_Start(&broker)); + + reset_mock_clients(1); + mock_client_input_append(0, connect, sizeof(connect)); + mock_client_input_append(0, subscribe_wild, sizeof(subscribe_wild)); + for (i = 0; i < 16; i++) { + MqttBroker_Step(&broker); + } + + ASSERT_EQ(1, count_packets_of_type(g_clients[0].out_buf, + g_clients[0].out_len, MQTT_PACKET_TYPE_SUBSCRIBE_ACK)); + last_byte = g_clients[0].out_buf[g_clients[0].out_len - 1]; + ASSERT_EQ(MQTT_REASON_WILDCARD_SUB_NOT_SUP, last_byte); + + MqttBroker_Stop(&broker); + MqttBroker_Free(&broker); +} +#endif /* WOLFMQTT_V5 */ +#endif /* !WOLFMQTT_BROKER_WILDCARDS */ + /* [MQTT-3.9.3-2] The broker SUBACK helper must reject reserved return * codes before serializing them to the wire. The normal subscribe path * only ever produces values in {0, 1, 2, 0x80}, so this defense is @@ -1833,6 +1985,13 @@ int main(int argc, char** argv) #endif RUN_TEST(broker_suback_reserved_v311_code_rejected); RUN_TEST(broker_suback_valid_v311_failure_code_encoded); +#ifndef WOLFMQTT_BROKER_WILDCARDS + RUN_TEST(broker_no_wildcards_suback_failure_for_wildcard_filter); + RUN_TEST(broker_no_wildcards_suback_grants_plain_filter); +#ifdef WOLFMQTT_V5 + RUN_TEST(broker_no_wildcards_suback_v5_reason_code); +#endif +#endif #ifdef WOLFMQTT_BROKER_RETAINED RUN_TEST(retained_qos_stored_1_sub_1_delivers_qos1); RUN_TEST(retained_qos_stored_2_sub_1_delivers_qos1); diff --git a/tests/test_mqtt_packet.c b/tests/test_mqtt_packet.c index 4fe4526a5..e8e771136 100644 --- a/tests/test_mqtt_packet.c +++ b/tests/test_mqtt_packet.c @@ -2664,6 +2664,121 @@ TEST(decode_subscribe_v5_empty_payload_rejected) } #endif /* WOLFMQTT_V5 */ +/* [MQTT-4.7.3-1] / [MQTT-4.7.1-2] / [MQTT-4.7.1-3] Topic Filter syntax + * pinned at the helper level so the rules stay table-checked across + * future refactors. Every entry maps directly to a spec-cited example. */ +TEST(topic_filter_valid_helper_table) +{ + /* Empty filter is invalid [MQTT-4.7.3-1]. */ + ASSERT_EQ(0, MqttPacket_TopicFilterValid(NULL, 0)); + ASSERT_EQ(0, MqttPacket_TopicFilterValid("", 0)); + + /* '#' alone is the canonical multi-level wildcard. */ + ASSERT_EQ(1, MqttPacket_TopicFilterValid("#", 1)); + ASSERT_EQ(1, MqttPacket_TopicFilterValid("sport/#", 7)); + ASSERT_EQ(1, MqttPacket_TopicFilterValid("sport/tennis/#", 14)); + /* Spec non-normative invalid examples. */ + ASSERT_EQ(0, MqttPacket_TopicFilterValid("sport/tennis#", 13)); + ASSERT_EQ(0, MqttPacket_TopicFilterValid("sport/#/ranking", 15)); + ASSERT_EQ(0, MqttPacket_TopicFilterValid("a#", 2)); + + /* '+' single-level wildcard placement. */ + ASSERT_EQ(1, MqttPacket_TopicFilterValid("+", 1)); + ASSERT_EQ(1, MqttPacket_TopicFilterValid("+/tennis/#", 10)); + ASSERT_EQ(1, MqttPacket_TopicFilterValid("sport/+", 7)); + ASSERT_EQ(1, MqttPacket_TopicFilterValid("sport/+/player1", 15)); + /* Spec non-normative invalid examples. */ + ASSERT_EQ(0, MqttPacket_TopicFilterValid("sport+", 6)); + ASSERT_EQ(0, MqttPacket_TopicFilterValid("sport+/player1", 14)); + ASSERT_EQ(0, MqttPacket_TopicFilterValid("a+b", 3)); + + /* Plain non-wildcard topics. */ + ASSERT_EQ(1, MqttPacket_TopicFilterValid("a", 1)); + ASSERT_EQ(1, MqttPacket_TopicFilterValid("sport/tennis", 12)); +} + +/* [MQTT-4.7.3-1] zero-length Topic Filter rejected by SUBSCRIBE decoder. */ +TEST(decode_subscribe_empty_topic_filter_rejected) +{ + byte rx_buf[] = { + 0x82, 0x05, + 0x00, 0x01, /* packet_id */ + 0x00, 0x00, /* topic_len = 0 */ + 0x00 /* options */ + }; + MqttSubscribe sub; + MqttTopic topic_arr[1]; + int rc; + + XMEMSET(&sub, 0, sizeof(sub)); + XMEMSET(topic_arr, 0, sizeof(topic_arr)); + sub.topics = topic_arr; + rc = MqttDecode_Subscribe(rx_buf, (int)sizeof(rx_buf), &sub); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); +} + +/* [MQTT-4.7.1-2] '#' must be solo or follow '/' and be the last char. */ +TEST(decode_subscribe_bad_hash_placement_rejected) +{ + /* "a#" — '#' embedded in a level. */ + byte rx_buf[] = { + 0x82, 0x07, + 0x00, 0x01, + 0x00, 0x02, 'a', '#', + 0x00 + }; + MqttSubscribe sub; + MqttTopic topic_arr[1]; + int rc; + + XMEMSET(&sub, 0, sizeof(sub)); + XMEMSET(topic_arr, 0, sizeof(topic_arr)); + sub.topics = topic_arr; + rc = MqttDecode_Subscribe(rx_buf, (int)sizeof(rx_buf), &sub); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); +} + +TEST(decode_subscribe_hash_not_last_rejected) +{ + /* "sp/#/r" — '#' is not the final character. */ + byte rx_buf[] = { + 0x82, 0x0B, + 0x00, 0x01, + 0x00, 0x06, 's', 'p', '/', '#', '/', 'r', + 0x00 + }; + MqttSubscribe sub; + MqttTopic topic_arr[1]; + int rc; + + XMEMSET(&sub, 0, sizeof(sub)); + XMEMSET(topic_arr, 0, sizeof(topic_arr)); + sub.topics = topic_arr; + rc = MqttDecode_Subscribe(rx_buf, (int)sizeof(rx_buf), &sub); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); +} + +/* [MQTT-4.7.1-3] '+' must occupy an entire topic level. */ +TEST(decode_subscribe_bad_plus_placement_rejected) +{ + /* "a+b" — '+' embedded in a level. */ + byte rx_buf[] = { + 0x82, 0x08, + 0x00, 0x01, + 0x00, 0x03, 'a', '+', 'b', + 0x00 + }; + MqttSubscribe sub; + MqttTopic topic_arr[1]; + int rc; + + XMEMSET(&sub, 0, sizeof(sub)); + XMEMSET(topic_arr, 0, sizeof(topic_arr)); + sub.topics = topic_arr; + rc = MqttDecode_Subscribe(rx_buf, (int)sizeof(rx_buf), &sub); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); +} + /* [MQTT-2.3.1-1] SUBSCRIBE must carry a non-zero Packet Identifier on the * receive path as well as the transmit path. */ TEST(decode_subscribe_packet_id_zero_rejected) @@ -2865,6 +2980,47 @@ TEST(decode_unsubscribe_v5_empty_payload_rejected) } #endif /* WOLFMQTT_V5 */ +/* UNSUBSCRIBE shares the same Topic Filter syntax rules as SUBSCRIBE + * ([MQTT-4.7.3-1], [MQTT-4.7.1-2], [MQTT-4.7.1-3]). The decoder uses + * the same MqttPacket_TopicFilterValid helper so a single sample per + * rule is enough — exhaustive coverage lives in the helper table test. */ +TEST(decode_unsubscribe_empty_topic_filter_rejected) +{ + byte rx_buf[] = { + 0xA2, 0x04, + 0x00, 0x01, /* packet_id */ + 0x00, 0x00 /* topic_len = 0 */ + }; + MqttUnsubscribe unsub; + MqttTopic topic_arr[1]; + int rc; + + XMEMSET(&unsub, 0, sizeof(unsub)); + XMEMSET(topic_arr, 0, sizeof(topic_arr)); + unsub.topics = topic_arr; + rc = MqttDecode_Unsubscribe(rx_buf, (int)sizeof(rx_buf), &unsub); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); +} + +TEST(decode_unsubscribe_bad_plus_placement_rejected) +{ + /* "a+b" — '+' embedded in a level. */ + byte rx_buf[] = { + 0xA2, 0x07, + 0x00, 0x01, + 0x00, 0x03, 'a', '+', 'b' + }; + MqttUnsubscribe unsub; + MqttTopic topic_arr[1]; + int rc; + + XMEMSET(&unsub, 0, sizeof(unsub)); + XMEMSET(topic_arr, 0, sizeof(topic_arr)); + unsub.topics = topic_arr; + rc = MqttDecode_Unsubscribe(rx_buf, (int)sizeof(rx_buf), &unsub); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); +} + /* [MQTT-2.3.1-1] UNSUBSCRIBE must carry a non-zero Packet Identifier on * the receive path as well as the transmit path. */ TEST(decode_unsubscribe_packet_id_zero_rejected) @@ -4072,6 +4228,11 @@ void run_mqtt_packet_tests(void) RUN_TEST(decode_subscribe_v311_options_reserved_bits_qos1_rejected); RUN_TEST(decode_subscribe_rejects_nul_in_filter); RUN_TEST(decode_subscribe_packet_id_zero_rejected); + RUN_TEST(topic_filter_valid_helper_table); + RUN_TEST(decode_subscribe_empty_topic_filter_rejected); + RUN_TEST(decode_subscribe_bad_hash_placement_rejected); + RUN_TEST(decode_subscribe_hash_not_last_rejected); + RUN_TEST(decode_subscribe_bad_plus_placement_rejected); RUN_TEST(decode_subscribe_empty_payload_rejected); #ifdef WOLFMQTT_V5 RUN_TEST(decode_subscribe_v5_empty_payload_rejected); @@ -4087,6 +4248,8 @@ void run_mqtt_packet_tests(void) RUN_TEST(decode_unsubscribe_rejects_nul_in_filter); RUN_TEST(decode_unsubscribe_packet_id_zero_rejected); RUN_TEST(decode_unsubscribe_empty_payload_rejected); + RUN_TEST(decode_unsubscribe_empty_topic_filter_rejected); + RUN_TEST(decode_unsubscribe_bad_plus_placement_rejected); #ifdef WOLFMQTT_V5 RUN_TEST(decode_unsubscribe_v5_empty_payload_rejected); #endif diff --git a/wolfmqtt/mqtt_packet.h b/wolfmqtt/mqtt_packet.h index 46e429226..e92923a80 100644 --- a/wolfmqtt/mqtt_packet.h +++ b/wolfmqtt/mqtt_packet.h @@ -662,6 +662,28 @@ WOLFMQTT_API int MqttPacket_Read(struct _MqttClient *client, byte* rx_buf, * packet. */ WOLFMQTT_API int MqttPacket_FixedHeaderFlagsValid(byte type_flags); +/*! \brief [MQTT-4.7.1-2] / [MQTT-4.7.1-3] / [MQTT-4.7.3-1] Validate + * an MQTT Topic Filter. Rejects empty filters, '#' that is + * not solo or a final character after '/', and '+' that + * does not occupy an entire topic level. The filter need + * not be NUL-terminated. + * \param filter Pointer to the topic filter bytes. + * \param len Length of the filter in bytes. + * \return 1 if the filter is well-formed, 0 if it is malformed. + */ +WOLFMQTT_API int MqttPacket_TopicFilterValid(const char* filter, word16 len); + +/*! \brief Return non-zero if the Topic Filter contains a wildcard + * ('#' or '+'). Use only on a filter that has already + * passed MqttPacket_TopicFilterValid — wildcard placement + * is not re-validated here. + * \param filter Pointer to the topic filter bytes. + * \param len Length of the filter in bytes. + * \return 1 if a wildcard byte is present, 0 otherwise. + */ +WOLFMQTT_API int MqttPacket_TopicFilterIsWildcard(const char* filter, + word16 len); + /*! \brief [MQTT-3.9.3-2] Validate a SUBACK return code / Reason Code. * \param code The byte to validate. * \param protocol_level MQTT protocol level (4 = v3.1.1, 5 = v5). v3.1.1 From 7b3fe62f4c1337976bc0d5d2d1910081fe8311fc Mon Sep 17 00:00:00 2001 From: Eric Blankenhorn Date: Tue, 5 May 2026 11:37:08 -0500 Subject: [PATCH 25/32] Fix PUBLISH Topic Names validation --- src/mqtt_broker.c | 22 ++--- src/mqtt_packet.c | 64 +++++++++++++- tests/test_mqtt_packet.c | 175 +++++++++++++++++++++++++++++++++++++++ wolfmqtt/mqtt_packet.h | 16 ++++ 4 files changed, 258 insertions(+), 19 deletions(-) diff --git a/src/mqtt_broker.c b/src/mqtt_broker.c index a5ff4ba95..619efbc19 100644 --- a/src/mqtt_broker.c +++ b/src/mqtt_broker.c @@ -3593,23 +3593,11 @@ static int BrokerHandle_Publish(BrokerClient* bc, int rx_len, return rc; } - /* [MQTT-3.3.2-2] PUBLISH topic must not contain wildcard characters. - * Run before any state-mutating logic so a malformed PUBLISH cannot - * briefly populate the QoS 2 dedup set. Set rc and jump to cleanup so - * dispatch closes the connection AND any v5 props/topic allocations - * are freed. */ - if (pub.topic_name && pub.topic_name_len > 0) { - word16 i; - for (i = 0; i < pub.topic_name_len; i++) { - if (pub.topic_name[i] == '+' || pub.topic_name[i] == '#') { - WBLOG_ERR(broker, - "broker: PUBLISH topic contains wildcard sock=%d", - (int)bc->sock); - rc = MQTT_CODE_ERROR_MALFORMED_DATA; - goto publish_cleanup; - } - } - } + /* [MQTT-3.3.2-2] PUBLISH topic name wildcard / [MQTT-4.7.3-1] + * empty-topic checks now live in MqttDecode_Publish via + * MqttPacket_TopicNameValid, which has already returned + * MALFORMED_DATA before reaching this handler. The broker no longer + * needs a per-handler scan. */ /* [MQTT-4.3.3] QoS 2 duplicate detection. If we already PUBREC'd this * packet_id and are still waiting for PUBREL, treat the inbound PUBLISH diff --git a/src/mqtt_packet.c b/src/mqtt_packet.c index 964d67d79..3004183be 100644 --- a/src/mqtt_packet.c +++ b/src/mqtt_packet.c @@ -287,6 +287,33 @@ int MqttPacket_TopicFilterValid(const char* filter, word16 len) return 1; } +/* Validate an MQTT PUBLISH Topic Name against [MQTT-3.3.2-2] / + * [MQTT-4.7.1-1] (Topic Names MUST NOT contain wildcard characters '#' + * or '+'; applies to both v3.1.1 and v5) and [MQTT-4.7.3-1] (minimum + * length one character) — but the latter is gated to v3.1.1 because + * v5 §3.3.2.3.4 explicitly permits a zero-length Topic Name when + * paired with a Topic Alias property. The pairing check (alias must + * be present when the topic is empty) is left to the caller because + * the property block hasn't been decoded yet at the wildcard-scan + * point. NULL topic_name with non-zero len is malformed regardless. */ +int MqttPacket_TopicNameValid(const char* topic_name, word16 len, + byte protocol_level) +{ + word16 i; + if (topic_name == NULL && len > 0) { + return 0; + } + for (i = 0; i < len; i++) { + if (topic_name[i] == '#' || topic_name[i] == '+') { + return 0; + } + } + if (len == 0 && protocol_level < MQTT_CONNECT_PROTOCOL_LEVEL_5) { + return 0; + } + return 1; +} + /* Return 1 if the Topic Filter contains a multi-level ('#') or * single-level ('+') wildcard, 0 otherwise. The decoder has already * validated wildcard *placement* via MqttPacket_TopicFilterValid, so a @@ -1653,12 +1680,29 @@ int MqttEncode_Publish(byte *tx_buf, int tx_buf_len, MqttPublish *publish, } /* MQTT UTF-8 strings are limited to 65535 bytes [MQTT-1.5.3]. Check here * before writing the fixed header so a later MqttEncode_String failure - * cannot corrupt tx_payload via `tx_payload += -1`. */ + * cannot corrupt tx_payload via `tx_payload += -1`. NULL topic_name + * is API misuse (BAD_ARG); callers using v5 Topic Alias must pass + * an empty string "" rather than NULL. */ { - size_t str_len = XSTRLEN(publish->topic_name); + size_t str_len; + byte level = 0; + if (publish->topic_name == NULL) { + return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_BAD_ARG); + } + str_len = XSTRLEN(publish->topic_name); if (str_len > (size_t)0xFFFF) { return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_BAD_ARG); } + #ifdef WOLFMQTT_V5 + level = publish->protocol_level; + #endif + /* [MQTT-3.3.2-2] / [MQTT-4.7.1-1] wildcards always forbidden in + * Topic Names. [MQTT-4.7.3-1] empty Topic Names rejected for + * v3.1.1; allowed for v5 with caller-managed Topic Alias. */ + if (!MqttPacket_TopicNameValid(publish->topic_name, + (word16)str_len, level)) { + return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_MALFORMED_DATA); + } /* Determine packet length */ variable_len = (int)str_len + MQTT_DATA_LEN_SIZE; @@ -1805,6 +1849,22 @@ int MqttDecode_Publish(byte *rx_buf, int rx_buf_len, MqttPublish *publish) if (variable_len + header_len > rx_buf_len) { return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_OUT_OF_BUFFER); } + /* [MQTT-3.3.2-2] / [MQTT-4.7.1-1] Reject Topic Names containing + * wildcards (both versions). [MQTT-4.7.3-1] Reject empty Topic + * Names for v3.1.1; v5 §3.3.2.3.4 permits a zero-length Topic Name + * paired with a Topic Alias property — the alias-empty pairing is + * left to the caller because the property block is decoded later + * in this function. */ + { + byte level = 0; + #ifdef WOLFMQTT_V5 + level = publish->protocol_level; + #endif + if (!MqttPacket_TopicNameValid(publish->topic_name, + publish->topic_name_len, level)) { + return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_MALFORMED_DATA); + } + } rx_payload += variable_len; /* If QoS > 0 then get packet Id */ diff --git a/tests/test_mqtt_packet.c b/tests/test_mqtt_packet.c index e8e771136..a240c9e42 100644 --- a/tests/test_mqtt_packet.c +++ b/tests/test_mqtt_packet.c @@ -892,6 +892,105 @@ TEST(encode_publish_topic_oversized_rejected) ASSERT_TRUE(rc < 0); } +/* Pin the helper truth table for both v3.1.1 (level=0/4) and v5 + * (level=5). Wildcards are forbidden in either version + * ([MQTT-3.3.2-2]); empty is rejected in v3.1.1 ([MQTT-4.7.3-1]) but + * permitted in v5 (§3.3.2.3.4 — empty Topic Name with Topic Alias). */ +TEST(topic_name_valid_helper_table) +{ + byte v311 = MQTT_CONNECT_PROTOCOL_LEVEL_4; + byte v5 = MQTT_CONNECT_PROTOCOL_LEVEL_5; + /* Empty: rejected in v3.1.1, accepted in v5. */ + ASSERT_EQ(0, MqttPacket_TopicNameValid(NULL, 0, v311)); + ASSERT_EQ(0, MqttPacket_TopicNameValid("", 0, v311)); + ASSERT_EQ(1, MqttPacket_TopicNameValid(NULL, 0, v5)); + ASSERT_EQ(1, MqttPacket_TopicNameValid("", 0, v5)); + /* NULL with non-zero len is malformed in any version. */ + ASSERT_EQ(0, MqttPacket_TopicNameValid(NULL, 1, v5)); + /* Plain topics. */ + ASSERT_EQ(1, MqttPacket_TopicNameValid("a", 1, v311)); + ASSERT_EQ(1, MqttPacket_TopicNameValid("sensor/value", 12, v311)); + ASSERT_EQ(1, MqttPacket_TopicNameValid("/", 1, v311)); + /* Wildcards are forbidden in both versions regardless of position. */ + ASSERT_EQ(0, MqttPacket_TopicNameValid("#", 1, v311)); + ASSERT_EQ(0, MqttPacket_TopicNameValid("+", 1, v311)); + ASSERT_EQ(0, MqttPacket_TopicNameValid("sensor/#", 8, v311)); + ASSERT_EQ(0, MqttPacket_TopicNameValid("sensor/+", 8, v311)); + ASSERT_EQ(0, MqttPacket_TopicNameValid("sen+sor", 7, v311)); + ASSERT_EQ(0, MqttPacket_TopicNameValid("sen#sor", 7, v311)); + ASSERT_EQ(0, MqttPacket_TopicNameValid("sensor/#", 8, v5)); + ASSERT_EQ(0, MqttPacket_TopicNameValid("sensor/+", 8, v5)); +} + +/* MqttEncode_Publish must reject a malformed Topic Name before + * serializing the packet; the broker rejects on inbound, so silent + * encoder acceptance produces a packet that strict peers will refuse. */ +TEST(encode_publish_empty_topic_rejected) +{ + byte tx_buf[64]; + MqttPublish pub; + int rc; + + XMEMSET(&pub, 0, sizeof(pub)); + pub.topic_name = ""; + pub.qos = MQTT_QOS_0; + rc = MqttEncode_Publish(tx_buf, (int)sizeof(tx_buf), &pub, 0); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); +} + +TEST(encode_publish_wildcard_topic_rejected) +{ + byte tx_buf[64]; + MqttPublish pub; + int rc; + + XMEMSET(&pub, 0, sizeof(pub)); + pub.topic_name = "sensor/#"; + pub.qos = MQTT_QOS_0; + rc = MqttEncode_Publish(tx_buf, (int)sizeof(tx_buf), &pub, 0); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); + + pub.topic_name = "sensor/+"; + rc = MqttEncode_Publish(tx_buf, (int)sizeof(tx_buf), &pub, 0); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); +} + +/* NULL topic_name is API misuse (separate from a malformed wire + * packet) — surface it as BAD_ARG to match the surrounding NULL-check + * convention in this function. */ +TEST(encode_publish_null_topic_returns_bad_arg) +{ + byte tx_buf[64]; + MqttPublish pub; + int rc; + + XMEMSET(&pub, 0, sizeof(pub)); + pub.topic_name = NULL; + pub.qos = MQTT_QOS_0; + rc = MqttEncode_Publish(tx_buf, (int)sizeof(tx_buf), &pub, 0); + ASSERT_EQ(MQTT_CODE_ERROR_BAD_ARG, rc); +} + +#ifdef WOLFMQTT_V5 +/* MQTT v5 §3.3.2.3.4: a zero-length Topic Name is permitted when paired + * with a Topic Alias property. The encoder must not reject the empty + * topic on the v5 path; the alias-empty pairing is the application's + * responsibility. */ +TEST(encode_publish_v5_empty_topic_accepted) +{ + byte tx_buf[64]; + MqttPublish pub; + int rc; + + XMEMSET(&pub, 0, sizeof(pub)); + pub.protocol_level = MQTT_CONNECT_PROTOCOL_LEVEL_5; + pub.topic_name = ""; + pub.qos = MQTT_QOS_0; + rc = MqttEncode_Publish(tx_buf, (int)sizeof(tx_buf), &pub, 0); + ASSERT_TRUE(rc > 0); +} +#endif /* WOLFMQTT_V5 */ + /* ============================================================================ * MqttDecode_Publish * ============================================================================ */ @@ -995,6 +1094,69 @@ TEST(decode_publish_rejects_nul_in_topic) ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); } +/* [MQTT-4.7.3-1] PUBLISH Topic Name length 0x0000 is malformed. Wire + * shape: PUBLISH | QoS 0, remain=3, topic_len=0, single payload byte. */ +TEST(decode_publish_empty_topic_rejected) +{ + byte buf[] = { 0x30, 0x03, 0x00, 0x00, 'x' }; + MqttPublish pub; + int rc; + + XMEMSET(&pub, 0, sizeof(pub)); + rc = MqttDecode_Publish(buf, (int)sizeof(buf), &pub); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); +} + +/* [MQTT-3.3.2-2] / [MQTT-4.7.1-1] PUBLISH Topic Names MUST NOT contain + * wildcards. Wire encodes "sensor/#" / "sensor/+" as the Topic Name. */ +TEST(decode_publish_wildcard_hash_topic_rejected) +{ + byte buf[] = { + 0x30, 0x0B, + 0x00, 0x08, 's', 'e', 'n', 's', 'o', 'r', '/', '#', + 'x' + }; + MqttPublish pub; + int rc; + + XMEMSET(&pub, 0, sizeof(pub)); + rc = MqttDecode_Publish(buf, (int)sizeof(buf), &pub); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); +} + +TEST(decode_publish_wildcard_plus_topic_rejected) +{ + byte buf[] = { + 0x30, 0x0B, + 0x00, 0x08, 's', 'e', 'n', 's', 'o', 'r', '/', '+', + 'x' + }; + MqttPublish pub; + int rc; + + XMEMSET(&pub, 0, sizeof(pub)); + rc = MqttDecode_Publish(buf, (int)sizeof(buf), &pub); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); +} + +#ifdef WOLFMQTT_V5 +/* MQTT v5 §3.3.2.3.4: a zero-length Topic Name is permitted (paired + * with a Topic Alias property at the application layer). Wire shape: + * PUBLISH | QoS 0, remain=4, topic_len=0, props_len=0, payload "x". */ +TEST(decode_publish_v5_empty_topic_accepted) +{ + byte buf[] = { 0x30, 0x04, 0x00, 0x00, 0x00, 'x' }; + MqttPublish pub; + int rc; + + XMEMSET(&pub, 0, sizeof(pub)); + pub.protocol_level = MQTT_CONNECT_PROTOCOL_LEVEL_5; + rc = MqttDecode_Publish(buf, (int)sizeof(buf), &pub); + ASSERT_TRUE(rc > 0); + ASSERT_EQ(0, pub.topic_name_len); +} +#endif /* WOLFMQTT_V5 */ + /* [MQTT-2.3.1-1] PUBLISH with QoS > 0 must carry a non-zero Packet * Identifier. The encoder rejects packet_id=0 already; this guards the * symmetric decode-side check so a peer cannot smuggle in a malformed @@ -4129,6 +4291,13 @@ void run_mqtt_packet_tests(void) RUN_TEST(encode_publish_qos1_with_dup_accepted); RUN_TEST(encode_publish_qos2_with_dup_accepted); RUN_TEST(encode_publish_topic_oversized_rejected); + RUN_TEST(topic_name_valid_helper_table); + RUN_TEST(encode_publish_empty_topic_rejected); + RUN_TEST(encode_publish_wildcard_topic_rejected); + RUN_TEST(encode_publish_null_topic_returns_bad_arg); +#ifdef WOLFMQTT_V5 + RUN_TEST(encode_publish_v5_empty_topic_accepted); +#endif /* MqttDecode_Publish */ RUN_TEST(decode_publish_qos0_valid); @@ -4136,6 +4305,12 @@ void run_mqtt_packet_tests(void) RUN_TEST(decode_publish_qos0_zero_payload); RUN_TEST(decode_publish_malformed_variable_exceeds_remain); RUN_TEST(decode_publish_rejects_nul_in_topic); + RUN_TEST(decode_publish_empty_topic_rejected); + RUN_TEST(decode_publish_wildcard_hash_topic_rejected); + RUN_TEST(decode_publish_wildcard_plus_topic_rejected); +#ifdef WOLFMQTT_V5 + RUN_TEST(decode_publish_v5_empty_topic_accepted); +#endif RUN_TEST(decode_publish_qos1_packet_id_zero_rejected); RUN_TEST(decode_publish_qos2_packet_id_zero_rejected); RUN_TEST(decode_publish_qos2_packet_id_one_valid); diff --git a/wolfmqtt/mqtt_packet.h b/wolfmqtt/mqtt_packet.h index e92923a80..bbe7b0ae4 100644 --- a/wolfmqtt/mqtt_packet.h +++ b/wolfmqtt/mqtt_packet.h @@ -684,6 +684,22 @@ WOLFMQTT_API int MqttPacket_TopicFilterValid(const char* filter, word16 len); WOLFMQTT_API int MqttPacket_TopicFilterIsWildcard(const char* filter, word16 len); +/*! \brief [MQTT-4.7.3-1] / [MQTT-3.3.2-2] Validate a PUBLISH Topic + * Name. Always rejects topics containing the wildcard + * characters '#' or '+'. Empty Topic Names are rejected + * under v3.1.1 (protocol_level < 5) per [MQTT-4.7.3-1] but + * allowed under v5 (§3.3.2.3.4) because v5 permits a + * zero-length Topic Name when paired with a Topic Alias + * property; the caller is responsible for the alias-empty + * pairing check. + * \param topic_name Pointer to the topic name bytes. + * \param len Length of the topic name in bytes. + * \param protocol_level MQTT protocol level (4 = v3.1.1, 5 = v5). + * \return 1 if well-formed, 0 if malformed. + */ +WOLFMQTT_API int MqttPacket_TopicNameValid(const char* topic_name, + word16 len, byte protocol_level); + /*! \brief [MQTT-3.9.3-2] Validate a SUBACK return code / Reason Code. * \param code The byte to validate. * \param protocol_level MQTT protocol level (4 = v3.1.1, 5 = v5). v3.1.1 From 3e9fae46710c1fcaabb264a2ba25fec24c426150 Mon Sep 17 00:00:00 2001 From: Eric Blankenhorn Date: Tue, 5 May 2026 11:55:25 -0500 Subject: [PATCH 26/32] Fix CONNECT User Name Validation --- tests/test_mqtt_packet.c | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/test_mqtt_packet.c b/tests/test_mqtt_packet.c index a240c9e42..62d67b6e5 100644 --- a/tests/test_mqtt_packet.c +++ b/tests/test_mqtt_packet.c @@ -661,6 +661,32 @@ TEST(decode_connect_invalid_utf8_username) rc = MqttDecode_Connect(rx_buf, (int)sizeof(rx_buf), &dec); ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); } + +/* [MQTT-1.5.3-1] / [MQTT-3.1.3-11]: ill-formed UTF-8 in CONNECT User + * Name MUST cause the receiver to close the connection. The companion + * test above covers a surrogate; this one covers the overlong-encoding + * bucket (C0 AF — the overlong representation of '/'). MqttDecode_String + * routes the field through Utf8WellFormed, which rejects both. */ +TEST(decode_connect_invalid_utf8_username_overlong) +{ + /* User name = C0 AF (2-byte overlong representation of U+002F). + * remain = 10 + 3 + 4 = 17 */ + byte rx_buf[] = { + 0x10, 17, + 0x00, 0x04, 'M', 'Q', 'T', 'T', + 0x04, + 0x82, + 0x00, 0x3C, + 0x00, 0x01, 'c', + 0x00, 0x02, 0xC0, 0xAF + }; + MqttConnect dec; + int rc; + + XMEMSET(&dec, 0, sizeof(dec)); + rc = MqttDecode_Connect(rx_buf, (int)sizeof(rx_buf), &dec); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); +} #endif /* WOLFMQTT_BROKER */ /* ============================================================================ @@ -4276,6 +4302,7 @@ void run_mqtt_packet_tests(void) RUN_TEST(decode_connect_invalid_utf8_clientid_surrogate); RUN_TEST(decode_connect_v311_binary_password); RUN_TEST(decode_connect_invalid_utf8_username); + RUN_TEST(decode_connect_invalid_utf8_username_overlong); #endif RUN_TEST(decode_publish_invalid_utf8_topic); From 31da638e368e68e8b42893ec0003421fc0f6d581 Mon Sep 17 00:00:00 2001 From: Eric Blankenhorn Date: Tue, 5 May 2026 12:26:29 -0500 Subject: [PATCH 27/32] Fix LWT Validation Issues --- src/mqtt_packet.c | 22 +++++-- tests/test_mqtt_packet.c | 127 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 143 insertions(+), 6 deletions(-) diff --git a/src/mqtt_packet.c b/src/mqtt_packet.c index 3004183be..120660518 100644 --- a/src/mqtt_packet.c +++ b/src/mqtt_packet.c @@ -515,12 +515,18 @@ int MqttEncode_Int(byte* buf, word32 len) return MQTT_DATA_INT_SIZE; } -/* [MQTT-1.5.3-1] Validate that the given byte sequence is well-formed UTF-8 - * per RFC 3629, including the surrogate-code-point ban (U+D800..U+DFFF MUST - * NOT be encoded). Returns 1 if valid, 0 if malformed. +/* MQTT 3.1.1 §1.5.3 / v5 §1.5.4: validate that the given byte sequence + * is a well-formed MQTT UTF-8 encoded string. This combines: + * [MQTT-1.5.3-1] RFC 3629 well-formedness (no overlongs, no surrogate + * code points U+D800..U+DFFF, no codepoints above + * U+10FFFF, no lone continuation, no truncated multi- + * byte sequences). + * [MQTT-1.5.3-2] U+0000 (the NUL character) MUST NOT be included in + * any MQTT UTF-8 encoded string. + * Returns 1 if valid, 0 if malformed. * * RFC 3629 byte-pattern table: - * 1-byte: 00..7F -> U+0000..U+007F + * 1-byte: 01..7F -> U+0001..U+007F * 2-byte: C2..DF 80..BF -> U+0080..U+07FF * 3-byte: E0 A0..BF 80..BF -> U+0800..U+0FFF * E1..EC 80..BF 80..BF -> U+1000..U+CFFF @@ -529,8 +535,8 @@ int MqttEncode_Int(byte* buf, word32 len) * 4-byte: F0 90..BF 80..BF 80..BF -> U+10000..U+3FFFF * F1..F3 80..BF 80..BF 80..BF -> U+40000..U+FFFFF * F4 80..8F 80..BF 80..BF -> U+100000..U+10FFFF - * Anything else (overlong, surrogate, > U+10FFFF, lone continuation, - * truncated multi-byte) is malformed. */ + * Note: 0x00 (U+0000) is excluded from the 1-byte range above per + * [MQTT-1.5.3-2]. */ static int Utf8WellFormed(const byte* s, word16 len) { word16 i = 0; @@ -538,6 +544,10 @@ static int Utf8WellFormed(const byte* s, word16 len) byte b0 = s[i]; byte b1, b2, b3; + if (b0 == 0x00) { + /* [MQTT-1.5.3-2] U+0000 forbidden in MQTT UTF-8 strings. */ + return 0; + } if (b0 < 0x80) { i++; continue; diff --git a/tests/test_mqtt_packet.c b/tests/test_mqtt_packet.c index 62d67b6e5..3f212aab1 100644 --- a/tests/test_mqtt_packet.c +++ b/tests/test_mqtt_packet.c @@ -574,6 +574,29 @@ TEST(decode_connect_invalid_utf8_clientid_surrogate) rc = MqttDecode_Connect(rx_buf, (int)sizeof(rx_buf), &dec); ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); } + +/* [MQTT-1.5.3-2] U+0000 is forbidden in every MQTT UTF-8 string field, + * not just Will Topic. Pin client_id as a separate entry point so a + * future regression that bypasses Utf8WellFormed on this field (e.g., + * a bespoke client_id decoder) is caught by CI. */ +TEST(decode_connect_clientid_contains_u0000_rejected) +{ + /* ClientId "a\\0b" (length 3 with U+0000 at position 1). */ + byte rx_buf[] = { + 0x10, 15, + 0x00, 0x04, 'M', 'Q', 'T', 'T', + 0x04, + 0x02, + 0x00, 0x3C, + 0x00, 0x03, 'a', 0x00, 'b' + }; + MqttConnect dec; + int rc; + + XMEMSET(&dec, 0, sizeof(dec)); + rc = MqttDecode_Connect(rx_buf, (int)sizeof(rx_buf), &dec); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); +} #endif /* WOLFMQTT_BROKER */ TEST(decode_publish_invalid_utf8_topic) @@ -1165,6 +1188,24 @@ TEST(decode_publish_wildcard_plus_topic_rejected) ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); } +/* [MQTT-1.5.3-2] U+0000 forbidden in PUBLISH Topic Name as well — broaden + * coverage of the new check to a non-CONNECT entry point. */ +TEST(decode_publish_topic_contains_u0000_rejected) +{ + /* PUBLISH QoS 0, remain=6, topic "a\0b", payload "x". */ + byte buf[] = { + 0x30, 0x06, + 0x00, 0x03, 'a', 0x00, 'b', + 'x' + }; + MqttPublish pub; + int rc; + + XMEMSET(&pub, 0, sizeof(pub)); + rc = MqttDecode_Publish(buf, (int)sizeof(buf), &pub); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); +} + #ifdef WOLFMQTT_V5 /* MQTT v5 §3.3.2.3.4: a zero-length Topic Name is permitted (paired * with a Topic Alias property at the application layer). Wire shape: @@ -2455,6 +2496,31 @@ TEST(decode_connect_rejects_nul_in_will_topic) ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); } +/* [MQTT-3.1.2-11] Will Flag=0 forbids Will Topic and Will Message fields + * in the payload. Wire matches the issue's reproducer: ClientId "cid" + * followed by a complete Will Topic/Message pair after Will Flag is + * cleared. The CONNECT consumed-length check rejects the trailing + * bytes regardless of which fields they look like. */ +TEST(decode_connect_will_flag_zero_with_will_topic_and_message_rejected) +{ + byte buf[] = { + 0x10, 0x21, /* CONNECT, remain_len = 33 */ + 0x00, 0x04, 'M', 'Q', 'T', 'T', + 0x04, + 0x02, /* flags: clean_session only */ + 0x00, 0x3C, + 0x00, 0x03, 'c', 'i', 'd', + 0x00, 0x0A, 'w', 'i', 'l', 'l', '/', 't', 'o', 'p', 'i', 'c', + 0x00, 0x04, 'b', 'o', 'o', 'm' + }; + MqttConnect dec; + int rc; + + XMEMSET(&dec, 0, sizeof(dec)); + rc = MqttDecode_Connect(buf, (int)sizeof(buf), &dec); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); +} + /* Will Flag=0 with will-topic-shaped trailing bytes is the symmetric case * for the LWT half of the payload. Pins the same consumed-length invariant * for the will fields. */ @@ -2566,6 +2632,62 @@ TEST(decode_connect_will_qos3_rejected) ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); } +/* [MQTT-1.5.3-1] / [MQTT-3.1.3-10] CONNECT Will Topic must be a + * well-formed UTF-8 string. Wire flag 0x06 = clean | will_flag, Will + * QoS = 0, ClientId "cid", Will Topic = C0 AF (overlong representation + * of '/'). MqttDecode_String routes the field through Utf8WellFormed + * which catches the malformed encoding before the decoder accepts. */ +TEST(decode_connect_will_topic_invalid_utf8_rejected) +{ + /* remain = 6 + 1 + 1 + 2 + 5 + 4 + 3 = 22 bytes */ + byte buf[] = { + 0x10, 0x16, + 0x00, 0x04, 'M', 'Q', 'T', 'T', + 0x04, + 0x06, /* clean | will_flag */ + 0x00, 0x3C, + 0x00, 0x03, 'c', 'i', 'd', + 0x00, 0x02, 0xC0, 0xAF, /* overlong UTF-8 */ + 0x00, 0x01, 'm' + }; + MqttConnect dec; + MqttMessage lwt; + int rc; + + XMEMSET(&dec, 0, sizeof(dec)); + XMEMSET(&lwt, 0, sizeof(lwt)); + dec.lwt_msg = &lwt; + rc = MqttDecode_Connect(buf, (int)sizeof(buf), &dec); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); +} + +/* [MQTT-1.5.3-2] U+0000 MUST NOT appear in any MQTT UTF-8 encoded + * string. Wire is the issue's reproducer: Will Topic "a\\0b" has a + * length-valid 3-byte string with U+0000 embedded. */ +TEST(decode_connect_will_topic_contains_u0000_rejected) +{ + /* remain = 6 + 1 + 1 + 2 + 5 + 5 + 3 = 23 bytes */ + byte buf[] = { + 0x10, 0x17, + 0x00, 0x04, 'M', 'Q', 'T', 'T', + 0x04, + 0x06, + 0x00, 0x3C, + 0x00, 0x03, 'c', 'i', 'd', + 0x00, 0x03, 'a', 0x00, 'b', /* embedded U+0000 */ + 0x00, 0x01, 'm' + }; + MqttConnect dec; + MqttMessage lwt; + int rc; + + XMEMSET(&dec, 0, sizeof(dec)); + XMEMSET(&lwt, 0, sizeof(lwt)); + dec.lwt_msg = &lwt; + rc = MqttDecode_Connect(buf, (int)sizeof(buf), &dec); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); +} + /* [MQTT-3.1.2-22] If the User Name Flag is 0, the Password Flag MUST be 0. * The encoder already enforces this; the decoder must too. Wire is * flags 0x42 = clean_session | password, with client_id "cid" followed @@ -4299,6 +4421,7 @@ void run_mqtt_packet_tests(void) RUN_TEST(decode_string_utf8_invalid_truncated_4byte); RUN_TEST(decode_string_utf8_invalid_FE_FF); RUN_TEST(decode_connect_invalid_utf8_clientid_overlong); + RUN_TEST(decode_connect_clientid_contains_u0000_rejected); RUN_TEST(decode_connect_invalid_utf8_clientid_surrogate); RUN_TEST(decode_connect_v311_binary_password); RUN_TEST(decode_connect_invalid_utf8_username); @@ -4335,6 +4458,7 @@ void run_mqtt_packet_tests(void) RUN_TEST(decode_publish_empty_topic_rejected); RUN_TEST(decode_publish_wildcard_hash_topic_rejected); RUN_TEST(decode_publish_wildcard_plus_topic_rejected); + RUN_TEST(decode_publish_topic_contains_u0000_rejected); #ifdef WOLFMQTT_V5 RUN_TEST(decode_publish_v5_empty_topic_accepted); #endif @@ -4411,11 +4535,14 @@ void run_mqtt_packet_tests(void) RUN_TEST(decode_connect_rejects_nul_in_will_topic); RUN_TEST(decode_connect_password_flag_zero_with_extra_payload_rejected); RUN_TEST(decode_connect_username_flag_zero_with_extra_payload_rejected); + RUN_TEST(decode_connect_will_flag_zero_with_will_topic_and_message_rejected); RUN_TEST(decode_connect_will_flag_zero_with_extra_payload_rejected); RUN_TEST(decode_connect_reserved_flag_bit_rejected); RUN_TEST(decode_connect_will_qos_with_will_flag_zero_rejected); RUN_TEST(decode_connect_will_retain_with_will_flag_zero_rejected); RUN_TEST(decode_connect_will_qos3_rejected); + RUN_TEST(decode_connect_will_topic_invalid_utf8_rejected); + RUN_TEST(decode_connect_will_topic_contains_u0000_rejected); RUN_TEST(decode_connect_password_flag_without_username_flag_rejected); RUN_TEST(decode_connect_trailing_garbage_rejected); #ifdef WOLFMQTT_V5 From 7e982824000269e84a5cf6efe9d4a839a42e8493 Mon Sep 17 00:00:00 2001 From: Eric Blankenhorn Date: Tue, 5 May 2026 16:50:42 -0500 Subject: [PATCH 28/32] Fixes from review --- src/mqtt_broker.c | 189 +++++++++++++++++++++++++++--------- src/mqtt_packet.c | 21 +++- tests/test_broker_connect.c | 183 +++++++++++++++++++++++++++++++++- tests/test_mqtt_packet.c | 45 +++++++++ wolfmqtt/mqtt_broker.h | 6 ++ 5 files changed, 392 insertions(+), 52 deletions(-) diff --git a/src/mqtt_broker.c b/src/mqtt_broker.c index 619efbc19..cbc9d653d 100644 --- a/src/mqtt_broker.c +++ b/src/mqtt_broker.c @@ -143,18 +143,19 @@ static void MqttBroker_ForceZero(void* mem, word32 len) #endif #ifdef WOLFMQTT_BROKER_AUTH -/* Constant-time string comparison for authentication. +/* Constant-time buffer comparison for authentication. * Iterates exactly cmp_len times so loop duration is independent of * either input's length; cmp_len is a caller-supplied fixed bound * (the credential buffer size). Length mismatch is folded in via the * final XOR. Inputs with length >= cmp_len force a mismatch to prevent - * bypass when both strings match in the first cmp_len bytes but differ - * beyond. Returns 0 if equal, non-zero if different. */ -static int BrokerStrCompare(const char* a, const char* b, int cmp_len) + * bypass when both inputs match in the first cmp_len bytes but differ + * beyond. Caller supplies explicit lengths so binary inputs containing + * embedded NULs (e.g. [MQTT-3.1.3.5] Password) are compared correctly. + * Returns 0 if equal, non-zero if different. */ +static int BrokerBufCompare(const byte* a, int len_a, + const byte* b, int len_b, int cmp_len) { int result = 0; - int len_a = (int)XSTRLEN(a); - int len_b = (int)XSTRLEN(b); int i; for (i = 0; i < cmp_len; i++) { /* Branchless index clamp: when i >= len, reads position 0. @@ -163,7 +164,7 @@ static int BrokerStrCompare(const char* a, const char* b, int cmp_len) unsigned int maskB = 0u - (unsigned int)(i < len_b); int ia = (int)((unsigned int)i & maskA); int ib = (int)((unsigned int)i & maskB); - result |= (a[ia] ^ b[ib]); + result |= ((int)a[ia] ^ (int)b[ib]); } result |= (len_a ^ len_b); /* Force mismatch if either input meets or exceeds cmp_len, since the @@ -172,26 +173,26 @@ static int BrokerStrCompare(const char* a, const char* b, int cmp_len) result |= (len_b >= cmp_len); return result; } + +/* Constant-time C-string comparison wrapper. Both inputs are assumed to + * be NUL-terminated UTF-8 (e.g. configured username, decoded username + * field which is rejected if it contains U+0000). */ +static int BrokerStrCompare(const char* a, const char* b, int cmp_len) +{ + return BrokerBufCompare((const byte*)a, (int)XSTRLEN(a), + (const byte*)b, (int)XSTRLEN(b), cmp_len); +} #endif /* WOLFMQTT_BROKER_AUTH */ /* Store a string of known length into a BrokerClient field. - * Static mode: copies into fixed-size buffer with truncation. The - * sensitive-wipe variant zeros the full max_len buffer, so it is - * unaffected by string contents. - * Dynamic mode: frees old value, allocates new buffer, copies. The - * sensitive-wipe path uses XSTRLEN(buf)+1, which is correct only when - * stored values contain no embedded NUL. The decode-side guards that - * feed this function enforce that invariant: - * - MqttDecode_String rejects U+0000 per [MQTT-1.5.3-2] / - * [MQTT-1.5.4-2] for UTF-8 fields (client_id, username, topics). - * - MqttDecode_Password applies the same NUL check to the CONNECT - * Password field; that field is Binary Data per MQTT-3.1.3.5 and - * the spec does not require it, but wolfMQTT compares passwords - * with XSTRLEN/XSTRCMP, so the broker-side defensive check here - * is what keeps the "no embedded NUL" assumption intact. - * Do not call the dynamic-mode sensitive path with src bytes that - * bypass those decoders without first capping src_len at the first - * NUL, or the wipe will leave trailing bytes uncleared. */ + * Static mode: copies into fixed-size buffer with truncation. + * Dynamic mode: frees old value, allocates new buffer, copies. + * + * The "sensitive" flavor in dynamic mode wipes the previous value via + * XSTRLEN-derived length. That is correct for NUL-terminated UTF-8 + * fields (e.g., username, where [MQTT-1.5.3-2] forbids embedded U+0000) + * but unsafe for binary data. Use BrokerStore_BinarySensitive for fields + * that may contain embedded 0x00 (e.g., [MQTT-3.1.3.5] Password). */ #ifdef WOLFMQTT_STATIC_MEMORY static void BrokerStore_String(char* dst, int max_len, const char* src, word16 src_len) @@ -213,6 +214,20 @@ static void BrokerStore_StringSensitive(char* dst, int max_len, XMEMCPY(dst, src, src_len); dst[src_len] = '\0'; } +/* Binary-data sensitive store. Wipes the destination (full buffer) and + * records the actual stored length so callers don't need XSTRLEN-based + * length recovery. */ +static void BrokerStore_BinarySensitive(char* dst, int max_len, + word16* dst_len_out, const char* src, word16 src_len) +{ + BROKER_FORCE_ZERO(dst, max_len); + if (src_len >= (word16)max_len) { + src_len = (word16)(max_len - 1); + } + XMEMCPY(dst, src, src_len); + dst[src_len] = '\0'; + *dst_len_out = src_len; +} #else static void BrokerStore_String(char** dst_ptr, const char* src, word16 src_len, int sensitive) @@ -230,6 +245,25 @@ static void BrokerStore_String(char** dst_ptr, (*dst_ptr)[src_len] = '\0'; } } +/* Binary-data sensitive store. Wipes the previous value using its + * tracked length (binary-safe — [MQTT-3.1.3.5] Password may contain + * 0x00) before free, then records the new stored length. */ +static void BrokerStore_BinarySensitive(char** dst_ptr, + word16* dst_len_out, const char* src, word16 src_len) +{ + if (*dst_ptr != NULL) { + BROKER_FORCE_ZERO(*dst_ptr, (size_t)(*dst_len_out) + 1); + WOLFMQTT_FREE(*dst_ptr); + *dst_ptr = NULL; + } + *dst_len_out = 0; + *dst_ptr = (char*)WOLFMQTT_MALLOC(src_len + 1); + if (*dst_ptr != NULL) { + XMEMCPY(*dst_ptr, src, src_len); + (*dst_ptr)[src_len] = '\0'; + *dst_len_out = src_len; + } +} #endif /* Wrapper macros to unify static/dynamic calling convention */ @@ -238,11 +272,15 @@ static void BrokerStore_String(char** dst_ptr, BrokerStore_String(dst, maxlen, src, len) #define BROKER_STORE_STR_SENSITIVE(dst, src, len, maxlen) \ BrokerStore_StringSensitive(dst, maxlen, src, len) + #define BROKER_STORE_BIN_SENSITIVE(dst, dst_len, src, len, maxlen) \ + BrokerStore_BinarySensitive(dst, maxlen, &(dst_len), src, len) #else #define BROKER_STORE_STR(dst, src, len, maxlen) \ BrokerStore_String(&(dst), src, len, 0) #define BROKER_STORE_STR_SENSITIVE(dst, src, len, maxlen) \ BrokerStore_String(&(dst), src, len, 1) + #define BROKER_STORE_BIN_SENSITIVE(dst, dst_len, src, len, maxlen) \ + BrokerStore_BinarySensitive(&(dst), &(dst_len), src, len) #endif #if defined(ENABLE_MQTT_TLS) && !defined(WOLFMQTT_BROKER_CUSTOM_NET) @@ -1406,7 +1444,10 @@ static void BrokerClient_Free(BrokerClient* bc) WOLFMQTT_FREE(bc->username); } if (bc->password) { - BROKER_FORCE_ZERO(bc->password, XSTRLEN(bc->password) + 1); + /* Wipe the full stored binary length plus the trailing NUL byte + * appended by BrokerStore_String. password_len reflects the actual + * decoded length, since [MQTT-3.1.3.5] Password may contain 0x00. */ + BROKER_FORCE_ZERO(bc->password, (size_t)bc->password_len + 1); WOLFMQTT_FREE(bc->password); } #endif @@ -3213,6 +3254,7 @@ static int BrokerHandle_Connect(BrokerClient* bc, int rx_len, bc->username[0] = '\0'; bc->password[0] = '\0'; #endif + bc->password_len = 0; if (mc.username) { word16 ulen = 0; if (MqttDecode_Num((byte*)mc.username - MQTT_DATA_LEN_SIZE, @@ -3263,8 +3305,12 @@ static int BrokerHandle_Connect(BrokerClient* bc, int rx_len, goto send_connack; } #endif - BROKER_STORE_STR_SENSITIVE(bc->password, mc.password, plen, - BROKER_MAX_PASSWORD_LEN); + /* [MQTT-3.1.3.5] Password is Binary Data and may legally + * contain 0x00. The binary-sensitive store records the + * actual length in bc->password_len so wipe and compare + * paths don't fall back to XSTRLEN truncation. */ + BROKER_STORE_BIN_SENSITIVE(bc->password, bc->password_len, + mc.password, plen, BROKER_MAX_PASSWORD_LEN); } } #endif /* WOLFMQTT_BROKER_AUTH */ @@ -3297,8 +3343,10 @@ static int BrokerHandle_Connect(BrokerClient* bc, int rx_len, #ifndef WOLFMQTT_STATIC_MEMORY bc->password == NULL || #endif - bc->password[0] == '\0' || - BrokerStrCompare(broker->auth_pass, bc->password, + bc->password_len == 0 || + BrokerBufCompare((const byte*)broker->auth_pass, + (int)XSTRLEN(broker->auth_pass), + (const byte*)bc->password, (int)bc->password_len, BROKER_MAX_PASSWORD_LEN) != 0)) { auth_ok = 0; } @@ -3614,14 +3662,25 @@ static int BrokerHandle_Publish(BrokerClient* bc, int rx_len, else { int add_rc = BrokerInboundQos2_Add(bc, pub.packet_id); if (add_rc != MQTT_CODE_SUCCESS) { - /* Either per-client cap reached or allocation failure. - * Treat as a protocol-level error so dispatch closes the - * connection. Log the underlying rc so operators can tell - * "client misbehaved" from "broker out of memory". */ + /* Distinguish per-client cap reached (OUT_OF_BUFFER) from + * allocator failure (MEMORY) so v5 clients get an + * accurate DISCONNECT reason code, and propagate the + * underlying rc rather than masking it as MALFORMED_DATA + * — server-side resource exhaustion is not a wire-level + * protocol violation. The dispatch's BrokerRcIsFatal + * gate recognizes both codes and closes the connection. */ WBLOG_ERR(broker, "broker: QoS2 inbound add failed sock=%d packet_id=%u " "rc=%d", (int)bc->sock, pub.packet_id, add_rc); - rc = MQTT_CODE_ERROR_MALFORMED_DATA; + #ifdef WOLFMQTT_V5 + if (bc->protocol_level >= MQTT_CONNECT_PROTOCOL_LEVEL_5) { + byte reason = (add_rc == MQTT_CODE_ERROR_OUT_OF_BUFFER) + ? MQTT_REASON_QUOTA_EXCEEDED + : MQTT_REASON_SERVER_BUSY; + (void)BrokerSend_Disconnect(bc, reason); + } + #endif + rc = add_rc; goto publish_cleanup; } } @@ -3720,15 +3779,29 @@ static int BrokerHandle_Publish(BrokerClient* bc, int rx_len, out_pub.props = pub.props; } #endif - rc = MqttEncode_Publish(sub->client->tx_buf, + /* Use a per-subscriber rc: a subscriber's encode/write + * failure (e.g., undersized tx_buf) is a peer-side + * issue and must not be propagated up as the + * publisher's return code, or the publisher would be + * wrongly disconnected by the dispatch's fatal-rc + * gate (especially for QoS 0, where the function- + * level rc is otherwise never overwritten before + * return). */ + int sub_rc = MqttEncode_Publish(sub->client->tx_buf, BROKER_CLIENT_TX_SZ(sub->client), &out_pub, 0); - if (rc > 0) { + if (sub_rc > 0) { WBLOG_DBG(broker, "broker: PUBLISH fwd sock=%d -> sock=%d " "topic=%s qos=%d len=%u", (int)bc->sock, (int)sub->client->sock, topic, eff_qos, (unsigned)pub.total_len); (void)MqttPacket_Write(&sub->client->client, - sub->client->tx_buf, rc); + sub->client->tx_buf, sub_rc); + } + else { + WBLOG_ERR(broker, + "broker: PUBLISH fwd encode failed sock=%d -> " + "sock=%d rc=%d", + (int)bc->sock, (int)sub->client->sock, sub_rc); } } #ifndef WOLFMQTT_STATIC_MEMORY @@ -3861,11 +3934,23 @@ static void BrokerClient_AbnormalClose(MqttBroker* broker, BrokerClient* bc) BrokerClient_Remove(broker, bc); } -/* Decode-level errors that indicate a malformed packet on the wire. */ -static int BrokerRcIsMalformed(int rc) +/* Returns non-zero for return codes that require the broker to close the + * client connection. Includes: + * - Wire-level decode errors (malformed packet, wrong packet type). + * - Packet ID violations: [MQTT-2.3.1-1] requires a non-zero Packet + * Identifier on QoS>0 PUBLISH and on every SUBSCRIBE/UNSUBSCRIBE; + * decoders return MQTT_CODE_ERROR_PACKET_ID for packet_id == 0, and + * [MQTT-4.13]/[MQTT-4.8.0-1] mandate connection close on malformed + * packets. + * - Server-side resource exhaustion (allocator failure, per-client cap + * reached) — the connection must be torn down so resources release. */ +static int BrokerRcIsFatal(int rc) { return (rc == MQTT_CODE_ERROR_MALFORMED_DATA || - rc == MQTT_CODE_ERROR_PACKET_TYPE); + rc == MQTT_CODE_ERROR_PACKET_TYPE || + rc == MQTT_CODE_ERROR_PACKET_ID || + rc == MQTT_CODE_ERROR_MEMORY || + rc == MQTT_CODE_ERROR_OUT_OF_BUFFER); } /* -------------------------------------------------------------------------- */ @@ -3996,7 +4081,7 @@ static int BrokerClient_Process(MqttBroker* broker, BrokerClient* bc) case MQTT_PACKET_TYPE_PUBLISH: { int p_rc = BrokerHandle_Publish(bc, rc, broker); - if (BrokerRcIsMalformed(p_rc)) { + if (BrokerRcIsFatal(p_rc)) { BrokerClient_AbnormalClose(broker, bc); return 0; } @@ -4010,7 +4095,7 @@ static int BrokerClient_Process(MqttBroker* broker, BrokerClient* bc) /* QoS 2 step 2: subscriber sends PUBREC, broker * responds with PUBREL */ int p_rc = BrokerHandle_PublishRec(bc, rc); - if (BrokerRcIsMalformed(p_rc)) { + if (BrokerRcIsFatal(p_rc)) { BrokerClient_AbnormalClose(broker, bc); return 0; } @@ -4021,7 +4106,7 @@ static int BrokerClient_Process(MqttBroker* broker, BrokerClient* bc) /* QoS 2 step 3: publisher sends PUBREL, broker * responds with PUBCOMP */ int p_rc = BrokerHandle_PublishRel(bc, rc); - if (BrokerRcIsMalformed(p_rc)) { + if (BrokerRcIsFatal(p_rc)) { BrokerClient_AbnormalClose(broker, bc); return 0; } @@ -4034,7 +4119,7 @@ static int BrokerClient_Process(MqttBroker* broker, BrokerClient* bc) case MQTT_PACKET_TYPE_SUBSCRIBE: { int s_rc = BrokerHandle_Subscribe(bc, rc, broker); - if (BrokerRcIsMalformed(s_rc)) { + if (BrokerRcIsFatal(s_rc)) { BrokerClient_AbnormalClose(broker, bc); return 0; } @@ -4043,7 +4128,7 @@ static int BrokerClient_Process(MqttBroker* broker, BrokerClient* bc) case MQTT_PACKET_TYPE_UNSUBSCRIBE: { int u_rc = BrokerHandle_Unsubscribe(bc, rc, broker); - if (BrokerRcIsMalformed(u_rc)) { + if (BrokerRcIsFatal(u_rc)) { BrokerClient_AbnormalClose(broker, bc); return 0; } @@ -4084,7 +4169,19 @@ static int BrokerClient_Process(MqttBroker* broker, BrokerClient* bc) BrokerClient_Remove(broker, bc); return 0; default: - break; + /* Unhandled packet type for this broker. Catches v3.1.1 + * clients sending AUTH (type 15, defined only in v5), + * v5 clients sending AUTH (this broker does not + * implement enhanced authentication), and any other + * type the dispatch above does not recognize. The + * pre-dispatch flag check rejects type 0 (RESERVED) + * already; this default closes the connection rather + * than silently no-op'ing the packet. */ + WBLOG_ERR(broker, + "broker: unhandled packet type %u sock=%d", + type, (int)bc->sock); + BrokerClient_AbnormalClose(broker, bc); + return 0; } #ifdef ENABLE_MQTT_WEBSOCKET if (bc->ws_ctx != NULL) { diff --git a/src/mqtt_packet.c b/src/mqtt_packet.c index 120660518..a66cf8658 100644 --- a/src/mqtt_packet.c +++ b/src/mqtt_packet.c @@ -182,7 +182,7 @@ static int MqttEncode_FixedHeader(byte *tx_buf, int tx_buf_len, int remain_len, return header_len; } -/* [MQTT-2.2.2-1] Required fixed-header reserved-flag values per packet type. +/* [MQTT-2.2.2-2] Required fixed-header reserved-flag values per packet type. * PUBLISH (type 3) carries DUP/QoS/RETAIN and is validated separately. */ static int FixedHeaderFlagsExpected(byte type, byte *expected) { @@ -354,9 +354,11 @@ int MqttPacket_FixedHeaderFlagsValid(byte type_flags) if (FixedHeaderFlagsExpected(type, &expected)) { return (flags == expected) ? 1 : 0; } - /* Unknown/reserved type: this helper validates the flag nibble only. - * Callers are responsible for rejecting unknown packet types. */ - return 1; + /* Reserved (type 0) or otherwise unrecognized packet type — reject so + * this helper is safe to use as a protocol-level malformed-packet + * gate. The broker uses it pre-dispatch, so anything it accepts has + * to be a known type. */ + return 0; } static int MqttDecode_FixedHeader(byte *rx_buf, int rx_buf_len, int *remain_len, @@ -1360,7 +1362,16 @@ int MqttDecode_Connect(byte *rx_buf, int rx_buf_len, MqttConnect *mc_connect) * unsupported levels (e.g., 6) — the broker's [MQTT-3.1.2-2] rejection * runs after this function, so we must let the wire decode under the * level the spec actually defines for it (here: nothing, fall through - * to the v3.1.1-shape payload). */ + * to the v3.1.1-shape payload). + * + * Corner case: a peer claiming level 6 but sending a v5-shape wire + * (extra properties-length VBI present) will misparse on the v3.1.1 + * path and the strict tail-consumption check below returns + * MALFORMED_DATA, which the broker translates to a silent socket + * close — CONNACK 0x01 is emitted only when the v3.1.1-shape decode + * succeeds. This is a best-effort spec compliance trade-off; clients + * that misrepresent their protocol level should not expect the broker + * to reverse-engineer the wire shape. */ if (mc_connect->protocol_level == MQTT_CONNECT_PROTOCOL_LEVEL_5) { /* Decode Length of Properties */ if (rx_buf_len < (rx_payload - rx_buf)) { diff --git a/tests/test_broker_connect.c b/tests/test_broker_connect.c index 6e7750365..e9660f42f 100644 --- a/tests/test_broker_connect.c +++ b/tests/test_broker_connect.c @@ -496,6 +496,97 @@ TEST(connect_unsupported_level_127_refused) run_unsupported_level(0x7F); } +#ifdef WOLFMQTT_BROKER_AUTH +/* Regression: [MQTT-3.1.3.5] Password is Binary Data and may legally + * contain 0x00. The broker must not use XSTRLEN-based length recovery on + * bc->password, which would truncate at the first embedded NUL and turn + * the constant-time auth compare into a prefix compare — letting a + * client that sends "abc\0" authenticate against auth_pass + * "abc". The fix tracks bc->password_len explicitly. */ +TEST(connect_v311_binary_password_with_embedded_nul_refused) +{ + MqttBroker broker; + MqttBrokerNet net; + /* CONNECT v3.1.1, username="user", password = "abc\0xyz" (7 bytes, + * embedded NUL at offset 3). Configured auth_pass is "abc"; under + * XSTRLEN truncation, the broker would read bc->password as "abc" + * and authenticate the client. With password_len tracking, the + * length mismatch (3 vs 7) is folded into the compare and auth + * fails. */ + static const byte connect[] = { + 0x10, 29, /* fixed header */ + 0x00, 0x04, 'M', 'Q', 'T', 'T', /* protocol name */ + 0x04, /* level 4 (v3.1.1) */ + 0xC2, /* flags: user+pass+clean */ + 0x00, 0x3C, /* keep-alive 60 */ + 0x00, 0x02, 'i', 'd', /* ClientId "id" */ + 0x00, 0x04, 'u', 's', 'e', 'r', /* Username "user" */ + 0x00, 0x07, 'a', 'b', 'c', 0x00, /* Password binary, */ + 'x', 'y', 'z' /* length 7 */ + }; + + install_mock_net(&net); + XMEMSET(&broker, 0, sizeof(broker)); + ASSERT_EQ(MQTT_CODE_SUCCESS, MqttBroker_Init(&broker, &net)); + broker.auth_user = "user"; + broker.auth_pass = "abc"; + ASSERT_EQ(MQTT_CODE_SUCCESS, MqttBroker_Start(&broker)); + + reset_mock_state(connect, sizeof(connect)); + run_broker_one_connect(&broker); + + /* Auth must fail — CONNACK return code 0x05 (Not Authorized) and the + * connection is closed. Pre-fix, XSTRLEN truncation would let this + * authenticate and emit return code 0x00. */ + ASSERT_TRUE(g_out_len >= 4); + ASSERT_EQ(0x20, g_out_buf[0]); + ASSERT_EQ(0x02, g_out_buf[1]); + ASSERT_EQ(0x00, g_out_buf[2]); + ASSERT_EQ(MQTT_CONNECT_ACK_CODE_REFUSED_BAD_USER_PWD, g_out_buf[3]); + ASSERT_TRUE(g_client_closed); + + MqttBroker_Stop(&broker); + MqttBroker_Free(&broker); +} + +/* Companion: a binary password that exactly matches the configured + * auth_pass bytes still authenticates. Pins that the length-aware + * compare doesn't over-correct and break the equal-length case. */ +TEST(connect_v311_binary_password_exact_match_accepted) +{ + MqttBroker broker; + MqttBrokerNet net; + /* password = "abc" (3 bytes, no embedded NUL); auth_pass = "abc". */ + static const byte connect[] = { + 0x10, 25, + 0x00, 0x04, 'M', 'Q', 'T', 'T', + 0x04, + 0xC2, + 0x00, 0x3C, + 0x00, 0x02, 'i', 'd', + 0x00, 0x04, 'u', 's', 'e', 'r', + 0x00, 0x03, 'a', 'b', 'c' + }; + + install_mock_net(&net); + XMEMSET(&broker, 0, sizeof(broker)); + ASSERT_EQ(MQTT_CODE_SUCCESS, MqttBroker_Init(&broker, &net)); + broker.auth_user = "user"; + broker.auth_pass = "abc"; + ASSERT_EQ(MQTT_CODE_SUCCESS, MqttBroker_Start(&broker)); + + reset_mock_state(connect, sizeof(connect)); + run_broker_one_connect(&broker); + + ASSERT_TRUE(g_out_len >= 4); + ASSERT_EQ(MQTT_CONNECT_ACK_CODE_ACCEPTED, g_out_buf[3]); + ASSERT_FALSE(g_client_closed); + + MqttBroker_Stop(&broker); + MqttBroker_Free(&broker); +} +#endif /* WOLFMQTT_BROKER_AUTH */ + #ifdef WOLFMQTT_V5 /* v5 dropped the [MQTT-3.1.3-8] CleanSession=1-only restriction; an empty * ClientId is acceptable with any Clean Start value. The broker MUST emit @@ -901,7 +992,10 @@ TEST(qos2_inbound_cap_reached_disconnects) reset_mock_clients(1); mock_client_input_append(0, connect_pub, sizeof(connect_pub)); /* Feed BROKER_MAX_INBOUND_QOS2 + 1 distinct in-flight PUBLISHes. The - * first cap accepted; the (cap+1)th must trigger malformed-data close. */ + * first cap accepted; the (cap+1)th must trigger a fatal close. + * v3.1.1 has no DISCONNECT-with-reason path so the broker closes the + * socket directly; v5 clients additionally receive a DISCONNECT + * with MQTT_REASON_QUOTA_EXCEEDED before the close. */ for (i = 1; i <= BROKER_MAX_INBOUND_QOS2 + 1; i++) { size_t n = build_qos2_pub(pub_buf, (word16)i); mock_client_input_append(0, pub_buf, n); @@ -1152,6 +1246,87 @@ TEST(disconnect_v311_nonzero_remain_len_fires_will) } #endif /* !WOLFMQTT_V5 */ +/* The broker switch's default branch must close the connection on any + * unhandled packet type rather than silently no-op'ing. Wire is an + * AUTH packet (type 15) from a v3.1.1 client — AUTH is undefined in + * v3.1.1 and this broker doesn't implement enhanced authentication + * even on v5, so AUTH is always unhandled. The pre-dispatch + * FixedHeaderFlagsValid gate accepts AUTH (it is a defined type 15 + * with required flag nibble 0x0); rejection has to happen at dispatch. */ +TEST(broker_unhandled_packet_type_closes) +{ + MqttBroker broker; + MqttBrokerNet net; + int i; + static const byte connect[] = { + 0x10, 0x0D, + 0x00, 0x04, 'M', 'Q', 'T', 'T', + 0x04, 0x02, 0x00, 0x3C, + 0x00, 0x01, 'A' + }; + /* AUTH (type 15) with empty body. v3.1.1 doesn't define AUTH. */ + static const byte auth[] = { 0xF0, 0x00 }; + + install_mock_net(&net); + XMEMSET(&broker, 0, sizeof(broker)); + ASSERT_EQ(MQTT_CODE_SUCCESS, MqttBroker_Init(&broker, &net)); + ASSERT_EQ(MQTT_CODE_SUCCESS, MqttBroker_Start(&broker)); + + reset_mock_clients(1); + mock_client_input_append(0, connect, sizeof(connect)); + mock_client_input_append(0, auth, sizeof(auth)); + for (i = 0; i < 16; i++) { + MqttBroker_Step(&broker); + } + ASSERT_TRUE(g_client_closed); + + MqttBroker_Stop(&broker); + MqttBroker_Free(&broker); +} + +/* [MQTT-2.3.1-1] / [MQTT-4.13]: a SUBSCRIBE packet with Packet + * Identifier = 0 is malformed and the broker MUST close the connection. + * MqttDecode_Subscribe returns MQTT_CODE_ERROR_PACKET_ID; this test + * pins that BrokerRcIsFatal classifies that rc as fatal so the dispatch + * takes the close path. Without PACKET_ID in the fatal set, the broker + * silently drops the packet and leaves the client connected. */ +TEST(broker_subscribe_packet_id_zero_closes) +{ + MqttBroker broker; + MqttBrokerNet net; + int i; + static const byte connect[] = { + 0x10, 0x0D, + 0x00, 0x04, 'M', 'Q', 'T', 'T', + 0x04, 0x02, 0x00, 0x3C, + 0x00, 0x01, 'S' + }; + /* SUBSCRIBE with packet_id=0x0000 — violates [MQTT-2.3.1-1]. + * Body: packet_id (2) + topic_len (2) + "t" (1) + qos (1) = 6. */ + static const byte sub_pid_zero[] = { + 0x82, 0x06, + 0x00, 0x00, /* packet_id = 0 (illegal) */ + 0x00, 0x01, 't', + 0x00 + }; + + install_mock_net(&net); + XMEMSET(&broker, 0, sizeof(broker)); + ASSERT_EQ(MQTT_CODE_SUCCESS, MqttBroker_Init(&broker, &net)); + ASSERT_EQ(MQTT_CODE_SUCCESS, MqttBroker_Start(&broker)); + + reset_mock_clients(1); + mock_client_input_append(0, connect, sizeof(connect)); + mock_client_input_append(0, sub_pid_zero, sizeof(sub_pid_zero)); + for (i = 0; i < 16; i++) { + MqttBroker_Step(&broker); + } + ASSERT_TRUE(g_client_closed); + + MqttBroker_Stop(&broker); + MqttBroker_Free(&broker); +} + /* MQTT 3.1.1 §3.14.1 / [MQTT-2.2.2-2]: DISCONNECT fixed-header low * nibble MUST be 0000. The broker dispatch enforces this via the * MqttPacket_FixedHeaderFlagsValid pre-check that runs before per-type @@ -1961,6 +2136,10 @@ int main(int argc, char** argv) RUN_TEST(connect_unsupported_level_3_refused); RUN_TEST(connect_unsupported_level_6_refused); RUN_TEST(connect_unsupported_level_127_refused); +#ifdef WOLFMQTT_BROKER_AUTH + RUN_TEST(connect_v311_binary_password_with_embedded_nul_refused); + RUN_TEST(connect_v311_binary_password_exact_match_accepted); +#endif #ifdef WOLFMQTT_V5 RUN_TEST(connect_v5_emptyid_assigned_id_emitted); RUN_TEST(connect_v5_emptyid_clean0_accepted); @@ -1977,6 +2156,8 @@ int main(int argc, char** argv) RUN_TEST(disconnect_v311_nonzero_remain_len_fires_will); #endif RUN_TEST(disconnect_invalid_fixed_header_flags_fires_will); + RUN_TEST(broker_unhandled_packet_type_closes); + RUN_TEST(broker_subscribe_packet_id_zero_closes); RUN_TEST(connack_session_present_set_on_resumed_session); RUN_TEST(connack_session_present_set_on_takeover); RUN_TEST(connack_session_present_clear_on_clean_session_reconnect); diff --git a/tests/test_mqtt_packet.c b/tests/test_mqtt_packet.c index 3f212aab1..79987a7e7 100644 --- a/tests/test_mqtt_packet.c +++ b/tests/test_mqtt_packet.c @@ -3979,6 +3979,49 @@ TEST(fixed_header_flags_valid_canonical_values) ASSERT_EQ(1, MqttPacket_FixedHeaderFlagsValid(0xF0)); /* AUTH (v5) */ } +/* Type 0 (RESERVED) is not a defined MQTT packet type. The helper must + * reject it so callers — including the broker pre-dispatch check — can + * treat any accepted byte as a known type. */ +TEST(fixed_header_flags_valid_reserved_type_rejected) +{ + ASSERT_EQ(0, MqttPacket_FixedHeaderFlagsValid(0x00)); + /* Every flag-nibble combination on the reserved type must be + * rejected — the type itself is the failure, not the nibble. */ + ASSERT_EQ(0, MqttPacket_FixedHeaderFlagsValid(0x01)); + ASSERT_EQ(0, MqttPacket_FixedHeaderFlagsValid(0x0F)); +} + +#ifdef WOLFMQTT_BROKER +/* Reserved-type packet on the wire — broker pre-dispatch must reject + * via the FixedHeaderFlagsValid gate. Exercises the decoder boundary + * separately from the helper unit test above. */ +TEST(decode_fixed_header_reserved_type_rejected) +{ + /* SUBSCRIBE wire shape but type byte set to RESERVED (0x00). + * MqttDecode_Subscribe runs MqttDecode_FixedHeader with the + * expected type SUBSCRIBE, so the type-mismatch path returns + * MQTT_CODE_ERROR_PACKET_TYPE first; that is correct on this + * decoder path. The broker dispatch path uses + * MqttPacket_FixedHeaderFlagsValid directly and is covered by the + * helper test above. */ + byte rx_buf[] = { + 0x00, 0x06, + 0x00, 0x01, + 0x00, 0x01, 'a', + 0x01 + }; + MqttSubscribe sub; + MqttTopic topic_arr[1]; + int rc; + + XMEMSET(&sub, 0, sizeof(sub)); + XMEMSET(topic_arr, 0, sizeof(topic_arr)); + sub.topics = topic_arr; + rc = MqttDecode_Subscribe(rx_buf, (int)sizeof(rx_buf), &sub); + ASSERT_TRUE(rc < 0); +} +#endif /* WOLFMQTT_BROKER */ + TEST(fixed_header_flags_valid_zero_required_rejects_nonzero) { /* Types whose reserved nibble MUST be 0000. Each non-zero permutation @@ -4644,12 +4687,14 @@ void run_mqtt_packet_tests(void) /* Fixed-header reserved-flag validation [MQTT-2.2.2-2] */ RUN_TEST(fixed_header_flags_valid_canonical_values); + RUN_TEST(fixed_header_flags_valid_reserved_type_rejected); RUN_TEST(fixed_header_flags_valid_zero_required_rejects_nonzero); RUN_TEST(fixed_header_flags_valid_two_required_rejects_other); RUN_TEST(fixed_header_flags_valid_publish_qos_and_dup); #ifdef WOLFMQTT_BROKER RUN_TEST(decode_subscribe_invalid_fixed_header_flags); RUN_TEST(decode_unsubscribe_invalid_fixed_header_flags); + RUN_TEST(decode_fixed_header_reserved_type_rejected); #endif RUN_TEST(decode_pubrel_invalid_fixed_header_flags); RUN_TEST(decode_publish_qos3_rejected); diff --git a/wolfmqtt/mqtt_broker.h b/wolfmqtt/mqtt_broker.h index 14232537d..146b676cd 100644 --- a/wolfmqtt/mqtt_broker.h +++ b/wolfmqtt/mqtt_broker.h @@ -247,6 +247,12 @@ typedef struct BrokerClient { WOLFMQTT_BROKER_TIME_T last_rx; byte clean_session; byte connected; /* set after successful CONNECT handshake */ +#ifdef WOLFMQTT_BROKER_AUTH + /* Actual stored length of password bytes. Tracked separately because + * [MQTT-3.1.3.5] defines Password as Binary Data, which may legally + * contain 0x00 — XSTRLEN would truncate at the first embedded NUL. */ + word16 password_len; +#endif #ifdef WOLFMQTT_BROKER_WILL byte has_will; word16 will_payload_len; From 196636656a86d1cd1f713cf6bac2221eb792c382 Mon Sep 17 00:00:00 2001 From: Eric Blankenhorn Date: Wed, 6 May 2026 07:55:23 -0500 Subject: [PATCH 29/32] Fix from review --- src/mqtt_broker.c | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/mqtt_broker.c b/src/mqtt_broker.c index cbc9d653d..b527861fe 100644 --- a/src/mqtt_broker.c +++ b/src/mqtt_broker.c @@ -203,6 +203,7 @@ static void BrokerStore_String(char* dst, int max_len, XMEMCPY(dst, src, src_len); dst[src_len] = '\0'; } +#ifdef WOLFMQTT_BROKER_AUTH static void BrokerStore_StringSensitive(char* dst, int max_len, const char* src, word16 src_len) { @@ -228,6 +229,7 @@ static void BrokerStore_BinarySensitive(char* dst, int max_len, dst[src_len] = '\0'; *dst_len_out = src_len; } +#endif /* WOLFMQTT_BROKER_AUTH */ #else static void BrokerStore_String(char** dst_ptr, const char* src, word16 src_len, int sensitive) @@ -245,6 +247,7 @@ static void BrokerStore_String(char** dst_ptr, (*dst_ptr)[src_len] = '\0'; } } +#ifdef WOLFMQTT_BROKER_AUTH /* Binary-data sensitive store. Wipes the previous value using its * tracked length (binary-safe — [MQTT-3.1.3.5] Password may contain * 0x00) before free, then records the new stored length. */ @@ -264,24 +267,29 @@ static void BrokerStore_BinarySensitive(char** dst_ptr, *dst_len_out = src_len; } } +#endif /* WOLFMQTT_BROKER_AUTH */ #endif /* Wrapper macros to unify static/dynamic calling convention */ #ifdef WOLFMQTT_STATIC_MEMORY #define BROKER_STORE_STR(dst, src, len, maxlen) \ BrokerStore_String(dst, maxlen, src, len) +#ifdef WOLFMQTT_BROKER_AUTH #define BROKER_STORE_STR_SENSITIVE(dst, src, len, maxlen) \ BrokerStore_StringSensitive(dst, maxlen, src, len) #define BROKER_STORE_BIN_SENSITIVE(dst, dst_len, src, len, maxlen) \ BrokerStore_BinarySensitive(dst, maxlen, &(dst_len), src, len) +#endif #else #define BROKER_STORE_STR(dst, src, len, maxlen) \ BrokerStore_String(&(dst), src, len, 0) +#ifdef WOLFMQTT_BROKER_AUTH #define BROKER_STORE_STR_SENSITIVE(dst, src, len, maxlen) \ BrokerStore_String(&(dst), src, len, 1) #define BROKER_STORE_BIN_SENSITIVE(dst, dst_len, src, len, maxlen) \ BrokerStore_BinarySensitive(&(dst), &(dst_len), src, len) #endif +#endif #if defined(ENABLE_MQTT_TLS) && !defined(WOLFMQTT_BROKER_CUSTOM_NET) static int BrokerTls_Init(MqttBroker* broker) From e71c77c18158911a0709610246b984535e391c7d Mon Sep 17 00:00:00 2001 From: Eric Blankenhorn Date: Wed, 6 May 2026 08:14:50 -0500 Subject: [PATCH 30/32] Fix MqttClientNet_DeInit to release pipe --- examples/mqttnet.c | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/examples/mqttnet.c b/examples/mqttnet.c index e4ecb647c..556497bba 100644 --- a/examples/mqttnet.c +++ b/examples/mqttnet.c @@ -1940,6 +1940,8 @@ int MqttClientNet_Init(MqttNet* net, MQTTCtx* mqttCtx) #endif #if defined(WOLFMQTT_MULTITHREAD) && defined(WOLFMQTT_ENABLE_STDIN_CAP) + sockCtx->pfd[0] = SOCKET_INVALID; + sockCtx->pfd[1] = SOCKET_INVALID; /* setup the pipe for waking select() */ if (pipe(sockCtx->pfd) != 0) { PRINTF("Failed to set up pipe for stdin"); @@ -1985,6 +1987,8 @@ int SN_ClientNet_Init(MqttNet* net, MQTTCtx* mqttCtx) #endif #if defined(WOLFMQTT_MULTITHREAD) && defined(WOLFMQTT_ENABLE_STDIN_CAP) + sockCtx->pfd[0] = SOCKET_INVALID; + sockCtx->pfd[1] = SOCKET_INVALID; /* setup the pipe for waking select() */ if (pipe(sockCtx->pfd) != 0) { PRINTF("Failed to set up pipe for stdin"); @@ -2001,6 +2005,17 @@ int MqttClientNet_DeInit(MqttNet* net) { if (net) { if (net->context) { +#if defined(WOLFMQTT_MULTITHREAD) && defined(WOLFMQTT_ENABLE_STDIN_CAP) + SocketContext* sockCtx = (SocketContext*)net->context; + if (sockCtx->pfd[0] != SOCKET_INVALID) { + SOCK_CLOSE(sockCtx->pfd[0]); + sockCtx->pfd[0] = SOCKET_INVALID; + } + if (sockCtx->pfd[1] != SOCKET_INVALID) { + SOCK_CLOSE(sockCtx->pfd[1]); + sockCtx->pfd[1] = SOCKET_INVALID; + } +#endif WOLFMQTT_FREE(net->context); } XMEMSET(net, 0, sizeof(MqttNet)); From dd042233b2694480bb62bf9294a089a6acf0b96e Mon Sep 17 00:00:00 2001 From: Eric Blankenhorn Date: Fri, 8 May 2026 11:39:50 -0500 Subject: [PATCH 31/32] Fixes from review --- src/mqtt_broker.c | 1540 ++++++++++++++++++----------------- src/mqtt_client.c | 76 -- src/mqtt_packet.c | 187 +++-- tests/fuzz/broker_fuzz.c | 2 +- tests/test_broker_connect.c | 54 +- tests/test_mqtt_client.c | 253 +----- tests/test_mqtt_packet.c | 140 ++-- wolfmqtt/mqtt_broker.h | 31 +- wolfmqtt/mqtt_client.h | 22 - wolfmqtt/mqtt_packet.h | 32 +- 10 files changed, 1085 insertions(+), 1252 deletions(-) diff --git a/src/mqtt_broker.c b/src/mqtt_broker.c index b527861fe..d09512a04 100644 --- a/src/mqtt_broker.c +++ b/src/mqtt_broker.c @@ -33,6 +33,10 @@ #include #include +#ifdef ENABLE_MQTT_TLS + #include +#endif + #ifdef WOLFMQTT_BROKER /* Secure memory zeroing - uses volatile pointer to prevent the compiler @@ -249,7 +253,7 @@ static void BrokerStore_String(char** dst_ptr, } #ifdef WOLFMQTT_BROKER_AUTH /* Binary-data sensitive store. Wipes the previous value using its - * tracked length (binary-safe — [MQTT-3.1.3.5] Password may contain + * tracked length (binary-safe - [MQTT-3.1.3.5] Password may contain * 0x00) before free, then records the new stored length. */ static void BrokerStore_BinarySensitive(char** dst_ptr, word16* dst_len_out, const char* src, word16 src_len) @@ -977,7 +981,7 @@ static int callback_broker_mqtt(struct lws *wsi, if (ws->pending_close) { /* Broker-initiated close: BrokerClient_Remove is already on * the call stack (BrokerWsNetDisconnect's spin loop drove us - * here). Signal completion and return — do NOT call + * here). Signal completion and return - do NOT call * BrokerClient_Remove again. */ *bc_ptr = NULL; return 0; @@ -992,7 +996,7 @@ static int callback_broker_mqtt(struct lws *wsi, /* bc is on the call stack inside BrokerClient_Process (a packet * handler triggered a lws_service spin that delivered this CLOSED * callback). Freeing bc now would leave dangling pointers in the - * packet handler — e.g. the fan-out payload pointing into rx_buf, + * packet handler - e.g. the fan-out payload pointing into rx_buf, * or the post-fan-out PUBACK write into tx_buf. Defer the free; * BrokerClient_Process will call BrokerClient_Remove on return. */ ws->pending_remove = 1; @@ -1279,27 +1283,28 @@ static int BrokerNetDisconnect(void* context) /* Returns 1 if packet_id is currently awaiting PUBREL, 0 otherwise. */ static int BrokerInboundQos2_Contains(BrokerClient* bc, word16 packet_id) { +#ifdef WOLFMQTT_STATIC_MEMORY + int i; +#else + BrokerInboundQos2* cur; +#endif + if (bc == NULL || packet_id == 0) { return 0; } #ifdef WOLFMQTT_STATIC_MEMORY - { - int i; - for (i = 0; i < BROKER_MAX_INBOUND_QOS2; i++) { - if (bc->qos2_pending[i] == packet_id) { - return 1; - } + for (i = 0; i < BROKER_MAX_INBOUND_QOS2; i++) { + if (bc->qos2_pending[i] == packet_id) { + return 1; } } #else - { - BrokerInboundQos2* cur = bc->qos2_pending; - while (cur != NULL) { - if (cur->packet_id == packet_id) { - return 1; - } - cur = cur->next; + cur = bc->qos2_pending; + while (cur != NULL) { + if (cur->packet_id == packet_id) { + return 1; } + cur = cur->next; } #endif return 0; @@ -1311,6 +1316,12 @@ static int BrokerInboundQos2_Contains(BrokerClient* bc, word16 packet_id) * a second add of an already-present packet_id is a no-op success. */ static int BrokerInboundQos2_Add(BrokerClient* bc, word16 packet_id) { +#ifdef WOLFMQTT_STATIC_MEMORY + int i; +#else + BrokerInboundQos2 *node; +#endif + if (bc == NULL || packet_id == 0) { return MQTT_CODE_ERROR_BAD_ARG; } @@ -1318,74 +1329,75 @@ static int BrokerInboundQos2_Add(BrokerClient* bc, word16 packet_id) return MQTT_CODE_SUCCESS; } #ifdef WOLFMQTT_STATIC_MEMORY - { - int i; - for (i = 0; i < BROKER_MAX_INBOUND_QOS2; i++) { - if (bc->qos2_pending[i] == 0) { - bc->qos2_pending[i] = packet_id; - return MQTT_CODE_SUCCESS; - } + for (i = 0; i < BROKER_MAX_INBOUND_QOS2; i++) { + if (bc->qos2_pending[i] == 0) { + bc->qos2_pending[i] = packet_id; + return MQTT_CODE_SUCCESS; } - return MQTT_CODE_ERROR_OUT_OF_BUFFER; } + return MQTT_CODE_ERROR_OUT_OF_BUFFER; #else - { - BrokerInboundQos2* node; - /* Enforce the same per-client cap in dynamic-memory builds so a - * misbehaving client cannot grow the list to ~65 535 entries. */ - if (bc->qos2_pending_count >= BROKER_MAX_INBOUND_QOS2) { - return MQTT_CODE_ERROR_OUT_OF_BUFFER; - } - node = (BrokerInboundQos2*)WOLFMQTT_MALLOC(sizeof(*node)); - if (node == NULL) { - return MQTT_CODE_ERROR_MEMORY; - } - node->packet_id = packet_id; - node->next = bc->qos2_pending; - bc->qos2_pending = node; - bc->qos2_pending_count++; - return MQTT_CODE_SUCCESS; + /* Enforce the same per-client cap in dynamic-memory builds so a + * misbehaving client cannot grow the list to ~65 535 entries. */ + if (bc->qos2_pending_count >= BROKER_MAX_INBOUND_QOS2) { + return MQTT_CODE_ERROR_OUT_OF_BUFFER; } + node = (BrokerInboundQos2*)WOLFMQTT_MALLOC(sizeof(*node)); + if (node == NULL) { + return MQTT_CODE_ERROR_MEMORY; + } + node->packet_id = packet_id; + node->next = bc->qos2_pending; + bc->qos2_pending = node; + bc->qos2_pending_count++; + return MQTT_CODE_SUCCESS; #endif } /* Remove packet_id from the awaiting-PUBREL set. No-op if not present. */ static void BrokerInboundQos2_Remove(BrokerClient* bc, word16 packet_id) { +#ifdef WOLFMQTT_STATIC_MEMORY + int i; +#else + BrokerInboundQos2* prev = NULL; + BrokerInboundQos2* cur; +#endif + if (bc == NULL || packet_id == 0) { return; } #ifdef WOLFMQTT_STATIC_MEMORY - { - int i; - for (i = 0; i < BROKER_MAX_INBOUND_QOS2; i++) { - if (bc->qos2_pending[i] == packet_id) { - bc->qos2_pending[i] = 0; - return; - } + for (i = 0; i < BROKER_MAX_INBOUND_QOS2; i++) { + if (bc->qos2_pending[i] == packet_id) { + bc->qos2_pending[i] = 0; + return; } } #else - { - BrokerInboundQos2* prev = NULL; - BrokerInboundQos2* cur = bc->qos2_pending; - while (cur != NULL) { - if (cur->packet_id == packet_id) { - if (prev == NULL) { - bc->qos2_pending = cur->next; - } - else { - prev->next = cur->next; - } - WOLFMQTT_FREE(cur); - if (bc->qos2_pending_count > 0) { - bc->qos2_pending_count--; - } - return; + cur = bc->qos2_pending; + while (cur != NULL) { + if (cur->packet_id == packet_id) { + if (prev == NULL) { + bc->qos2_pending = cur->next; } - prev = cur; - cur = cur->next; + else { + prev->next = cur->next; + } + WOLFMQTT_FREE(cur); + if (bc->qos2_pending_count > 0) { + bc->qos2_pending_count--; + } + else { + /* This should never happen. + * Count should never be negative. */ + WBLOG_ERR(bc->broker, + "broker: qos2_pending_count underflow"); + } + return; } + prev = cur; + cur = cur->next; } #endif } @@ -1393,22 +1405,24 @@ static void BrokerInboundQos2_Remove(BrokerClient* bc, word16 packet_id) /* Clear all entries (called on client free / disconnect). */ static void BrokerInboundQos2_Clear(BrokerClient* bc) { +#ifndef WOLFMQTT_STATIC_MEMORY + BrokerInboundQos2* cur; +#endif + if (bc == NULL) { return; } #ifdef WOLFMQTT_STATIC_MEMORY XMEMSET(bc->qos2_pending, 0, sizeof(bc->qos2_pending)); #else - { - BrokerInboundQos2* cur = bc->qos2_pending; - while (cur != NULL) { - BrokerInboundQos2* next = cur->next; - WOLFMQTT_FREE(cur); - cur = next; - } - bc->qos2_pending = NULL; - bc->qos2_pending_count = 0; + cur = bc->qos2_pending; + while (cur != NULL) { + BrokerInboundQos2* next = cur->next; + WOLFMQTT_FREE(cur); + cur = next; } + bc->qos2_pending = NULL; + bc->qos2_pending_count = 0; #endif } @@ -1484,26 +1498,26 @@ static void BrokerClient_Free(BrokerClient* bc) static BrokerClient* BrokerClient_Add(MqttBroker* broker, BROKER_SOCKET_T sock, int is_tls) { +#ifdef WOLFMQTT_STATIC_MEMORY + int i; +#endif BrokerClient* bc = NULL; int rc = MQTT_CODE_SUCCESS; #ifdef WOLFMQTT_STATIC_MEMORY - { - int i; - for (i = 0; i < BROKER_MAX_CLIENTS; i++) { - if (!broker->clients[i].in_use) { - bc = &broker->clients[i]; - break; - } - } - if (bc == NULL) { - rc = MQTT_CODE_ERROR_MEMORY; - } - if (rc == MQTT_CODE_SUCCESS) { - XMEMSET(bc, 0, sizeof(*bc)); - bc->in_use = 1; + for (i = 0; i < BROKER_MAX_CLIENTS; i++) { + if (!broker->clients[i].in_use) { + bc = &broker->clients[i]; + break; } } + if (bc == NULL) { + rc = MQTT_CODE_ERROR_MEMORY; + } + if (rc == MQTT_CODE_SUCCESS) { + XMEMSET(bc, 0, sizeof(*bc)); + bc->in_use = 1; + } #else bc = (BrokerClient*)WOLFMQTT_MALLOC(sizeof(BrokerClient)); if (bc == NULL) { @@ -1587,27 +1601,29 @@ static BrokerClient* BrokerClient_Add(MqttBroker* broker, static void BrokerClient_Remove(MqttBroker* broker, BrokerClient* bc) { +#ifndef WOLFMQTT_STATIC_MEMORY + BrokerClient* cur; + BrokerClient* prev = NULL; +#endif + if (broker == NULL || bc == NULL) { return; } #ifndef WOLFMQTT_STATIC_MEMORY - { - BrokerClient* cur = broker->clients; - BrokerClient* prev = NULL; - while (cur) { - if (cur == bc) { - if (prev) { - prev->next = cur->next; - } - else { - broker->clients = cur->next; - } - break; + cur = broker->clients; + while (cur) { + if (cur == bc) { + if (prev) { + prev->next = cur->next; } - prev = cur; - cur = cur->next; + else { + broker->clients = cur->next; + } + break; } + prev = cur; + cur = cur->next; } #endif BrokerClient_Free(bc); @@ -1621,9 +1637,14 @@ static void BrokerClient_Remove(MqttBroker* broker, BrokerClient* bc) * Sets client pointer to NULL but keeps the subscription for reconnect. */ static void BrokerSubs_OrphanClient(MqttBroker* broker, BrokerClient* bc) { - int count = 0; #ifdef WOLFMQTT_STATIC_MEMORY int i; +#else + BrokerSub *cur; +#endif + int count = 0; + +#ifdef WOLFMQTT_STATIC_MEMORY for (i = 0; i < BROKER_MAX_SUBS; i++) { if (broker->subs[i].in_use && broker->subs[i].client == bc) { broker->subs[i].client = NULL; @@ -1631,7 +1652,7 @@ static void BrokerSubs_OrphanClient(MqttBroker* broker, BrokerClient* bc) } } #else - BrokerSub* cur = broker->subs; + cur = broker->subs; while (cur) { if (cur->client == bc) { cur->client = NULL; @@ -1650,15 +1671,18 @@ static void BrokerSubs_RemoveClient(MqttBroker* broker, BrokerClient* bc) { #ifdef WOLFMQTT_STATIC_MEMORY int i; +#else + BrokerSub* cur = broker->subs; + BrokerSub* prev = NULL; +#endif + +#ifdef WOLFMQTT_STATIC_MEMORY for (i = 0; i < BROKER_MAX_SUBS; i++) { if (broker->subs[i].in_use && broker->subs[i].client == bc) { XMEMSET(&broker->subs[i], 0, sizeof(BrokerSub)); } } #else - BrokerSub* cur = broker->subs; - BrokerSub* prev = NULL; - while (cur) { BrokerSub* next = cur->next; if (cur->client == bc) { @@ -1687,65 +1711,61 @@ static void BrokerSubs_RemoveClient(MqttBroker* broker, BrokerClient* bc) static int BrokerSubs_Add(MqttBroker* broker, BrokerClient* bc, const char* filter, word16 filter_len, MqttQoS qos) { +#ifdef WOLFMQTT_STATIC_MEMORY + int i; +#else + BrokerSub* cur = broker->subs; +#endif BrokerSub* sub = NULL; int rc = MQTT_CODE_SUCCESS; /* Check for existing subscription to same filter by same client */ #ifdef WOLFMQTT_STATIC_MEMORY - { - int i; - for (i = 0; i < BROKER_MAX_SUBS; i++) { - if (broker->subs[i].in_use && broker->subs[i].client == bc && - (word16)XSTRLEN(broker->subs[i].filter) == filter_len && - XMEMCMP(broker->subs[i].filter, filter, filter_len) == 0) { - broker->subs[i].qos = qos; - WBLOG_INFO(broker, "broker: sub update sock=%d filter=%s qos=%d", - (int)bc->sock, broker->subs[i].filter, qos); - return MQTT_CODE_SUCCESS; - } + for (i = 0; i < BROKER_MAX_SUBS; i++) { + if (broker->subs[i].in_use && broker->subs[i].client == bc && + (word16)XSTRLEN(broker->subs[i].filter) == filter_len && + XMEMCMP(broker->subs[i].filter, filter, filter_len) == 0) { + broker->subs[i].qos = qos; + WBLOG_INFO(broker, "broker: sub update sock=%d filter=%s qos=%d", + (int)bc->sock, broker->subs[i].filter, qos); + return MQTT_CODE_SUCCESS; } } #else - { - BrokerSub* cur = broker->subs; - while (cur) { - if (cur->client == bc && cur->filter != NULL && - (word16)XSTRLEN(cur->filter) == filter_len && - XMEMCMP(cur->filter, filter, filter_len) == 0) { - cur->qos = qos; - WBLOG_INFO(broker, "broker: sub update sock=%d filter=%s qos=%d", - (int)bc->sock, cur->filter, qos); - return MQTT_CODE_SUCCESS; - } - cur = cur->next; + while (cur) { + if (cur->client == bc && cur->filter != NULL && + (word16)XSTRLEN(cur->filter) == filter_len && + XMEMCMP(cur->filter, filter, filter_len) == 0) { + cur->qos = qos; + WBLOG_INFO(broker, "broker: sub update sock=%d filter=%s qos=%d", + (int)bc->sock, cur->filter, qos); + return MQTT_CODE_SUCCESS; } + cur = cur->next; } #endif #ifdef WOLFMQTT_STATIC_MEMORY - { - int i; - for (i = 0; i < BROKER_MAX_SUBS; i++) { - if (!broker->subs[i].in_use) { - sub = &broker->subs[i]; - break; - } - } - if (sub == NULL) { - rc = MQTT_CODE_ERROR_MEMORY; - } - if (rc == MQTT_CODE_SUCCESS) { - if (filter_len >= BROKER_MAX_FILTER_LEN) { - rc = MQTT_CODE_ERROR_OUT_OF_BUFFER; - } + for (i = 0; i < BROKER_MAX_SUBS; i++) { + if (!broker->subs[i].in_use) { + sub = &broker->subs[i]; + break; } - if (rc == MQTT_CODE_SUCCESS) { - XMEMSET(sub, 0, sizeof(*sub)); - sub->in_use = 1; - XMEMCPY(sub->filter, filter, filter_len); - sub->filter[filter_len] = '\0'; + } + if (sub == NULL) { + rc = MQTT_CODE_ERROR_MEMORY; + } + if (rc == MQTT_CODE_SUCCESS) { + if (filter_len >= BROKER_MAX_FILTER_LEN) { + rc = MQTT_CODE_ERROR_OUT_OF_BUFFER; } } + if (rc == MQTT_CODE_SUCCESS) { + XMEMSET(sub, 0, sizeof(*sub)); + sub->in_use = 1; + XMEMCPY(sub->filter, filter, filter_len); + sub->filter[filter_len] = '\0'; + } #else sub = (BrokerSub*)WOLFMQTT_MALLOC(sizeof(BrokerSub)); if (sub == NULL) { @@ -1802,6 +1822,12 @@ static void BrokerSubs_Remove(MqttBroker* broker, BrokerClient* bc, { #ifdef WOLFMQTT_STATIC_MEMORY int i; +#else + BrokerSub *cur; + BrokerSub *prev = NULL; +#endif + +#ifdef WOLFMQTT_STATIC_MEMORY for (i = 0; i < BROKER_MAX_SUBS; i++) { BrokerSub* s = &broker->subs[i]; if (s->in_use && s->client == bc && @@ -1815,9 +1841,7 @@ static void BrokerSubs_Remove(MqttBroker* broker, BrokerClient* bc, } } #else - BrokerSub* cur = broker->subs; - BrokerSub* prev = NULL; - + cur = broker->subs; while (cur) { BrokerSub* next = cur->next; if (cur->client == bc && @@ -1864,31 +1888,32 @@ static word16 BrokerNextPacketId(MqttBroker* broker) static BrokerClient* BrokerClient_FindByClientId(MqttBroker* broker, const char* client_id, BrokerClient* exclude) { +#ifdef WOLFMQTT_STATIC_MEMORY + int i; +#else + BrokerClient* bc; +#endif + if (broker == NULL || client_id == NULL || client_id[0] == '\0') { return NULL; } #ifdef WOLFMQTT_STATIC_MEMORY - { - int i; - for (i = 0; i < BROKER_MAX_CLIENTS; i++) { - BrokerClient* bc = &broker->clients[i]; - if (!bc->in_use) continue; - if (bc != exclude && BROKER_STR_VALID(bc->client_id) && - XSTRCMP(bc->client_id, client_id) == 0) { - return bc; - } + for (i = 0; i < BROKER_MAX_CLIENTS; i++) { + BrokerClient* bc = &broker->clients[i]; + if (!bc->in_use) continue; + if (bc != exclude && BROKER_STR_VALID(bc->client_id) && + XSTRCMP(bc->client_id, client_id) == 0) { + return bc; } } #else - { - BrokerClient* bc = broker->clients; - while (bc) { - if (bc != exclude && BROKER_STR_VALID(bc->client_id) && - XSTRCMP(bc->client_id, client_id) == 0) { - return bc; - } - bc = bc->next; + bc = broker->clients; + while (bc) { + if (bc != exclude && BROKER_STR_VALID(bc->client_id) && + XSTRCMP(bc->client_id, client_id) == 0) { + return bc; } + bc = bc->next; } #endif return NULL; @@ -1900,67 +1925,68 @@ static BrokerClient* BrokerClient_FindByClientId(MqttBroker* broker, static void BrokerSubs_RemoveByClientId(MqttBroker* broker, const char* client_id) { +#ifdef WOLFMQTT_STATIC_MEMORY + int i; +#else + BrokerSub* cur; + BrokerSub* prev = NULL; +#endif + if (broker == NULL || client_id == NULL || client_id[0] == '\0') { return; } #ifdef WOLFMQTT_STATIC_MEMORY - { - int i; - for (i = 0; i < BROKER_MAX_SUBS; i++) { - BrokerSub* s = &broker->subs[i]; - if (!s->in_use) continue; - /* Check active client subs */ - if (s->client != NULL && - s->client->client_id[0] != '\0' && - XSTRCMP(s->client->client_id, client_id) == 0) { - XMEMSET(s, 0, sizeof(BrokerSub)); - } - /* Check orphaned subs (stored client_id) */ - else if (s->client == NULL && - BROKER_STR_VALID(s->client_id) && - XSTRCMP(s->client_id, client_id) == 0) { - XMEMSET(s, 0, sizeof(BrokerSub)); - } + for (i = 0; i < BROKER_MAX_SUBS; i++) { + BrokerSub* s = &broker->subs[i]; + if (!s->in_use) continue; + /* Check active client subs */ + if (s->client != NULL && + s->client->client_id[0] != '\0' && + XSTRCMP(s->client->client_id, client_id) == 0) { + XMEMSET(s, 0, sizeof(BrokerSub)); + } + /* Check orphaned subs (stored client_id) */ + else if (s->client == NULL && + BROKER_STR_VALID(s->client_id) && + XSTRCMP(s->client_id, client_id) == 0) { + XMEMSET(s, 0, sizeof(BrokerSub)); } } #else - { - BrokerSub* cur = broker->subs; - BrokerSub* prev = NULL; - while (cur) { - BrokerSub* next = cur->next; - int remove = 0; - /* Check active client subs */ - if (cur->client != NULL && cur->client->client_id != NULL && - XSTRCMP(cur->client->client_id, client_id) == 0) { - remove = 1; + cur = broker->subs; + while (cur) { + BrokerSub* next = cur->next; + int remove = 0; + /* Check active client subs */ + if (cur->client != NULL && cur->client->client_id != NULL && + XSTRCMP(cur->client->client_id, client_id) == 0) { + remove = 1; + } + /* Check orphaned subs (stored client_id) */ + else if (cur->client == NULL && + BROKER_STR_VALID(cur->client_id) && + XSTRCMP(cur->client_id, client_id) == 0) { + remove = 1; + } + if (remove) { + if (prev) { + prev->next = next; } - /* Check orphaned subs (stored client_id) */ - else if (cur->client == NULL && - BROKER_STR_VALID(cur->client_id) && - XSTRCMP(cur->client_id, client_id) == 0) { - remove = 1; + else { + broker->subs = next; } - if (remove) { - if (prev) { - prev->next = next; - } - else { - broker->subs = next; - } - if (cur->filter) { - WOLFMQTT_FREE(cur->filter); - } - if (cur->client_id) { - WOLFMQTT_FREE(cur->client_id); - } - WOLFMQTT_FREE(cur); + if (cur->filter) { + WOLFMQTT_FREE(cur->filter); } - else { - prev = cur; + if (cur->client_id) { + WOLFMQTT_FREE(cur->client_id); } - cur = next; + WOLFMQTT_FREE(cur); + } + else { + prev = cur; } + cur = next; } #endif } @@ -1972,49 +1998,50 @@ static void BrokerSubs_RemoveByClientId(MqttBroker* broker, static int BrokerSubs_ReassociateClient(MqttBroker* broker, const char* client_id, BrokerClient* new_bc) { +#ifdef WOLFMQTT_STATIC_MEMORY + int i; +#else + BrokerSub* s; +#endif + int count = 0; if (broker == NULL || client_id == NULL || client_id[0] == '\0' || new_bc == NULL) { return 0; } #ifdef WOLFMQTT_STATIC_MEMORY - { - int i; - for (i = 0; i < BROKER_MAX_SUBS; i++) { - BrokerSub* s = &broker->subs[i]; - if (!s->in_use) continue; - /* Check orphaned subs (client=NULL, client_id stored in sub) */ - if (s->client == NULL && BROKER_STR_VALID(s->client_id) && - XSTRCMP(s->client_id, client_id) == 0) { - s->client = new_bc; - count++; - } - /* Check subs with active client (takeover scenario) */ - else if (s->client != NULL && BROKER_STR_VALID(s->client->client_id) && - XSTRCMP(s->client->client_id, client_id) == 0) { - s->client = new_bc; - count++; - } + for (i = 0; i < BROKER_MAX_SUBS; i++) { + BrokerSub* s = &broker->subs[i]; + if (!s->in_use) continue; + /* Check orphaned subs (client=NULL, client_id stored in sub) */ + if (s->client == NULL && BROKER_STR_VALID(s->client_id) && + XSTRCMP(s->client_id, client_id) == 0) { + s->client = new_bc; + count++; + } + /* Check subs with active client (takeover scenario) */ + else if (s->client != NULL && BROKER_STR_VALID(s->client->client_id) && + XSTRCMP(s->client->client_id, client_id) == 0) { + s->client = new_bc; + count++; } } #else - { - BrokerSub* s = broker->subs; - while (s) { - /* Check orphaned subs (client=NULL, client_id stored in sub) */ - if (s->client == NULL && BROKER_STR_VALID(s->client_id) && - XSTRCMP(s->client_id, client_id) == 0) { - s->client = new_bc; - count++; - } - /* Check subs with active client (takeover scenario) */ - else if (s->client != NULL && BROKER_STR_VALID(s->client->client_id) && - XSTRCMP(s->client->client_id, client_id) == 0) { - s->client = new_bc; - count++; - } - s = s->next; + s = broker->subs; + while (s) { + /* Check orphaned subs (client=NULL, client_id stored in sub) */ + if (s->client == NULL && BROKER_STR_VALID(s->client_id) && + XSTRCMP(s->client_id, client_id) == 0) { + s->client = new_bc; + count++; } + /* Check subs with active client (takeover scenario) */ + else if (s->client != NULL && BROKER_STR_VALID(s->client->client_id) && + XSTRCMP(s->client->client_id, client_id) == 0) { + s->client = new_bc; + count++; + } + s = s->next; } #endif if (count > 0) { @@ -2031,120 +2058,122 @@ static int BrokerSubs_ReassociateClient(MqttBroker* broker, static int BrokerRetained_Store(MqttBroker* broker, const char* topic, const byte* payload, word32 payload_len, MqttQoS qos, word32 expiry_sec) { +#ifdef WOLFMQTT_STATIC_MEMORY + int i; +#else + byte is_new = 0; + byte* new_payload = NULL; + BrokerRetainedMsg* cur; +#endif BrokerRetainedMsg* msg = NULL; int rc = MQTT_CODE_SUCCESS; if (broker == NULL || topic == NULL) { return MQTT_CODE_ERROR_BAD_ARG; } +#ifndef WOLFMQTT_STATIC_MEMORY + cur = broker->retained; +#endif #ifdef WOLFMQTT_STATIC_MEMORY - { - int i; - /* Look for existing retained msg on this topic */ + /* Look for existing retained msg on this topic */ + for (i = 0; i < BROKER_MAX_RETAINED; i++) { + if (broker->retained[i].in_use && + XSTRCMP(broker->retained[i].topic, topic) == 0) { + msg = &broker->retained[i]; + break; + } + } + /* If not found, find a free slot */ + if (msg == NULL) { for (i = 0; i < BROKER_MAX_RETAINED; i++) { - if (broker->retained[i].in_use && - XSTRCMP(broker->retained[i].topic, topic) == 0) { + if (!broker->retained[i].in_use) { msg = &broker->retained[i]; break; } } - /* If not found, find a free slot */ - if (msg == NULL) { - for (i = 0; i < BROKER_MAX_RETAINED; i++) { - if (!broker->retained[i].in_use) { - msg = &broker->retained[i]; - break; - } - } - } - if (msg == NULL) { - rc = MQTT_CODE_ERROR_MEMORY; - } + } + if (msg == NULL) { + rc = MQTT_CODE_ERROR_MEMORY; + } + if (rc == MQTT_CODE_SUCCESS) { + int tlen = (int)XSTRLEN(topic); + if (tlen >= BROKER_MAX_TOPIC_LEN) { + rc = MQTT_CODE_ERROR_OUT_OF_BUFFER; + } + else if (payload_len > BROKER_MAX_PAYLOAD_LEN) { + rc = MQTT_CODE_ERROR_OUT_OF_BUFFER; + } if (rc == MQTT_CODE_SUCCESS) { - int tlen = (int)XSTRLEN(topic); - if (tlen >= BROKER_MAX_TOPIC_LEN) { - rc = MQTT_CODE_ERROR_OUT_OF_BUFFER; - } - else if (payload_len > BROKER_MAX_PAYLOAD_LEN) { - rc = MQTT_CODE_ERROR_OUT_OF_BUFFER; - } - if (rc == MQTT_CODE_SUCCESS) { - XMEMSET(msg, 0, sizeof(*msg)); - msg->in_use = 1; - XMEMCPY(msg->topic, topic, (size_t)tlen); - msg->topic[tlen] = '\0'; - if (payload_len > 0 && payload != NULL) { - XMEMCPY(msg->payload, payload, payload_len); - } - msg->payload_len = payload_len; + XMEMSET(msg, 0, sizeof(*msg)); + msg->in_use = 1; + XMEMCPY(msg->topic, topic, (size_t)tlen); + msg->topic[tlen] = '\0'; + if (payload_len > 0 && payload != NULL) { + XMEMCPY(msg->payload, payload, payload_len); } + msg->payload_len = payload_len; } } #else - { - byte is_new = 0; - byte* new_payload = NULL; - BrokerRetainedMsg* cur = broker->retained; - while (cur) { - if (cur->topic != NULL && XSTRCMP(cur->topic, topic) == 0) { - msg = cur; - break; - } - cur = cur->next; + while (cur) { + if (cur->topic != NULL && XSTRCMP(cur->topic, topic) == 0) { + msg = cur; + break; } + cur = cur->next; + } + if (msg == NULL) { + /* Allocate new node + topic */ + int tlen = (int)XSTRLEN(topic); + msg = (BrokerRetainedMsg*)WOLFMQTT_MALLOC( + sizeof(BrokerRetainedMsg)); if (msg == NULL) { - /* Allocate new node + topic */ - int tlen = (int)XSTRLEN(topic); - msg = (BrokerRetainedMsg*)WOLFMQTT_MALLOC( - sizeof(BrokerRetainedMsg)); - if (msg == NULL) { - rc = MQTT_CODE_ERROR_MEMORY; - } - if (rc == MQTT_CODE_SUCCESS) { - XMEMSET(msg, 0, sizeof(*msg)); - msg->topic = (char*)WOLFMQTT_MALLOC((size_t)tlen + 1); - if (msg->topic == NULL) { - WOLFMQTT_FREE(msg); - msg = NULL; - rc = MQTT_CODE_ERROR_MEMORY; - } - } - if (rc == MQTT_CODE_SUCCESS) { - XMEMCPY(msg->topic, topic, (size_t)tlen); - msg->topic[tlen] = '\0'; - is_new = 1; - } + rc = MQTT_CODE_ERROR_MEMORY; } - /* Stage new payload in a temp; only touch the stored message after - * all allocations succeed, so an OOM cannot destroy the prior one. */ - if (rc == MQTT_CODE_SUCCESS && payload_len > 0 && payload != NULL) { - new_payload = (byte*)WOLFMQTT_MALLOC(payload_len); - if (new_payload == NULL) { + if (rc == MQTT_CODE_SUCCESS) { + XMEMSET(msg, 0, sizeof(*msg)); + msg->topic = (char*)WOLFMQTT_MALLOC((size_t)tlen + 1); + if (msg->topic == NULL) { + WOLFMQTT_FREE(msg); + msg = NULL; rc = MQTT_CODE_ERROR_MEMORY; } - else { - XMEMCPY(new_payload, payload, payload_len); - } } if (rc == MQTT_CODE_SUCCESS) { - if (!is_new && msg->payload != NULL) { - WOLFMQTT_FREE(msg->payload); - } - msg->payload = new_payload; - msg->payload_len = payload_len; - if (is_new) { - msg->next = broker->retained; - broker->retained = msg; - } + XMEMCPY(msg->topic, topic, (size_t)tlen); + msg->topic[tlen] = '\0'; + is_new = 1; } - else if (is_new && msg != NULL) { - if (msg->topic) { - WOLFMQTT_FREE(msg->topic); - } - WOLFMQTT_FREE(msg); + } + /* Stage new payload in a temp; only touch the stored message after + * all allocations succeed, so an OOM cannot destroy the prior one. */ + if (rc == MQTT_CODE_SUCCESS && payload_len > 0 && payload != NULL) { + new_payload = (byte*)WOLFMQTT_MALLOC(payload_len); + if (new_payload == NULL) { + rc = MQTT_CODE_ERROR_MEMORY; + } + else { + XMEMCPY(new_payload, payload, payload_len); } } + if (rc == MQTT_CODE_SUCCESS) { + if (!is_new && msg->payload != NULL) { + WOLFMQTT_FREE(msg->payload); + } + msg->payload = new_payload; + msg->payload_len = payload_len; + if (is_new) { + msg->next = broker->retained; + broker->retained = msg; + } + } + else if (is_new && msg != NULL) { + if (msg->topic) { + WOLFMQTT_FREE(msg->topic); + } + WOLFMQTT_FREE(msg); + } #endif if (rc == MQTT_CODE_SUCCESS) { @@ -2160,45 +2189,46 @@ static int BrokerRetained_Store(MqttBroker* broker, const char* topic, static void BrokerRetained_Delete(MqttBroker* broker, const char* topic) { +#ifdef WOLFMQTT_STATIC_MEMORY + int i; +#else + BrokerRetainedMsg* cur; + BrokerRetainedMsg* prev = NULL; +#endif + if (broker == NULL || topic == NULL) { return; } #ifdef WOLFMQTT_STATIC_MEMORY - { - int i; - for (i = 0; i < BROKER_MAX_RETAINED; i++) { - if (broker->retained[i].in_use && - XSTRCMP(broker->retained[i].topic, topic) == 0) { - WBLOG_DBG(broker, "broker: retained delete topic=%s", topic); - XMEMSET(&broker->retained[i], 0, sizeof(BrokerRetainedMsg)); - return; - } + for (i = 0; i < BROKER_MAX_RETAINED; i++) { + if (broker->retained[i].in_use && + XSTRCMP(broker->retained[i].topic, topic) == 0) { + WBLOG_DBG(broker, "broker: retained delete topic=%s", topic); + XMEMSET(&broker->retained[i], 0, sizeof(BrokerRetainedMsg)); + return; } } #else - { - BrokerRetainedMsg* cur = broker->retained; - BrokerRetainedMsg* prev = NULL; - while (cur) { - BrokerRetainedMsg* next = cur->next; - if (cur->topic != NULL && XSTRCMP(cur->topic, topic) == 0) { - WBLOG_DBG(broker, "broker: retained delete topic=%s", topic); - if (prev) { - prev->next = next; - } - else { - broker->retained = next; - } - WOLFMQTT_FREE(cur->topic); - if (cur->payload) { - WOLFMQTT_FREE(cur->payload); - } - WOLFMQTT_FREE(cur); - return; + cur = broker->retained; + while (cur) { + BrokerRetainedMsg* next = cur->next; + if (cur->topic != NULL && XSTRCMP(cur->topic, topic) == 0) { + WBLOG_DBG(broker, "broker: retained delete topic=%s", topic); + if (prev) { + prev->next = next; } - prev = cur; - cur = next; + else { + broker->retained = next; + } + WOLFMQTT_FREE(cur->topic); + if (cur->payload) { + WOLFMQTT_FREE(cur->payload); + } + WOLFMQTT_FREE(cur); + return; } + prev = cur; + cur = next; } #endif } @@ -2206,28 +2236,28 @@ static void BrokerRetained_Delete(MqttBroker* broker, const char* topic) static void BrokerRetained_FreeAll(MqttBroker* broker) { #ifdef WOLFMQTT_STATIC_MEMORY - { - int i; - for (i = 0; i < BROKER_MAX_RETAINED; i++) { - XMEMSET(&broker->retained[i], 0, sizeof(BrokerRetainedMsg)); - } + int i; +#else + BrokerRetainedMsg *cur = broker->retained; +#endif + +#ifdef WOLFMQTT_STATIC_MEMORY + for (i = 0; i < BROKER_MAX_RETAINED; i++) { + XMEMSET(&broker->retained[i], 0, sizeof(BrokerRetainedMsg)); } #else - { - BrokerRetainedMsg* cur = broker->retained; - while (cur) { - BrokerRetainedMsg* next = cur->next; - if (cur->topic) { - WOLFMQTT_FREE(cur->topic); - } - if (cur->payload) { - WOLFMQTT_FREE(cur->payload); - } - WOLFMQTT_FREE(cur); - cur = next; + while (cur) { + BrokerRetainedMsg* next = cur->next; + if (cur->topic) { + WOLFMQTT_FREE(cur->topic); } - broker->retained = NULL; + if (cur->payload) { + WOLFMQTT_FREE(cur->payload); + } + WOLFMQTT_FREE(cur); + cur = next; } + broker->retained = NULL; #endif } #endif /* WOLFMQTT_BROKER_RETAINED */ @@ -2273,46 +2303,47 @@ static void BrokerClient_ClearWill(BrokerClient* bc) /* Add a pending will to be published after delay expires */ static int BrokerPendingWill_Add(MqttBroker* broker, BrokerClient* bc) { +#ifdef WOLFMQTT_STATIC_MEMORY + int i; +#endif + BrokerPendingWill* pw = NULL; WOLFMQTT_BROKER_TIME_T now = WOLFMQTT_BROKER_GET_TIME_S(); int rc = MQTT_CODE_SUCCESS; #ifdef WOLFMQTT_STATIC_MEMORY - { - int i; - for (i = 0; i < BROKER_MAX_PENDING_WILLS; i++) { - if (!broker->pending_wills[i].in_use) { - pw = &broker->pending_wills[i]; - break; - } + for (i = 0; i < BROKER_MAX_PENDING_WILLS; i++) { + if (!broker->pending_wills[i].in_use) { + pw = &broker->pending_wills[i]; + break; } - if (pw == NULL) { - rc = MQTT_CODE_ERROR_MEMORY; + } + if (pw == NULL) { + rc = MQTT_CODE_ERROR_MEMORY; + } + if (rc == MQTT_CODE_SUCCESS) { + int id_len = (int)XSTRLEN(bc->client_id); + int t_len = (int)XSTRLEN(bc->will_topic); + if (id_len >= BROKER_MAX_CLIENT_ID_LEN) { + rc = MQTT_CODE_ERROR_OUT_OF_BUFFER; + } + else if (t_len >= BROKER_MAX_TOPIC_LEN) { + rc = MQTT_CODE_ERROR_OUT_OF_BUFFER; + } + else if (bc->will_payload_len > BROKER_MAX_WILL_PAYLOAD_LEN) { + rc = MQTT_CODE_ERROR_OUT_OF_BUFFER; } if (rc == MQTT_CODE_SUCCESS) { - int id_len = (int)XSTRLEN(bc->client_id); - int t_len = (int)XSTRLEN(bc->will_topic); - if (id_len >= BROKER_MAX_CLIENT_ID_LEN) { - rc = MQTT_CODE_ERROR_OUT_OF_BUFFER; - } - else if (t_len >= BROKER_MAX_TOPIC_LEN) { - rc = MQTT_CODE_ERROR_OUT_OF_BUFFER; - } - else if (bc->will_payload_len > BROKER_MAX_WILL_PAYLOAD_LEN) { - rc = MQTT_CODE_ERROR_OUT_OF_BUFFER; - } - if (rc == MQTT_CODE_SUCCESS) { - XMEMSET(pw, 0, sizeof(*pw)); - pw->in_use = 1; - XMEMCPY(pw->client_id, bc->client_id, id_len); - pw->client_id[id_len] = '\0'; - XMEMCPY(pw->topic, bc->will_topic, t_len); - pw->topic[t_len] = '\0'; - if (bc->will_payload_len > 0) { - XMEMCPY(pw->payload, bc->will_payload, - bc->will_payload_len); - pw->payload_len = bc->will_payload_len; - } + XMEMSET(pw, 0, sizeof(*pw)); + pw->in_use = 1; + XMEMCPY(pw->client_id, bc->client_id, id_len); + pw->client_id[id_len] = '\0'; + XMEMCPY(pw->topic, bc->will_topic, t_len); + pw->topic[t_len] = '\0'; + if (bc->will_payload_len > 0) { + XMEMCPY(pw->payload, bc->will_payload, + bc->will_payload_len); + pw->payload_len = bc->will_payload_len; } } } @@ -2386,82 +2417,85 @@ static int BrokerPendingWill_Add(MqttBroker* broker, BrokerClient* bc) static void BrokerPendingWill_Cancel(MqttBroker* broker, const char* client_id) { +#ifdef WOLFMQTT_STATIC_MEMORY + int i; +#else + BrokerPendingWill* pw; + BrokerPendingWill* prev = NULL; +#endif + if (broker == NULL || client_id == NULL) { return; } #ifdef WOLFMQTT_STATIC_MEMORY - { - int i; - for (i = 0; i < BROKER_MAX_PENDING_WILLS; i++) { - if (broker->pending_wills[i].in_use && - XSTRCMP(broker->pending_wills[i].client_id, client_id) == 0) { - WBLOG_DBG(broker, "broker: will cancelled client_id=%s", client_id); - XMEMSET(&broker->pending_wills[i], 0, - sizeof(BrokerPendingWill)); - return; - } + for (i = 0; i < BROKER_MAX_PENDING_WILLS; i++) { + if (broker->pending_wills[i].in_use && + XSTRCMP(broker->pending_wills[i].client_id, client_id) == 0) { + WBLOG_DBG(broker, "broker: will cancelled client_id=%s", client_id); + XMEMSET(&broker->pending_wills[i], 0, + sizeof(BrokerPendingWill)); + return; } } #else - { - BrokerPendingWill* pw = broker->pending_wills; - BrokerPendingWill* prev = NULL; - while (pw) { - BrokerPendingWill* next = pw->next; - if (pw->client_id != NULL && - XSTRCMP(pw->client_id, client_id) == 0) { - WBLOG_DBG(broker, "broker: will cancelled client_id=%s", client_id); - if (prev) { - prev->next = next; - } - else { - broker->pending_wills = next; - } - WOLFMQTT_FREE(pw->client_id); - if (pw->topic) { - BROKER_FORCE_ZERO(pw->topic, XSTRLEN(pw->topic) + 1); - WOLFMQTT_FREE(pw->topic); - } - if (pw->payload) { - BROKER_FORCE_ZERO(pw->payload, pw->payload_len); - WOLFMQTT_FREE(pw->payload); - } - WOLFMQTT_FREE(pw); - return; + pw = broker->pending_wills; + while (pw) { + BrokerPendingWill* next = pw->next; + if (pw->client_id != NULL && + XSTRCMP(pw->client_id, client_id) == 0) { + WBLOG_DBG(broker, "broker: will cancelled client_id=%s", client_id); + if (prev) { + prev->next = next; } - prev = pw; - pw = next; + else { + broker->pending_wills = next; + } + WOLFMQTT_FREE(pw->client_id); + if (pw->topic) { + BROKER_FORCE_ZERO(pw->topic, XSTRLEN(pw->topic) + 1); + WOLFMQTT_FREE(pw->topic); + } + if (pw->payload) { + BROKER_FORCE_ZERO(pw->payload, pw->payload_len); + WOLFMQTT_FREE(pw->payload); + } + WOLFMQTT_FREE(pw); + return; } + prev = pw; + pw = next; } #endif } static void BrokerPendingWill_FreeAll(MqttBroker* broker) { +#ifndef WOLFMQTT_STATIC_MEMORY + BrokerPendingWill* pw; +#endif + if (broker == NULL) { return; } #ifdef WOLFMQTT_STATIC_MEMORY XMEMSET(broker->pending_wills, 0, sizeof(broker->pending_wills)); #else - { - BrokerPendingWill* pw = broker->pending_wills; - while (pw) { - BrokerPendingWill* next = pw->next; - if (pw->client_id) WOLFMQTT_FREE(pw->client_id); - if (pw->topic) { - BROKER_FORCE_ZERO(pw->topic, XSTRLEN(pw->topic) + 1); - WOLFMQTT_FREE(pw->topic); - } - if (pw->payload) { - BROKER_FORCE_ZERO(pw->payload, pw->payload_len); - WOLFMQTT_FREE(pw->payload); - } - WOLFMQTT_FREE(pw); - pw = next; + pw = broker->pending_wills; + while (pw) { + BrokerPendingWill* next = pw->next; + if (pw->client_id) WOLFMQTT_FREE(pw->client_id); + if (pw->topic) { + BROKER_FORCE_ZERO(pw->topic, XSTRLEN(pw->topic) + 1); + WOLFMQTT_FREE(pw->topic); } - broker->pending_wills = NULL; + if (pw->payload) { + BROKER_FORCE_ZERO(pw->payload, pw->payload_len); + WOLFMQTT_FREE(pw->payload); + } + WOLFMQTT_FREE(pw); + pw = next; } + broker->pending_wills = NULL; #endif } @@ -2472,6 +2506,12 @@ static void BrokerClient_PublishWillImmediate(MqttBroker* broker, /* Process pending wills - publish any that have expired their delay */ static int BrokerPendingWill_Process(MqttBroker* broker) { +#ifdef WOLFMQTT_STATIC_MEMORY + int i; +#else + BrokerPendingWill* pw; + BrokerPendingWill* prev = NULL; +#endif int activity = 0; WOLFMQTT_BROKER_TIME_T now = WOLFMQTT_BROKER_GET_TIME_S(); @@ -2480,59 +2520,53 @@ static int BrokerPendingWill_Process(MqttBroker* broker) } #ifdef WOLFMQTT_STATIC_MEMORY - { - int i; - for (i = 0; i < BROKER_MAX_PENDING_WILLS; i++) { - BrokerPendingWill* pw = &broker->pending_wills[i]; - if (!pw->in_use) { - continue; - } - if (now >= pw->publish_time) { - WBLOG_DBG(broker, "broker: LWT deferred publish client_id=%s topic=%s " - "len=%u", pw->client_id, pw->topic, - (unsigned)pw->payload_len); - BrokerClient_PublishWillImmediate(broker, pw->topic, - pw->payload, pw->payload_len, pw->qos, pw->retain); - XMEMSET(pw, 0, sizeof(BrokerPendingWill)); - activity = 1; - } + for (i = 0; i < BROKER_MAX_PENDING_WILLS; i++) { + BrokerPendingWill* pw = &broker->pending_wills[i]; + if (!pw->in_use) { + continue; + } + if (now >= pw->publish_time) { + WBLOG_DBG(broker, "broker: LWT deferred publish client_id=%s topic=%s " + "len=%u", pw->client_id, pw->topic, + (unsigned)pw->payload_len); + BrokerClient_PublishWillImmediate(broker, pw->topic, + pw->payload, pw->payload_len, pw->qos, pw->retain); + XMEMSET(pw, 0, sizeof(BrokerPendingWill)); + activity = 1; } } #else - { - BrokerPendingWill* pw = broker->pending_wills; - BrokerPendingWill* prev = NULL; - while (pw) { - BrokerPendingWill* next = pw->next; - if (now >= pw->publish_time) { - WBLOG_DBG(broker, "broker: LWT deferred publish client_id=%s topic=%s " - "len=%u", pw->client_id, pw->topic, - (unsigned)pw->payload_len); - BrokerClient_PublishWillImmediate(broker, pw->topic, - pw->payload, pw->payload_len, pw->qos, pw->retain); - if (prev) { - prev->next = next; - } - else { - broker->pending_wills = next; - } - if (pw->client_id) WOLFMQTT_FREE(pw->client_id); - if (pw->topic) { - BROKER_FORCE_ZERO(pw->topic, XSTRLEN(pw->topic) + 1); - WOLFMQTT_FREE(pw->topic); - } - if (pw->payload) { - BROKER_FORCE_ZERO(pw->payload, pw->payload_len); - WOLFMQTT_FREE(pw->payload); - } - WOLFMQTT_FREE(pw); - activity = 1; + pw = broker->pending_wills; + while (pw) { + BrokerPendingWill* next = pw->next; + if (now >= pw->publish_time) { + WBLOG_DBG(broker, "broker: LWT deferred publish client_id=%s topic=%s " + "len=%u", pw->client_id, pw->topic, + (unsigned)pw->payload_len); + BrokerClient_PublishWillImmediate(broker, pw->topic, + pw->payload, pw->payload_len, pw->qos, pw->retain); + if (prev) { + prev->next = next; } else { - prev = pw; + broker->pending_wills = next; } - pw = next; + if (pw->client_id) WOLFMQTT_FREE(pw->client_id); + if (pw->topic) { + BROKER_FORCE_ZERO(pw->topic, XSTRLEN(pw->topic) + 1); + WOLFMQTT_FREE(pw->topic); + } + if (pw->payload) { + BROKER_FORCE_ZERO(pw->payload, pw->payload_len); + WOLFMQTT_FREE(pw->payload); + } + WOLFMQTT_FREE(pw); + activity = 1; } + else { + prev = pw; + } + pw = next; } #endif @@ -2545,6 +2579,12 @@ static void BrokerRetained_DeliverToClient(MqttBroker* broker, BrokerClient* bc, const char* filter, MqttQoS sub_qos) { WOLFMQTT_BROKER_TIME_T now; +#ifdef WOLFMQTT_STATIC_MEMORY + int i; +#else + BrokerRetainedMsg* rm; + BrokerRetainedMsg* rm_prev = NULL; +#endif if (broker == NULL || bc == NULL || filter == NULL) { return; @@ -2552,99 +2592,93 @@ static void BrokerRetained_DeliverToClient(MqttBroker* broker, now = WOLFMQTT_BROKER_GET_TIME_S(); #ifdef WOLFMQTT_STATIC_MEMORY - { - int i; - for (i = 0; i < BROKER_MAX_RETAINED; i++) { - BrokerRetainedMsg* rm = &broker->retained[i]; - if (!rm->in_use || rm->topic[0] == '\0') { - continue; - } - /* Skip expired messages */ - if (rm->expiry_sec > 0 && - (now - rm->store_time) >= rm->expiry_sec) { - WBLOG_DBG(broker, "broker: retained expired topic=%s", rm->topic); - XMEMSET(rm, 0, sizeof(BrokerRetainedMsg)); - continue; + for (i = 0; i < BROKER_MAX_RETAINED; i++) { + BrokerRetainedMsg* rm = &broker->retained[i]; + if (!rm->in_use || rm->topic[0] == '\0') { + continue; + } + /* Skip expired messages */ + if (rm->expiry_sec > 0 && + (now - rm->store_time) >= rm->expiry_sec) { + WBLOG_DBG(broker, "broker: retained expired topic=%s", rm->topic); + XMEMSET(rm, 0, sizeof(BrokerRetainedMsg)); + continue; + } + if (BrokerTopicMatch(filter, rm->topic)) { + MqttPublish out_pub; + MqttQoS eff_qos = (rm->qos < sub_qos) ? rm->qos : sub_qos; + int enc_rc; + XMEMSET(&out_pub, 0, sizeof(out_pub)); + out_pub.topic_name = rm->topic; + out_pub.qos = eff_qos; + out_pub.retain = 1; + out_pub.duplicate = 0; + out_pub.buffer = (rm->payload_len > 0) ? rm->payload : NULL; + out_pub.total_len = rm->payload_len; + if (eff_qos >= MQTT_QOS_1) { + out_pub.packet_id = BrokerNextPacketId(broker); } - if (BrokerTopicMatch(filter, rm->topic)) { - MqttPublish out_pub; - MqttQoS eff_qos = (rm->qos < sub_qos) ? rm->qos : sub_qos; - int enc_rc; - XMEMSET(&out_pub, 0, sizeof(out_pub)); - out_pub.topic_name = rm->topic; - out_pub.qos = eff_qos; - out_pub.retain = 1; - out_pub.duplicate = 0; - out_pub.buffer = (rm->payload_len > 0) ? rm->payload : NULL; - out_pub.total_len = rm->payload_len; - if (eff_qos >= MQTT_QOS_1) { - out_pub.packet_id = BrokerNextPacketId(broker); - } #ifdef WOLFMQTT_V5 - out_pub.protocol_level = bc->protocol_level; -#endif - enc_rc = MqttEncode_Publish(bc->tx_buf, - BROKER_CLIENT_TX_SZ(bc), &out_pub, 0); - if (enc_rc > 0) { - WBLOG_DBG(broker, "broker: retained deliver sock=%d topic=%s " - "len=%u qos=%d", (int)bc->sock, rm->topic, - (unsigned)rm->payload_len, (int)eff_qos); - (void)MqttPacket_Write(&bc->client, bc->tx_buf, enc_rc); - } + out_pub.protocol_level = bc->protocol_level; +#endif + enc_rc = MqttEncode_Publish(bc->tx_buf, + BROKER_CLIENT_TX_SZ(bc), &out_pub, 0); + if (enc_rc > 0) { + WBLOG_DBG(broker, "broker: retained deliver sock=%d topic=%s " + "len=%u qos=%d", (int)bc->sock, rm->topic, + (unsigned)rm->payload_len, (int)eff_qos); + (void)MqttPacket_Write(&bc->client, bc->tx_buf, enc_rc); } } } #else - { - BrokerRetainedMsg* rm = broker->retained; - BrokerRetainedMsg* rm_prev = NULL; - while (rm) { - BrokerRetainedMsg* rm_next = rm->next; - /* Skip and remove expired messages */ - if (rm->expiry_sec > 0 && - (now - rm->store_time) >= rm->expiry_sec) { - WBLOG_DBG(broker, "broker: retained expired topic=%s", rm->topic); - if (rm_prev) { - rm_prev->next = rm_next; - } - else { - broker->retained = rm_next; - } - if (rm->topic) WOLFMQTT_FREE(rm->topic); - if (rm->payload) WOLFMQTT_FREE(rm->payload); - WOLFMQTT_FREE(rm); - rm = rm_next; - continue; + rm = broker->retained; + while (rm) { + BrokerRetainedMsg* rm_next = rm->next; + /* Skip and remove expired messages */ + if (rm->expiry_sec > 0 && + (now - rm->store_time) >= rm->expiry_sec) { + WBLOG_DBG(broker, "broker: retained expired topic=%s", rm->topic); + if (rm_prev) { + rm_prev->next = rm_next; } - if (rm->topic != NULL && BrokerTopicMatch(filter, rm->topic)) { - MqttPublish out_pub; - MqttQoS eff_qos = (rm->qos < sub_qos) ? rm->qos : sub_qos; - int enc_rc; - XMEMSET(&out_pub, 0, sizeof(out_pub)); - out_pub.topic_name = rm->topic; - out_pub.qos = eff_qos; - out_pub.retain = 1; - out_pub.duplicate = 0; - out_pub.buffer = (rm->payload_len > 0) ? rm->payload : NULL; - out_pub.total_len = rm->payload_len; - if (eff_qos >= MQTT_QOS_1) { - out_pub.packet_id = BrokerNextPacketId(broker); - } -#ifdef WOLFMQTT_V5 - out_pub.protocol_level = bc->protocol_level; -#endif - enc_rc = MqttEncode_Publish(bc->tx_buf, - BROKER_CLIENT_TX_SZ(bc), &out_pub, 0); - if (enc_rc > 0) { - WBLOG_DBG(broker, "broker: retained deliver sock=%d topic=%s " - "len=%u qos=%d", (int)bc->sock, rm->topic, - (unsigned)rm->payload_len, (int)eff_qos); - (void)MqttPacket_Write(&bc->client, bc->tx_buf, enc_rc); - } + else { + broker->retained = rm_next; } - rm_prev = rm; + if (rm->topic) WOLFMQTT_FREE(rm->topic); + if (rm->payload) WOLFMQTT_FREE(rm->payload); + WOLFMQTT_FREE(rm); rm = rm_next; + continue; + } + if (rm->topic != NULL && BrokerTopicMatch(filter, rm->topic)) { + MqttPublish out_pub; + MqttQoS eff_qos = (rm->qos < sub_qos) ? rm->qos : sub_qos; + int enc_rc; + XMEMSET(&out_pub, 0, sizeof(out_pub)); + out_pub.topic_name = rm->topic; + out_pub.qos = eff_qos; + out_pub.retain = 1; + out_pub.duplicate = 0; + out_pub.buffer = (rm->payload_len > 0) ? rm->payload : NULL; + out_pub.total_len = rm->payload_len; + if (eff_qos >= MQTT_QOS_1) { + out_pub.packet_id = BrokerNextPacketId(broker); + } +#ifdef WOLFMQTT_V5 + out_pub.protocol_level = bc->protocol_level; +#endif + enc_rc = MqttEncode_Publish(bc->tx_buf, + BROKER_CLIENT_TX_SZ(bc), &out_pub, 0); + if (enc_rc > 0) { + WBLOG_DBG(broker, "broker: retained deliver sock=%d topic=%s " + "len=%u qos=%d", (int)bc->sock, rm->topic, + (unsigned)rm->payload_len, (int)eff_qos); + (void)MqttPacket_Write(&bc->client, bc->tx_buf, enc_rc); + } } + rm_prev = rm; + rm = rm_next; } #endif } @@ -2683,6 +2717,12 @@ static void BrokerClient_PublishWillImmediate(MqttBroker* broker, const char* topic, const byte* payload, word16 payload_len, MqttQoS qos, byte retain) { +#ifdef WOLFMQTT_STATIC_MEMORY + int i; +#else + BrokerSub* sub; +#endif + if (broker == NULL || topic == NULL) { return; } @@ -2704,47 +2744,43 @@ static void BrokerClient_PublishWillImmediate(MqttBroker* broker, /* Fan out to matching subscribers */ #ifdef WOLFMQTT_STATIC_MEMORY - { - int i; - for (i = 0; i < BROKER_MAX_SUBS; i++) { - BrokerSub* sub = &broker->subs[i]; - if (!sub->in_use) continue; + for (i = 0; i < BROKER_MAX_SUBS; i++) { + BrokerSub* sub = &broker->subs[i]; + if (!sub->in_use) continue; #else - { - BrokerSub* sub = broker->subs; - while (sub) { -#endif - if (sub->client != NULL && sub->client->protocol_level != 0 && - BROKER_STR_VALID(sub->filter) && - BrokerTopicMatch(sub->filter, topic)) { - MqttPublish out_pub; - MqttQoS eff_qos; - int enc_rc; - XMEMSET(&out_pub, 0, sizeof(out_pub)); - out_pub.topic_name = (char*)topic; - eff_qos = (qos < sub->qos) ? qos : sub->qos; - out_pub.qos = eff_qos; - out_pub.retain = 0; - out_pub.duplicate = 0; - out_pub.buffer = (payload_len > 0) ? (byte*)payload : NULL; - out_pub.total_len = payload_len; - if (eff_qos >= MQTT_QOS_1) { - out_pub.packet_id = BrokerNextPacketId(broker); - } + sub = broker->subs; + while (sub) { +#endif + if (sub->client != NULL && sub->client->protocol_level != 0 && + BROKER_STR_VALID(sub->filter) && + BrokerTopicMatch(sub->filter, topic)) { + MqttPublish out_pub; + MqttQoS eff_qos; + int enc_rc; + XMEMSET(&out_pub, 0, sizeof(out_pub)); + out_pub.topic_name = (char*)topic; + eff_qos = (qos < sub->qos) ? qos : sub->qos; + out_pub.qos = eff_qos; + out_pub.retain = 0; + out_pub.duplicate = 0; + out_pub.buffer = (payload_len > 0) ? (byte*)payload : NULL; + out_pub.total_len = payload_len; + if (eff_qos >= MQTT_QOS_1) { + out_pub.packet_id = BrokerNextPacketId(broker); + } #ifdef WOLFMQTT_V5 - out_pub.protocol_level = sub->client->protocol_level; + out_pub.protocol_level = sub->client->protocol_level; #endif - enc_rc = MqttEncode_Publish(sub->client->tx_buf, - BROKER_CLIENT_TX_SZ(sub->client), &out_pub, 0); - if (enc_rc > 0) { - (void)MqttPacket_Write(&sub->client->client, - sub->client->tx_buf, enc_rc); - } + enc_rc = MqttEncode_Publish(sub->client->tx_buf, + BROKER_CLIENT_TX_SZ(sub->client), &out_pub, 0); + if (enc_rc > 0) { + (void)MqttPacket_Write(&sub->client->client, + sub->client->tx_buf, enc_rc); } + } #ifndef WOLFMQTT_STATIC_MEMORY - sub = sub->next; + sub = sub->next; #endif - } } } #endif /* WOLFMQTT_BROKER_WILL */ @@ -2830,19 +2866,13 @@ static int BrokerSend_PingResp(BrokerClient* bc) return MqttPacket_Write(&bc->client, bc->tx_buf, 2); } -/* Not WOLFMQTT_API: kept internal but external linkage so the broker - * unit-test harness can call it directly to exercise the - * [MQTT-3.9.3-2] reserved-code rejection branch. The prior prototype - * silences -Wmissing-prototypes; the symbol is intentionally not in - * any public header. */ -int BrokerSend_SubAck(BrokerClient* bc, word16 packet_id, - const byte* return_codes, int return_code_count); int BrokerSend_SubAck(BrokerClient* bc, word16 packet_id, const byte* return_codes, int return_code_count) { int remain_len; int pos = 0; int i; + int i_chk; if (bc == NULL || return_codes == NULL || return_code_count <= 0) { return MQTT_CODE_ERROR_BAD_ARG; @@ -2850,20 +2880,17 @@ int BrokerSend_SubAck(BrokerClient* bc, word16 packet_id, /* [MQTT-3.9.3-2] Refuse to serialize a reserved SUBACK return code. * The normal broker subscribe path produces only spec-allowed - * values, but this helper is the final boundary — a future caller + * values, but this helper is the final boundary - a future caller * passing a reserved value should fail loudly here rather than emit * a malformed SUBACK on the wire. */ - { - int i_chk; - for (i_chk = 0; i_chk < return_code_count; i_chk++) { - if (!MqttPacket_SubAckReturnCodeValid(return_codes[i_chk], - bc->protocol_level)) { - WBLOG_ERR(bc->broker, - "broker: SUBACK reserved return code 0x%02X sock=%d " - "[MQTT-3.9.3-2]", - return_codes[i_chk], (int)bc->sock); - return MQTT_CODE_ERROR_MALFORMED_DATA; - } + for (i_chk = 0; i_chk < return_code_count; i_chk++) { + if (!MqttPacket_SubAckReturnCodeValid(return_codes[i_chk], + bc->protocol_level)) { + WBLOG_ERR(bc->broker, + "broker: SUBACK reserved return code 0x%02X sock=%d " + "[MQTT-3.9.3-2]", + return_codes[i_chk], (int)bc->sock); + return MQTT_CODE_ERROR_MALFORMED_DATA; } } @@ -2956,7 +2983,7 @@ static int BrokerHandle_Connect(BrokerClient* bc, int rx_len, * subscriptions across disconnects today (BrokerSubs_OrphanClient * keeps them, but per-message QoS 2 state in bc->qos2_in_flight is * dropped). If broker session persistence ever widens to cover QoS - * state, the source of session_present needs to widen too — a + * state, the source of session_present needs to widen too - a * BrokerSession_HasStoredState() helper is the natural extension * point. */ int session_present = 0; @@ -3093,26 +3120,28 @@ static int BrokerHandle_Connect(BrokerClient* bc, int rx_len, * (emitted in the v5 CONNACK construction below); v3.1.1 has no such * field, so the assignment is server-internal. */ if (id_len == 0 && !BROKER_STR_VALID(bc->client_id)) { - /* "auto-" + at least 8 hex chars + NUL. On a 16-bit-int platform the - * full 32-bit counter prints as more than 8 nibbles, so size for - * headroom rather than the minimum. The counter advances on every - * empty-ID CONNECT that reaches this point — including connects that - * are later refused (e.g., auth failure below) — so it is not a - * count of accepted clients. */ - char auto_id[32]; - int auto_len; - unsigned long id_value = (unsigned long)broker->next_auto_id++; + /* "auto-" + 8 hex chars + NUL. The counter advances on every empty-ID + * CONNECT that reaches this point - including connects that are later + * refused (e.g., auth failure below) - so it is not a count of + * accepted clients. */ + static const char hex_digits[] = "0123456789abcdef"; + char auto_id[14]; + const word16 auto_len = 13; + word32 id_value = broker->next_auto_id++; + int i; if (broker->next_auto_id == 0) { /* Skip 0 on wrap for stylistic consistency with next_packet_id; * unlike packet IDs, 0 has no protocol significance here. */ broker->next_auto_id = 1; } - auto_len = XSNPRINTF(auto_id, (int)sizeof(auto_id), - "auto-%08lx", id_value); - if (auto_len > 0) { - BROKER_STORE_STR(bc->client_id, auto_id, (word16)auto_len, - BROKER_MAX_CLIENT_ID_LEN); + XMEMCPY(auto_id, "auto-", 5); + for (i = 7; i >= 0; i--) { + auto_id[5 + i] = hex_digits[id_value & 0xF]; + id_value >>= 4; } + auto_id[auto_len] = '\0'; + BROKER_STORE_STR(bc->client_id, auto_id, auto_len, + BROKER_MAX_CLIENT_ID_LEN); if (!BROKER_STR_VALID(bc->client_id)) { /* Storage failed (e.g., WOLFMQTT_MALLOC returned NULL in the * dynamic-memory path). Refuse rather than proceeding with an @@ -3327,7 +3356,7 @@ static int BrokerHandle_Connect(BrokerClient* bc, int rx_len, * accepted CleanSession=0 connection finds stored session state, * Session Present MUST be 1; otherwise it MUST be 0. The flag is * cleared again below for any path that overrides return_code to a - * non-zero refusal — [MQTT-3.2.2-4] requires Session Present=0 on a + * non-zero refusal - [MQTT-3.2.2-4] requires Session Present=0 on a * refused CONNACK. */ ack.flags = session_present ? MQTT_CONNECT_ACK_FLAG_SESSION_PRESENT : 0; ack.return_code = MQTT_CONNECT_ACK_CODE_ACCEPTED; @@ -3499,13 +3528,13 @@ static int BrokerHandle_Subscribe(BrokerClient* bc, int rx_len, int sub_rc = MQTT_CODE_SUCCESS; byte fail_code = MQTT_SUBSCRIBE_ACK_CODE_FAILURE; #ifndef WOLFMQTT_BROKER_WILDCARDS - /* [MQTT-3.8.3-2] (v3.1.1 §3.8.3): when the server does not + /* [MQTT-3.8.3-2] (v3.1.1 section 3.8.3): when the server does not * support wildcard subscriptions it MUST reject any * Subscription request whose filter contains a wildcard. - * v5 §3.2.2.3.20 advertises this via the Wildcard - * Subscription Available property and §3.9.3 reserves + * v5 section 3.2.2.3.20 advertises this via the Wildcard + * Subscription Available property and section 3.9.3 reserves * reason code 0xA2 (Wildcard Subscriptions not supported) - * specifically for this case — use it on v5 connections so + * specifically for this case - use it on v5 connections so * the client gets the actionable diagnostic the spec * defines. The decoder already validated Topic Filter * syntax via MqttPacket_TopicFilterValid, so any '#' or @@ -3674,7 +3703,7 @@ static int BrokerHandle_Publish(BrokerClient* bc, int rx_len, * allocator failure (MEMORY) so v5 clients get an * accurate DISCONNECT reason code, and propagate the * underlying rc rather than masking it as MALFORMED_DATA - * — server-side resource exhaustion is not a wire-level + * - server-side resource exhaustion is not a wire-level * protocol violation. The dispatch's BrokerRcIsFatal * gate recognizes both codes and closes the connection. */ WBLOG_ERR(broker, @@ -3712,13 +3741,13 @@ static int BrokerHandle_Publish(BrokerClient* bc, int rx_len, } #endif } - /* Use payload pointer directly from decoded packet — rx_buf is not + /* Use payload pointer directly from decoded packet - rx_buf is not * modified during fan-out (each subscriber encodes into their own * tx_buf), so this pointer remains valid throughout. */ payload = pub.buffer; #ifdef WOLFMQTT_BROKER_RETAINED - /* Handle retained messages — skipped for QoS 2 duplicates: the original + /* Handle retained messages - skipped for QoS 2 duplicates: the original * PUBLISH already updated the retained store. */ if (!qos2_duplicate && topic != NULL && pub.retain) { if (pub.total_len == 0) { @@ -3751,71 +3780,73 @@ static int BrokerHandle_Publish(BrokerClient* bc, int rx_len, * the application message from the original PUBLISH ([MQTT-4.3.3]). */ if (!qos2_duplicate && topic != NULL && (payload != NULL || pub.total_len == 0)) { +#ifdef WOLFMQTT_STATIC_MEMORY + int i; +#else + BrokerSub* sub = broker->subs; +#endif /* Fan out to matching subscribers */ #ifdef WOLFMQTT_STATIC_MEMORY - { - int i; - for (i = 0; i < BROKER_MAX_SUBS; i++) { - BrokerSub* sub = &broker->subs[i]; - if (!sub->in_use) continue; + for (i = 0; i < BROKER_MAX_SUBS; i++) { + BrokerSub* sub = &broker->subs[i]; + if (!sub->in_use) continue; #else - { - BrokerSub* sub = broker->subs; - while (sub) { -#endif - if (sub->client != NULL && - sub->client->protocol_level != 0 && - BROKER_STR_VALID(sub->filter) && - BrokerTopicMatch(sub->filter, topic)) { - MqttPublish out_pub; - MqttQoS eff_qos; - XMEMSET(&out_pub, 0, sizeof(out_pub)); - out_pub.topic_name = topic; - eff_qos = (pub.qos < sub->qos) ? pub.qos : sub->qos; - out_pub.qos = eff_qos; - if (eff_qos >= MQTT_QOS_1) { - out_pub.packet_id = BrokerNextPacketId(broker); - } - out_pub.retain = 0; - out_pub.duplicate = 0; - out_pub.buffer = payload; - out_pub.total_len = pub.total_len; + while (sub) { +#endif + if (sub->client != NULL && + sub->client->protocol_level != 0 && + BROKER_STR_VALID(sub->filter) && + BrokerTopicMatch(sub->filter, topic)) { + int sub_rc; + MqttPublish out_pub; + MqttQoS eff_qos; + XMEMSET(&out_pub, 0, sizeof(out_pub)); + out_pub.topic_name = topic; + eff_qos = (pub.qos < sub->qos) ? pub.qos : sub->qos; + out_pub.qos = eff_qos; + if (eff_qos >= MQTT_QOS_1) { + out_pub.packet_id = BrokerNextPacketId(broker); + } + out_pub.retain = 0; + out_pub.duplicate = 0; + out_pub.buffer = payload; + out_pub.total_len = pub.total_len; #ifdef WOLFMQTT_V5 - out_pub.protocol_level = sub->client->protocol_level; - if (sub->client->protocol_level >= - MQTT_CONNECT_PROTOCOL_LEVEL_5) { - out_pub.props = pub.props; - } -#endif - /* Use a per-subscriber rc: a subscriber's encode/write - * failure (e.g., undersized tx_buf) is a peer-side - * issue and must not be propagated up as the - * publisher's return code, or the publisher would be - * wrongly disconnected by the dispatch's fatal-rc - * gate (especially for QoS 0, where the function- - * level rc is otherwise never overwritten before - * return). */ - int sub_rc = MqttEncode_Publish(sub->client->tx_buf, - BROKER_CLIENT_TX_SZ(sub->client), &out_pub, 0); - if (sub_rc > 0) { - WBLOG_DBG(broker, "broker: PUBLISH fwd sock=%d -> sock=%d " - "topic=%s qos=%d len=%u", - (int)bc->sock, (int)sub->client->sock, - topic, eff_qos, (unsigned)pub.total_len); - (void)MqttPacket_Write(&sub->client->client, - sub->client->tx_buf, sub_rc); - } - else { - WBLOG_ERR(broker, - "broker: PUBLISH fwd encode failed sock=%d -> " - "sock=%d rc=%d", - (int)bc->sock, (int)sub->client->sock, sub_rc); - } + out_pub.protocol_level = sub->client->protocol_level; + if (sub->client->protocol_level >= + MQTT_CONNECT_PROTOCOL_LEVEL_5) { + out_pub.props = pub.props; } -#ifndef WOLFMQTT_STATIC_MEMORY - sub = sub->next; #endif + /* Use a per-subscriber rc: a subscriber's encode/write + * failure (e.g., undersized tx_buf) is a peer-side + * issue and must not be propagated up as the + * publisher's return code, or the publisher would be + * wrongly disconnected by the dispatch's fatal-rc + * gate (especially for QoS 0, where the function- + * level rc is otherwise never overwritten before + * return). */ + sub_rc = MqttEncode_Publish(sub->client->tx_buf, + BROKER_CLIENT_TX_SZ(sub->client), &out_pub, 0); + if (sub_rc > 0) { + WBLOG_DBG(broker, + "broker: PUBLISH fwd sock=%d -> sock=%d " + "topic=%s qos=%d len=%u", + (int)bc->sock, (int)sub->client->sock, + topic, eff_qos, (unsigned)pub.total_len); + (void)MqttPacket_Write(&sub->client->client, + sub->client->tx_buf, sub_rc); + } + else { + WBLOG_ERR(broker, + "broker: PUBLISH fwd encode failed sock=%d -> " + "sock=%d rc=%d", + (int)bc->sock, (int)sub->client->sock, sub_rc); + } } +#ifndef WOLFMQTT_STATIC_MEMORY + sub = sub->next; +#endif } } @@ -3871,7 +3902,7 @@ static int BrokerHandle_PublishRel(BrokerClient* bc, int rx_len) /* [MQTT-4.3.3] QoS 2 step 3: discard the stored Packet Identifier so a * later PUBLISH with the same ID is treated as a fresh delivery. PUBREL - * for an unknown ID is idempotent — we still PUBCOMP it. */ + * for an unknown ID is idempotent - we still PUBCOMP it. */ BrokerInboundQos2_Remove(bc, resp.packet_id); #ifdef WOLFMQTT_V5 @@ -3951,7 +3982,7 @@ static void BrokerClient_AbnormalClose(MqttBroker* broker, BrokerClient* bc) * [MQTT-4.13]/[MQTT-4.8.0-1] mandate connection close on malformed * packets. * - Server-side resource exhaustion (allocator failure, per-client cap - * reached) — the connection must be torn down so resources release. */ + * reached) - the connection must be torn down so resources release. */ static int BrokerRcIsFatal(int rc) { return (rc == MQTT_CODE_ERROR_MALFORMED_DATA || @@ -4143,8 +4174,8 @@ static int BrokerClient_Process(MqttBroker* broker, BrokerClient* bc) break; } case MQTT_PACKET_TYPE_PING_REQ: - /* MQTT 3.1.1 §3.12 / v5 §3.12: PINGREQ is fixed-header- - * only — Remaining Length MUST be 0. Reject malformed + /* MQTT 3.1.1 section 3.12 / v5 section 3.12: PINGREQ is fixed-header- + * only - Remaining Length MUST be 0. Reject malformed * PINGREQ before sending PINGRESP. */ if (bc->client.packet.remain_len != 0) { BrokerClient_AbnormalClose(broker, bc); @@ -4153,8 +4184,8 @@ static int BrokerClient_Process(MqttBroker* broker, BrokerClient* bc) (void)BrokerSend_PingResp(bc); break; case MQTT_PACKET_TYPE_DISCONNECT: - /* MQTT 3.1.1 §3.14: DISCONNECT has no variable header and - * no payload — Remaining Length MUST be 0. v5 §3.14 + /* MQTT 3.1.1 section 3.14: DISCONNECT has no variable header and + * no payload - Remaining Length MUST be 0. v5 section 3.14 * relaxes this to allow an optional Reason Code and * Properties, so the check is gated on protocol level. */ #ifdef WOLFMQTT_V5 @@ -4249,7 +4280,34 @@ int MqttBroker_Init(MqttBroker* broker, MqttBrokerNet* net) broker->running = 0; broker->log_level = BROKER_LOG_LEVEL_DEFAULT; broker->next_packet_id = 1; + /* Seed the auto-id counter from a CSPRNG so the initial value + * doesn't reveal broker uptime or start time. The counter still + * advances by +1 per empty-ID CONNECT, so observing one assigned + * auto-id discloses subsequent ones; the "auto-" prefix reservation + * is what actually blocks hijack-via-prediction. The CSPRNG seed is + * defense-in-depth against the residual information leak in the + * starting value only. + * + * Gated on ENABLE_MQTT_TLS to avoid pulling wolfCrypt into plaintext- + * broker builds that don't otherwise depend on it. Non-TLS builds + * therefore start at 1; this is acceptable because (a) the prefix + * reservation is the actual security boundary, and (b) operators + * deploying a plaintext broker have already accepted that the wire + * is observable. */ broker->next_auto_id = 1; +#ifdef ENABLE_MQTT_TLS + { + WC_RNG rng; + if (wc_InitRng(&rng) == 0) { + word32 seed = 0; + if (wc_RNG_GenerateBlock(&rng, (byte*)&seed, sizeof(seed)) == 0 + && seed != 0) { + broker->next_auto_id = seed; + } + wc_FreeRng(&rng); + } + } +#endif #if !defined(WOLFMQTT_WOLFIP) && !defined(WOLFMQTT_BROKER_CUSTOM_NET) /* For the default POSIX backend, the net callbacks expect ctx to be a diff --git a/src/mqtt_client.c b/src/mqtt_client.c index 6c041d634..c536c8e3f 100644 --- a/src/mqtt_client.c +++ b/src/mqtt_client.c @@ -3081,82 +3081,6 @@ int MqttClient_NetDisconnect(MqttClient *client) return MqttSocket_Disconnect(client); } -/* [MQTT-2.3.1-1] / MQTT 3.1.1 section 2.3.1: a new QoS-related Control - * Packet must use a Packet Identifier that is not currently in use; the - * value only becomes reusable after the corresponding acknowledgement - * flow completes. This is the central allocator implementing that rule. - * - * The pending response list (under WOLFMQTT_MULTITHREAD) is the - * authoritative in-use set: an entry is present from the moment a - * QoS-related packet is registered and is removed only after the matching - * ack is processed. We walk it to skip any value that is still in flight - * and advance the per-client counter to find the next unused id. - * - * Under WOLFMQTT_MULTITHREAD lockClient is held across both the counter - * advance and the in-flight walk. Splitting them would allow two - * concurrent callers to increment past the same pre-value, see an empty - * resp list (neither has registered yet), and both return the same id — - * the very collision this allocator exists to prevent. - * - * Returns 0 when client is NULL, lockClient cannot be acquired, or the - * full 1..0xFFFF range is in flight. In all three cases the caller must - * back off rather than reuse a value. */ -word16 MqttClient_NextPacketId(MqttClient *client) -{ - word32 tries; - word16 result = 0; - - if (client == NULL) { - return 0; - } - -#ifdef WOLFMQTT_MULTITHREAD - /* Lock failure: surface as the documented sentinel. Returning an - * unverified candidate would silently violate the in-use guarantee. */ - if (wm_SemLock(&client->lockClient) != 0) { - return 0; - } -#endif - - for (tries = 0; tries < (word32)MAX_PACKET_ID + 1U; tries++) { - word16 candidate; - - client->next_packet_id++; - if (client->next_packet_id == 0) { - client->next_packet_id = 1; /* skip 0 on wrap */ - } - candidate = client->next_packet_id; - -#ifdef WOLFMQTT_MULTITHREAD - { - MqttPendResp *tmpResp; - int collision = 0; - for (tmpResp = client->firstPendResp; - tmpResp != NULL; - tmpResp = tmpResp->next) - { - if (tmpResp->packet_id == candidate) { - collision = 1; - break; - } - } - if (collision) { - continue; /* try the next value */ - } - } -#endif - - result = candidate; - break; - } - -#ifdef WOLFMQTT_MULTITHREAD - wm_SemUnlock(&client->lockClient); -#endif - - return result; -} - int MqttClient_GetProtocolVersion(MqttClient *client) { #ifdef WOLFMQTT_V5 diff --git a/src/mqtt_packet.c b/src/mqtt_packet.c index a66cf8658..4b387d9c3 100644 --- a/src/mqtt_packet.c +++ b/src/mqtt_packet.c @@ -211,8 +211,8 @@ static int FixedHeaderFlagsExpected(byte type, byte *expected) } /* [MQTT-3.9.3-2] Validate a SUBACK return code against the spec-allowed - * set. v3.1.1 §3.9.3 restricts the payload to exactly four values - * {0x00, 0x01, 0x02, 0x80}. v5 §3.9.3 broadens the set to include + * set. v3.1.1 section 3.9.3 restricts the payload to exactly four values + * {0x00, 0x01, 0x02, 0x80}. v5 section 3.9.3 broadens the set to include * additional Reason Codes (Implementation specific error, Not authorized, * Topic Filter invalid, Packet Identifier in use, Quota exceeded, * Shared Subscriptions not supported, Subscription Identifiers not @@ -254,7 +254,7 @@ int MqttPacket_SubAckReturnCodeValid(byte code, byte protocol_level) * [MQTT-4.7.3-1] (minimum length one character), [MQTT-4.7.1-2] * (multi-level wildcard '#' must be either the whole filter or directly * follow '/', and must be the final character), and [MQTT-4.7.1-3] - * (single-level wildcard '+' must occupy an entire level). v5 §4.7 + * (single-level wildcard '+' must occupy an entire level). v5 section 4.7 * carries the same rules. Returns 1 if the filter is well-formed, 0 if * it must be treated as malformed. The length is decoded by the caller * via MqttDecode_String so this helper takes (filter, len) rather than @@ -290,17 +290,19 @@ int MqttPacket_TopicFilterValid(const char* filter, word16 len) /* Validate an MQTT PUBLISH Topic Name against [MQTT-3.3.2-2] / * [MQTT-4.7.1-1] (Topic Names MUST NOT contain wildcard characters '#' * or '+'; applies to both v3.1.1 and v5) and [MQTT-4.7.3-1] (minimum - * length one character) — but the latter is gated to v3.1.1 because - * v5 §3.3.2.3.4 explicitly permits a zero-length Topic Name when + * length one character) - but the latter is gated to v3.1.1 because + * v5 section 3.3.2.3.4 explicitly permits a zero-length Topic Name when * paired with a Topic Alias property. The pairing check (alias must * be present when the topic is empty) is left to the caller because * the property block hasn't been decoded yet at the wildcard-scan - * point. NULL topic_name with non-zero len is malformed regardless. */ + * point. NULL topic_name is malformed regardless of len; callers + * representing a v5 Topic Alias placeholder must pass an empty + * string. */ int MqttPacket_TopicNameValid(const char* topic_name, word16 len, byte protocol_level) { word16 i; - if (topic_name == NULL && len > 0) { + if (topic_name == NULL) { return 0; } for (i = 0; i < len; i++) { @@ -354,7 +356,7 @@ int MqttPacket_FixedHeaderFlagsValid(byte type_flags) if (FixedHeaderFlagsExpected(type, &expected)) { return (flags == expected) ? 1 : 0; } - /* Reserved (type 0) or otherwise unrecognized packet type — reject so + /* Reserved (type 0) or otherwise unrecognized packet type - reject so * this helper is safe to use as a protocol-level malformed-packet * gate. The broker uses it pre-dispatch, so anything it accepts has * to be a known type. */ @@ -517,7 +519,8 @@ int MqttEncode_Int(byte* buf, word32 len) return MQTT_DATA_INT_SIZE; } -/* MQTT 3.1.1 §1.5.3 / v5 §1.5.4: validate that the given byte sequence +#ifndef WOLFMQTT_NO_UTF8_VALIDATION +/* MQTT 3.1.1 section 1.5.3 / v5 section 1.5.4: validate that the given byte sequence * is a well-formed MQTT UTF-8 encoded string. This combines: * [MQTT-1.5.3-1] RFC 3629 well-formedness (no overlongs, no surrogate * code points U+D800..U+DFFF, no codepoints above @@ -592,6 +595,7 @@ static int Utf8WellFormed(const byte* s, word16 len) } return 1; } +#endif /* !WOLFMQTT_NO_UTF8_VALIDATION */ /* Returns pointer to string (which is not guaranteed to be null terminated). * [MQTT-1.5.3-1] Rejects ill-formed UTF-8 with MQTT_CODE_ERROR_MALFORMED_DATA; @@ -610,13 +614,15 @@ int MqttDecode_String(byte *buf, const char **pstr, word16 *pstr_len, word32 buf } buf += len; if (str_len > 0) { + #ifndef WOLFMQTT_NO_UTF8_VALIDATION /* [MQTT-1.5.3-1] Reject ill-formed UTF-8 (RFC 3629). */ if (!Utf8WellFormed(buf, str_len)) { return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_MALFORMED_DATA); } + #endif /* [MQTT-1.5.3-2] / [MQTT-1.5.4-2]: an MQTT UTF-8 encoded string * MUST NOT include the null character (U+0000). Although U+0000 - * is well-formed UTF-8, it is forbidden in MQTT string fields — + * is well-formed UTF-8, it is forbidden in MQTT string fields - * downstream C-string handling would otherwise be tricked by an * embedded NUL truncating the value (e.g., a topic "se\0cret" * would route to subscribers of "se"). The CONNECT Password @@ -1266,6 +1272,7 @@ int MqttDecode_Connect(byte *rx_buf, int rx_buf_len, MqttConnect *mc_connect) MqttConnectPacket packet; word16 protocol_len = 0; int tmp; + int rc; #ifdef WOLFMQTT_V5 word32 props_len = 0; #endif @@ -1306,6 +1313,14 @@ int MqttDecode_Connect(byte *rx_buf, int rx_buf_len, MqttConnect *mc_connect) return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_MALFORMED_DATA); } + /* Initialize props pointers up front so the cleanup path can free them + * on any subsequent error return without inspecting partially-populated + * state. lwt_msg->props is initialized here as well (when applicable) + * because cleanup touches it on every goto-cleanup path between this + * point and the LWT decode, not only the ones inside the enable_lwt + * branch. The caller is not required to zero lwt_msg, so leaving the + * field uninitialized would let cleanup read garbage and free a wild + * pointer. */ mc_connect->protocol_level = packet.protocol_level; mc_connect->clean_session = (packet.flags & MQTT_CONNECT_FLAG_CLEAN_SESSION) ? 1 : 0; @@ -1313,11 +1328,18 @@ int MqttDecode_Connect(byte *rx_buf, int rx_buf_len, MqttConnect *mc_connect) (packet.flags & MQTT_CONNECT_FLAG_WILL_FLAG) ? 1 : 0; mc_connect->username = NULL; mc_connect->password = NULL; +#ifdef WOLFMQTT_V5 + mc_connect->props = NULL; + if (mc_connect->enable_lwt && mc_connect->lwt_msg != NULL) { + mc_connect->lwt_msg->props = NULL; + } +#endif /* [MQTT-3.1.2-3] CONNECT flags bit 0 is reserved and MUST be 0. * Applies to both v3.1.1 and v5. */ if (packet.flags & MQTT_CONNECT_FLAG_RESERVED) { - return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_MALFORMED_DATA); + rc = MQTT_TRACE_ERROR(MQTT_CODE_ERROR_MALFORMED_DATA); + goto cleanup; } /* [MQTT-3.1.2-13] / [MQTT-3.1.2-15] If the Will Flag is 0, Will QoS @@ -1326,7 +1348,8 @@ int MqttDecode_Connect(byte *rx_buf, int rx_buf_len, MqttConnect *mc_connect) if (!(packet.flags & MQTT_CONNECT_FLAG_WILL_FLAG) && (packet.flags & (MQTT_CONNECT_FLAG_WILL_QOS_MASK | MQTT_CONNECT_FLAG_WILL_RETAIN))) { - return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_MALFORMED_DATA); + rc = MQTT_TRACE_ERROR(MQTT_CODE_ERROR_MALFORMED_DATA); + goto cleanup; } /* [MQTT-3.1.2-14] Will QoS = 3 is reserved and MUST NOT be used. @@ -1334,32 +1357,34 @@ int MqttDecode_Connect(byte *rx_buf, int rx_buf_len, MqttConnect *mc_connect) * already rejects nonzero QoS bits in that case. */ if ((packet.flags & MQTT_CONNECT_FLAG_WILL_FLAG) && MQTT_CONNECT_FLAG_GET_QOS(packet.flags) == MQTT_QOS_3) { - return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_MALFORMED_DATA); + rc = MQTT_TRACE_ERROR(MQTT_CODE_ERROR_MALFORMED_DATA); + goto cleanup; } /* [MQTT-3.1.2-22] (v3.1.1 only) If the User Name Flag is 0, the * Password Flag MUST be 0. MQTT v5 section 3.1.2.9 explicitly relaxes - * this — "This version of the protocol allows the sending of a - * Password with no User Name, where MQTT v3.1.1 did not." — so the + * this - "This version of the protocol allows the sending of a + * Password with no User Name, where MQTT v3.1.1 did not." - so the * check is gated on the protocol level. mc_connect->protocol_level was * just populated above. */ if (mc_connect->protocol_level == MQTT_CONNECT_PROTOCOL_LEVEL_4 && (packet.flags & MQTT_CONNECT_FLAG_PASSWORD) && !(packet.flags & MQTT_CONNECT_FLAG_USERNAME)) { - return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_MALFORMED_DATA); + rc = MQTT_TRACE_ERROR(MQTT_CODE_ERROR_MALFORMED_DATA); + goto cleanup; } tmp = MqttDecode_Num((byte*)&packet.keep_alive, &mc_connect->keep_alive_sec, MQTT_DATA_LEN_SIZE); if (tmp < 0) { - return tmp; + rc = tmp; + goto cleanup; } #ifdef WOLFMQTT_V5 - mc_connect->props = NULL; /* Only decode v5 properties when the level is exactly 5. Treating any * level >= 5 as v5 incorrectly consumes a properties-length byte for - * unsupported levels (e.g., 6) — the broker's [MQTT-3.1.2-2] rejection + * unsupported levels (e.g., 6) - the broker's [MQTT-3.1.2-2] rejection * runs after this function, so we must let the wire decode under the * level the spec actually defines for it (here: nothing, fall through * to the v3.1.1-shape payload). @@ -1368,19 +1393,21 @@ int MqttDecode_Connect(byte *rx_buf, int rx_buf_len, MqttConnect *mc_connect) * (extra properties-length VBI present) will misparse on the v3.1.1 * path and the strict tail-consumption check below returns * MALFORMED_DATA, which the broker translates to a silent socket - * close — CONNACK 0x01 is emitted only when the v3.1.1-shape decode + * close - CONNACK 0x01 is emitted only when the v3.1.1-shape decode * succeeds. This is a best-effort spec compliance trade-off; clients * that misrepresent their protocol level should not expect the broker * to reverse-engineer the wire shape. */ if (mc_connect->protocol_level == MQTT_CONNECT_PROTOCOL_LEVEL_5) { /* Decode Length of Properties */ if (rx_buf_len < (rx_payload - rx_buf)) { - return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_OUT_OF_BUFFER); + rc = MQTT_TRACE_ERROR(MQTT_CODE_ERROR_OUT_OF_BUFFER); + goto cleanup; } tmp = MqttDecode_Vbi(rx_payload, &props_len, (word32)(rx_buf_len - (rx_payload - rx_buf))); if (tmp < 0) { - return tmp; + rc = tmp; + goto cleanup; } rx_payload += tmp; if (props_len > 0) { @@ -1389,7 +1416,8 @@ int MqttDecode_Connect(byte *rx_buf, int rx_buf_len, MqttConnect *mc_connect) &mc_connect->props, rx_payload, (word32)(rx_buf_len - (rx_payload - rx_buf)), props_len); if (tmp < 0) { - return tmp; + rc = tmp; + goto cleanup; } rx_payload += tmp; } @@ -1400,16 +1428,19 @@ int MqttDecode_Connect(byte *rx_buf, int rx_buf_len, MqttConnect *mc_connect) tmp = MqttDecode_String(rx_payload, &mc_connect->client_id, NULL, (word32)(rx_buf_len - (rx_payload - rx_buf))); if (tmp < 0) { - return tmp; + rc = tmp; + goto cleanup; } if ((rx_payload - rx_buf) + tmp > header_len + remain_len) { - return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_OUT_OF_BUFFER); + rc = MQTT_TRACE_ERROR(MQTT_CODE_ERROR_OUT_OF_BUFFER); + goto cleanup; } rx_payload += tmp; if (mc_connect->enable_lwt) { if (mc_connect->lwt_msg == NULL) { - return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_BAD_ARG); + rc = MQTT_TRACE_ERROR(MQTT_CODE_ERROR_BAD_ARG); + goto cleanup; } mc_connect->lwt_msg->qos = @@ -1418,19 +1449,20 @@ int MqttDecode_Connect(byte *rx_buf, int rx_buf_len, MqttConnect *mc_connect) (packet.flags & MQTT_CONNECT_FLAG_WILL_RETAIN) ? 1 : 0; #ifdef WOLFMQTT_V5 - mc_connect->lwt_msg->props = NULL; /* See note above: only level 5 carries v5 LWT properties on the wire. */ if (mc_connect->protocol_level == MQTT_CONNECT_PROTOCOL_LEVEL_5) { word32 lwt_props_len = 0; int lwt_tmp; /* Decode Length of LWT Properties */ if (rx_buf_len < (rx_payload - rx_buf)) { - return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_OUT_OF_BUFFER); + rc = MQTT_TRACE_ERROR(MQTT_CODE_ERROR_OUT_OF_BUFFER); + goto cleanup; } lwt_tmp = MqttDecode_Vbi(rx_payload, &lwt_props_len, (word32)(rx_buf_len - (rx_payload - rx_buf))); if (lwt_tmp < 0) { - return lwt_tmp; + rc = lwt_tmp; + goto cleanup; } rx_payload += lwt_tmp; if (lwt_props_len > 0) { @@ -1440,7 +1472,8 @@ int MqttDecode_Connect(byte *rx_buf, int rx_buf_len, MqttConnect *mc_connect) (word32)(rx_buf_len - (rx_payload - rx_buf)), lwt_props_len); if (lwt_tmp < 0) { - return lwt_tmp; + rc = lwt_tmp; + goto cleanup; } rx_payload += lwt_tmp; } @@ -1451,10 +1484,12 @@ int MqttDecode_Connect(byte *rx_buf, int rx_buf_len, MqttConnect *mc_connect) &mc_connect->lwt_msg->topic_name_len, (word32)(rx_buf_len - (rx_payload - rx_buf))); if (tmp < 0) { - return tmp; + rc = tmp; + goto cleanup; } if ((rx_payload - rx_buf) + tmp > header_len + remain_len) { - return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_OUT_OF_BUFFER); + rc = MQTT_TRACE_ERROR(MQTT_CODE_ERROR_OUT_OF_BUFFER); + goto cleanup; } rx_payload += tmp; @@ -1463,14 +1498,16 @@ int MqttDecode_Connect(byte *rx_buf, int rx_buf_len, MqttConnect *mc_connect) tmp = MqttDecode_Num(rx_payload, &lwt_len, (word32)(rx_buf_len - (rx_payload - rx_buf))); if (tmp < 0) { - return tmp; + rc = tmp; + goto cleanup; } mc_connect->lwt_msg->total_len = lwt_len; } rx_payload += tmp; if ((rx_payload - rx_buf) + (int)mc_connect->lwt_msg->total_len > header_len + remain_len) { - return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_OUT_OF_BUFFER); + rc = MQTT_TRACE_ERROR(MQTT_CODE_ERROR_OUT_OF_BUFFER); + goto cleanup; } mc_connect->lwt_msg->buffer = rx_payload; mc_connect->lwt_msg->buffer_len = mc_connect->lwt_msg->total_len; @@ -1482,10 +1519,12 @@ int MqttDecode_Connect(byte *rx_buf, int rx_buf_len, MqttConnect *mc_connect) tmp = MqttDecode_String(rx_payload, &mc_connect->username, NULL, (word32)(rx_buf_len - (rx_payload - rx_buf))); if (tmp < 0) { - return tmp; + rc = tmp; + goto cleanup; } if ((rx_payload - rx_buf) + tmp > header_len + remain_len) { - return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_OUT_OF_BUFFER); + rc = MQTT_TRACE_ERROR(MQTT_CODE_ERROR_OUT_OF_BUFFER); + goto cleanup; } rx_payload += tmp; } @@ -1498,15 +1537,18 @@ int MqttDecode_Connect(byte *rx_buf, int rx_buf_len, MqttConnect *mc_connect) tmp = MqttDecode_Num(rx_payload, &plen, (word32)(rx_buf_len - (rx_payload - rx_buf))); if (tmp < 0) { - return tmp; + rc = tmp; + goto cleanup; } if ((word32)plen > (word32)(rx_buf_len - (rx_payload - rx_buf) - tmp)) { - return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_OUT_OF_BUFFER); + rc = MQTT_TRACE_ERROR(MQTT_CODE_ERROR_OUT_OF_BUFFER); + goto cleanup; } if ((rx_payload - rx_buf) + tmp + (int)plen > header_len + remain_len) { - return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_OUT_OF_BUFFER); + rc = MQTT_TRACE_ERROR(MQTT_CODE_ERROR_OUT_OF_BUFFER); + goto cleanup; } mc_connect->password = (char*)(rx_payload + tmp); rx_payload += tmp + plen; @@ -1520,11 +1562,32 @@ int MqttDecode_Connect(byte *rx_buf, int rx_buf_len, MqttConnect *mc_connect) * a password-shaped suffix), which the receiver must reject as * malformed instead of silently dropping. */ if ((rx_payload - rx_buf) != header_len + remain_len) { - return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_MALFORMED_DATA); + rc = MQTT_TRACE_ERROR(MQTT_CODE_ERROR_MALFORMED_DATA); + goto cleanup; } - /* Return total length of packet */ - return header_len + remain_len; + /* Total length of packet */ + rc = header_len + remain_len; + +cleanup: +#ifdef WOLFMQTT_V5 + /* On error, free any v5 property lists already populated by + * MqttDecode_Props above and null the pointers so a defensive caller + * that also frees on error sees a no-op. The success path skips the + * free; the caller owns the lists from then on. */ + if (rc < 0) { + if (mc_connect->props != NULL) { + (void)MqttProps_Free(mc_connect->props); + mc_connect->props = NULL; + } + if (mc_connect->enable_lwt && mc_connect->lwt_msg != NULL && + mc_connect->lwt_msg->props != NULL) { + (void)MqttProps_Free(mc_connect->lwt_msg->props); + mc_connect->lwt_msg->props = NULL; + } + } +#endif + return rc; } #endif /* WOLFMQTT_BROKER */ @@ -1844,6 +1907,7 @@ int MqttDecode_Publish(byte *rx_buf, int rx_buf_len, MqttPublish *publish) { int header_len, remain_len, variable_len, payload_len; byte *rx_payload; + byte level = 0; /* Validate required arguments */ if (rx_buf == NULL || rx_buf_len <= 0 || publish == NULL) { @@ -1872,19 +1936,16 @@ int MqttDecode_Publish(byte *rx_buf, int rx_buf_len, MqttPublish *publish) } /* [MQTT-3.3.2-2] / [MQTT-4.7.1-1] Reject Topic Names containing * wildcards (both versions). [MQTT-4.7.3-1] Reject empty Topic - * Names for v3.1.1; v5 §3.3.2.3.4 permits a zero-length Topic Name - * paired with a Topic Alias property — the alias-empty pairing is + * Names for v3.1.1; v5 section 3.3.2.3.4 permits a zero-length Topic Name + * paired with a Topic Alias property - the alias-empty pairing is * left to the caller because the property block is decoded later * in this function. */ - { - byte level = 0; - #ifdef WOLFMQTT_V5 - level = publish->protocol_level; - #endif - if (!MqttPacket_TopicNameValid(publish->topic_name, - publish->topic_name_len, level)) { - return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_MALFORMED_DATA); - } +#ifdef WOLFMQTT_V5 + level = publish->protocol_level; +#endif + if (!MqttPacket_TopicNameValid(publish->topic_name, + publish->topic_name_len, level)) { + return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_MALFORMED_DATA); } rx_payload += variable_len; @@ -2081,9 +2142,9 @@ int MqttDecode_PublishResp(byte* rx_buf, int rx_buf_len, byte type, return header_len; } - /* MQTT 3.1.1 §3.4-3.7: PUBACK/PUBREC/PUBREL/PUBCOMP variable header is - * exactly the two-byte Packet Identifier and there is no payload — - * Remaining Length is fixed at 2. v5 §3.4-3.7 relaxes this to allow + /* MQTT 3.1.1 sections 3.4-3.7: PUBACK/PUBREC/PUBREL/PUBCOMP variable header is + * exactly the two-byte Packet Identifier and there is no payload - + * Remaining Length is fixed at 2. v5 sections 3.4-3.7 relaxes this to allow * an optional Reason Code and Properties block, so the longer form is * only valid when the caller has identified the connection as v5. * (publish_resp == NULL takes the strict path: with no struct to @@ -2375,10 +2436,10 @@ int MqttDecode_Subscribe(byte *rx_buf, int rx_buf_len, MqttSubscribe *subscribe) return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_OUT_OF_BUFFER); } options = *rx_payload++; - /* MQTT 3.1.1 §3.8.3.1: bits 2-7 of the SUBSCRIBE options byte + /* MQTT 3.1.1 section 3.8.3.1: bits 2-7 of the SUBSCRIBE options byte * are reserved and MUST be 0; Requested QoS (bits 0-1) MUST - * be 0, 1, or 2. v5 §3.8.3.1 redefines bits 2-5 as No Local, - * Retain As Published, and Retain Handling — bits 6-7 stay + * be 0, 1, or 2. v5 section 3.8.3.1 redefines bits 2-5 as No Local, + * Retain As Published, and Retain Handling - bits 6-7 stay * reserved, Retain Handling = 3 is also reserved, and QoS = 3 * remains invalid. The fixed-header [MQTT-3.8.1-1] reserved- * flag check has already run by this point; this check covers @@ -2405,7 +2466,7 @@ int MqttDecode_Subscribe(byte *rx_buf, int rx_buf_len, MqttSubscribe *subscribe) } /* [MQTT-3.8.3-3] The payload of a SUBSCRIBE packet MUST contain at - * least one Topic Filter / QoS pair. v5 §3.8.3 carries the same + * least one Topic Filter / QoS pair. v5 section 3.8.3 carries the same * minimum-cardinality requirement. */ if (subscribe->topic_count == 0) { return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_MALFORMED_DATA); @@ -2734,7 +2795,7 @@ int MqttDecode_Unsubscribe(byte *rx_buf, int rx_buf_len, MqttUnsubscribe *unsubs } /* [MQTT-3.10.3-2] The Payload of an UNSUBSCRIBE packet MUST - * contain at least one Topic Filter. v5 §3.10.3 carries the same + * contain at least one Topic Filter. v5 section 3.10.3 carries the same * minimum-cardinality requirement. */ if (unsubscribe->topic_count == 0) { return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_MALFORMED_DATA); @@ -2958,7 +3019,7 @@ int MqttDecode_Ping(byte *rx_buf, int rx_buf_len, MqttPing* ping) return header_len; } - /* MQTT 3.1.1 §3.13 / v5 §3.13: PINGRESP has no variable header and no + /* MQTT 3.1.1 section 3.13 / v5 section 3.13: PINGRESP has no variable header and no * payload, so Remaining Length MUST be 0. */ if (remain_len != 0) { return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_MALFORMED_DATA); @@ -3078,7 +3139,7 @@ int MqttDecode_Disconnect(byte *rx_buf, int rx_buf_len, MqttDisconnect* disc) return header_len; } - /* MQTT 3.1.1 §3.14: DISCONNECT has no variable header and no payload, + /* MQTT 3.1.1 section 3.14: DISCONNECT has no variable header and no payload, * so Remaining Length MUST be 0. The WOLFMQTT_V5 decoder below * legitimately accepts remain_len > 0 for the Reason Code and * Properties. */ diff --git a/tests/fuzz/broker_fuzz.c b/tests/fuzz/broker_fuzz.c index d37c6aad4..9816108c8 100644 --- a/tests/fuzz/broker_fuzz.c +++ b/tests/fuzz/broker_fuzz.c @@ -63,7 +63,7 @@ int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size); #define FUZZ_FAKE_LISTEN_SOCK 100 #define FUZZ_FAKE_SOCK 101 -/* Maximum Step() calls per input — enough for CONNECT + several packets */ +/* Maximum Step() calls per input - enough for CONNECT + several packets */ #define FUZZ_MAX_STEPS 6 /* Input size limits */ diff --git a/tests/test_broker_connect.c b/tests/test_broker_connect.c index e9660f42f..fda2bbda5 100644 --- a/tests/test_broker_connect.c +++ b/tests/test_broker_connect.c @@ -417,7 +417,7 @@ TEST(connect_v311_explicit_auto_prefix_refused) /* [MQTT-3.1.2-2] Unsupported Protocol Level must be refused with CONNACK * 0x01 (REFUSED_PROTO) followed by disconnect. The CONNACK MUST come back * in v3.1.1 wire shape (4 bytes: type, remain=2, flags, code) regardless - * of the level the client claimed — we don't know what their wire format + * of the level the client claimed - we don't know what their wire format * actually is, and the spec text specifies "CONNACK return code 0x01" * verbatim. * @@ -480,7 +480,7 @@ TEST(connect_unsupported_level_6_refused) * (0x00 0x01) as the ClientId length prefix and 'A' as the start of a * 1-byte ClientId. With WOLFMQTT_V5 the decoder still returned success * for this particular wire, but it produced a misaligned MqttConnect with - * ClientId="" — so the test below also fails on the post-decode path + * ClientId="" - so the test below also fails on the post-decode path * unless the broker's [MQTT-3.1.2-2] check rejects the level. (For wires * without enough trailing bytes, the pre-fix decoder instead returned * OUT_OF_BUFFER and never emitted a CONNACK at all, which would also @@ -491,7 +491,7 @@ TEST(connect_unsupported_level_6_refused) TEST(connect_unsupported_level_127_refused) { - /* Top of the byte range — guards against a future "treat high values + /* Top of the byte range - guards against a future "treat high values * as latest known" mutation. */ run_unsupported_level(0x7F); } @@ -500,7 +500,7 @@ TEST(connect_unsupported_level_127_refused) /* Regression: [MQTT-3.1.3.5] Password is Binary Data and may legally * contain 0x00. The broker must not use XSTRLEN-based length recovery on * bc->password, which would truncate at the first embedded NUL and turn - * the constant-time auth compare into a prefix compare — letting a + * the constant-time auth compare into a prefix compare - letting a * client that sends "abc\0" authenticate against auth_pass * "abc". The fix tracks bc->password_len explicitly. */ TEST(connect_v311_binary_password_with_embedded_nul_refused) @@ -535,7 +535,7 @@ TEST(connect_v311_binary_password_with_embedded_nul_refused) reset_mock_state(connect, sizeof(connect)); run_broker_one_connect(&broker); - /* Auth must fail — CONNACK return code 0x05 (Not Authorized) and the + /* Auth must fail - CONNACK return code 0x05 (Not Authorized) and the * connection is closed. Pre-fix, XSTRLEN truncation would let this * authenticate and emit return code 0x00. */ ASSERT_TRUE(g_out_len >= 4); @@ -615,7 +615,7 @@ TEST(connect_v5_emptyid_assigned_id_emitted) * [2] session_present * [3] reason_code (0x00 = Success) * [4] properties_len (VBI; expect 1 byte) - * [5] first property tag — MUST be 0x12 (ASSIGNED_CLIENT_ID) + * [5] first property tag - MUST be 0x12 (ASSIGNED_CLIENT_ID) * [6..7] string length (big-endian word16) * [8..] UTF-8 string ("auto-XXXXXXXX") * MqttProps_Add appends to the end of the prop list (mqtt_packet.c @@ -879,7 +879,7 @@ TEST(qos2_phantom_dup_publish_is_fresh) g_clients[1].out_len, MQTT_PACKET_TYPE_PUBLISH_REC); /* Subscriber gets one forwarded PUBLISH; publisher gets one PUBREC. - * The DUP flag does NOT suppress fan-out — only an actual matching + * The DUP flag does NOT suppress fan-out - only an actual matching * dedup-set entry does. */ ASSERT_EQ(1, sub_pubs); ASSERT_EQ(1, pub_pubrecs); @@ -1032,7 +1032,7 @@ TEST(qos2_state_freed_on_client_disconnect) 0x10, 0x0D, 0x00, 0x04, 'M', 'Q', 'T', 'T', 0x04, 0x02, 0x00, 0x3C, 0x00, 0x01, 'B' }; - /* Normal DISCONNECT packet — drives the broker through the + /* Normal DISCONNECT packet - drives the broker through the * clean-disconnect cleanup path. */ static const byte disconnect[] = { 0xE0, 0x00 }; byte pub_buf[8]; @@ -1102,7 +1102,7 @@ TEST(qos2_pubrel_unknown_id_still_pubcomps) MqttBroker_Free(&broker); } -/* MQTT 3.1.1 §3.12 / v5 §3.12: PINGREQ has no variable header and no +/* MQTT 3.1.1 section 3.12 / v5 section 3.12: PINGREQ has no variable header and no * payload, so Remaining Length MUST be 0. Broker dispatch must reject a * malformed PINGREQ with an abnormal close instead of emitting a * PINGRESP. @@ -1152,7 +1152,7 @@ TEST(pingreq_nonzero_remain_len_closes_no_pingresp) 0x04, 0x02, 0x00, 0x3C, 0x00, 0x01, 'A' }; - /* C0 01 00 — PINGREQ with one trailing byte. The fixed-header-only + /* C0 01 00 - PINGREQ with one trailing byte. The fixed-header-only * rule makes this malformed. */ static const byte pingreq_bad[] = { 0xC0, 0x01, 0x00 }; int i; @@ -1177,11 +1177,11 @@ TEST(pingreq_nonzero_remain_len_closes_no_pingresp) MqttBroker_Free(&broker); } -/* MQTT 3.1.1 §3.14: DISCONNECT has no variable header and no payload, so +/* MQTT 3.1.1 section 3.14: DISCONNECT has no variable header and no payload, so * Remaining Length MUST be 0. The strong observable for "malformed * DISCONNECT was rejected" is the Last Will: a normal DISCONNECT clears - * the will, but AbnormalClose fires it. Two clients — subscriber on the - * will topic, publisher with an LWT — let us assert the broker dispatched + * the will, but AbnormalClose fires it. Two clients - subscriber on the + * will topic, publisher with an LWT - let us assert the broker dispatched * the malformed packet through AbnormalClose by observing the will * delivery. v5 has its own decoder that legitimately accepts Reason Code * + Properties, so the broker's remain_len check (and this test) is @@ -1215,7 +1215,7 @@ TEST(disconnect_v311_nonzero_remain_len_fires_will) 0x00, 0x03, 'l', 'w', 't', 0x00, 0x03, 'b', 'y', 'e' }; - /* E0 01 00 — malformed v3.1.1 DISCONNECT (nonzero remain_len). */ + /* E0 01 00 - malformed v3.1.1 DISCONNECT (nonzero remain_len). */ static const byte disconnect_bad[] = { 0xE0, 0x01, 0x00 }; install_mock_net(&net); @@ -1248,7 +1248,7 @@ TEST(disconnect_v311_nonzero_remain_len_fires_will) /* The broker switch's default branch must close the connection on any * unhandled packet type rather than silently no-op'ing. Wire is an - * AUTH packet (type 15) from a v3.1.1 client — AUTH is undefined in + * AUTH packet (type 15) from a v3.1.1 client - AUTH is undefined in * v3.1.1 and this broker doesn't implement enhanced authentication * even on v5, so AUTH is always unhandled. The pre-dispatch * FixedHeaderFlagsValid gate accepts AUTH (it is a defined type 15 @@ -1301,7 +1301,7 @@ TEST(broker_subscribe_packet_id_zero_closes) 0x04, 0x02, 0x00, 0x3C, 0x00, 0x01, 'S' }; - /* SUBSCRIBE with packet_id=0x0000 — violates [MQTT-2.3.1-1]. + /* SUBSCRIBE with packet_id=0x0000 - violates [MQTT-2.3.1-1]. * Body: packet_id (2) + topic_len (2) + "t" (1) + qos (1) = 6. */ static const byte sub_pid_zero[] = { 0x82, 0x06, @@ -1327,7 +1327,7 @@ TEST(broker_subscribe_packet_id_zero_closes) MqttBroker_Free(&broker); } -/* MQTT 3.1.1 §3.14.1 / [MQTT-2.2.2-2]: DISCONNECT fixed-header low +/* MQTT 3.1.1 section 3.14.1 / [MQTT-2.2.2-2]: DISCONNECT fixed-header low * nibble MUST be 0000. The broker dispatch enforces this via the * MqttPacket_FixedHeaderFlagsValid pre-check that runs before per-type * handlers, so a malformed DISCONNECT (e.g. 0xE1) takes the abnormal- @@ -1358,7 +1358,7 @@ TEST(disconnect_invalid_fixed_header_flags_fires_will) 0x00, 0x03, 'l', 'w', 't', 0x00, 0x03, 'b', 'y', 'e' }; - /* 0xE1 — DISCONNECT type with reserved bit 0 set. */ + /* 0xE1 - DISCONNECT type with reserved bit 0 set. */ static const byte disconnect_bad[] = { 0xE1, 0x00 }; install_mock_net(&net); @@ -1568,7 +1568,7 @@ TEST(connack_session_present_set_on_takeover) for (i = 0; i < 16; i++) { MqttBroker_Step(&broker); } - /* Client 0 is still connected — sub registered, no DISCONNECT yet. */ + /* Client 0 is still connected - sub registered, no DISCONNECT yet. */ ASSERT_FALSE(g_clients[0].closed); /* Phase 2: client 1 connects with the same client_id. The takeover @@ -1732,11 +1732,11 @@ TEST(connack_session_present_v5_set_on_resumed_session) #endif /* WOLFMQTT_V5 */ #ifndef WOLFMQTT_BROKER_WILDCARDS -/* [MQTT-3.8.3-2] (v3.1.1 §3.8.3): when the server does not support +/* [MQTT-3.8.3-2] (v3.1.1 section 3.8.3): when the server does not support * wildcard subscriptions it MUST reject any Subscription request whose - * filter contains '#' or '+'. v5 §3.9.3 reserves reason code 0xA2 + * filter contains '#' or '+'. v5 section 3.9.3 reserves reason code 0xA2 * (Wildcard Subscriptions not supported) for this case, paired with - * the v5 §3.2.2.3.20 Wildcard Subscription Available CONNACK property. + * the v5 section 3.2.2.3.20 Wildcard Subscription Available CONNACK property. * The decoder still accepts the syntactically-valid wildcard filter; * rejection lives in the broker's per-topic SUBACK entry. The plain- * topic case is paired so a "reject everything" mutation also trips. */ @@ -1829,7 +1829,7 @@ TEST(broker_no_wildcards_suback_grants_plain_filter) } #ifdef WOLFMQTT_V5 -/* v5 §3.9.3: Wildcard Subscriptions not supported uses Reason Code +/* v5 section 3.9.3: Wildcard Subscriptions not supported uses Reason Code * 0xA2 rather than the generic 0x80 Failure that v3.1.1 returns. The * broker must surface that distinction so v5 clients receive an * actionable diagnostic. */ @@ -1916,7 +1916,7 @@ TEST(broker_suback_reserved_v311_code_rejected) XMEMSET(tx_buf, 0xAA, sizeof(tx_buf)); rc = BrokerSend_SubAck(&bc, 1, &reserved_codes[i], 1); ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); - /* No SUBACK bytes should have hit the buffer — first byte still + /* No SUBACK bytes should have hit the buffer - first byte still * the 0xAA poison. */ ASSERT_EQ(0xAA, (int)tx_buf[0]); } @@ -1925,7 +1925,7 @@ TEST(broker_suback_reserved_v311_code_rejected) /* Pair: a valid v3.1.1 code (0x80 = Failure) must succeed and overwrite * the buffer. Without this a "reject everything" mutation of the helper * would not be caught. The harness has no real network, so the call - * fails at MqttPacket_Write — we only assert that the helper got past + * fails at MqttPacket_Write - we only assert that the helper got past * the validation branch and into encoding (the type byte ends up at * tx_buf[0]). */ TEST(broker_suback_valid_v311_failure_code_encoded) @@ -2103,13 +2103,13 @@ TEST(retained_qos_stored_0_sub_1_delivers_qos0) /* Pins the QoS 2 outbound wire shape (first byte 0x35, packet_id * present). Without this case the QoS 2 outbound branch of * BrokerRetained_DeliverToClient never produces QoS 2 on the wire of any - * test — the stored=2/sub=1 case caps to QoS 1. */ + * test - the stored=2/sub=1 case caps to QoS 1. */ TEST(retained_qos_stored_2_sub_2_delivers_qos2) { retained_qos_case(MQTT_QOS_2, MQTT_QOS_2, MQTT_QOS_2); } -/* Steepest downgrade: stored QoS 2, subscriber QoS 0 — verifies the +/* Steepest downgrade: stored QoS 2, subscriber QoS 0 - verifies the * retained delivery omits the packet identifier and emits the QoS-0 * wire shape, not a stale identifier from the stored message. */ TEST(retained_qos_stored_2_sub_0_delivers_qos0) diff --git a/tests/test_mqtt_client.c b/tests/test_mqtt_client.c index 780749a68..5f395c36a 100644 --- a/tests/test_mqtt_client.c +++ b/tests/test_mqtt_client.c @@ -88,7 +88,7 @@ static void setup(void) static void teardown(void) { - /* Only DeInit if Init succeeded — DeInit calls MqttProps_ShutDown + /* Only DeInit if Init succeeded - DeInit calls MqttProps_ShutDown * which decrements a ref counter that must be balanced with Init. */ if (test_client_inited) { MqttClient_DeInit(&test_client); @@ -531,7 +531,7 @@ TEST(publish_null_publish) * Control Packet use a Packet Identifier that is not currently in use; * the identifier only becomes reusable after the matching acknowledgement * flow completes. Before the fix, MqttClient_RespList_Add only checked - * that the same MqttPendResp object pointer was not already in the list — + * that the same MqttPendResp object pointer was not already in the list - * it did not reject a different pending entry that reused an in-flight * Packet Identifier. The repro requires both MULTITHREAD (so the pending * response list is in use) and NONBLOCK (so the write-only publish leaves @@ -619,7 +619,7 @@ static int mock_net_read_continue(void *context, byte* buf, int buf_len, int timeout_ms) { (void)context; (void)buf; (void)buf_len; (void)timeout_ms; - /* Return 0 bytes — under WOLFMQTT_NONBLOCK the socket layer translates + /* Return 0 bytes - under WOLFMQTT_NONBLOCK the socket layer translates * this into MQTT_CODE_CONTINUE so MqttClient_Subscribe returns with * its pendResp still registered. */ return 0; @@ -672,238 +672,6 @@ TEST(subscribe_in_flight_blocks_publish_with_same_packet_id) } #endif /* WOLFMQTT_MULTITHREAD && WOLFMQTT_NONBLOCK */ -/* ============================================================================ - * MqttClient_NextPacketId Tests - * ============================================================================ */ - -/* Central allocator must reject NULL clients with the documented sentinel - * (0) rather than crashing or wrapping a NULL into a counter increment. */ -TEST(next_packet_id_null_returns_zero) -{ - ASSERT_EQ(0, MqttClient_NextPacketId(NULL)); -} - -/* Sequential calls must return non-zero values that are distinct from the - * previous one. The contract intentionally does not pin the starting - * value or require strict +1 stepping — a future change could legally - * skip values (e.g., to randomize, or because an in-flight id collided) - * without breaking callers. */ -TEST(next_packet_id_increments) -{ - word16 a, b, c; - int rc; - - rc = test_init_client(); - ASSERT_EQ(MQTT_CODE_SUCCESS, rc); - - a = MqttClient_NextPacketId(&test_client); - b = MqttClient_NextPacketId(&test_client); - c = MqttClient_NextPacketId(&test_client); - - ASSERT_TRUE(a != 0); - ASSERT_TRUE(b != 0); - ASSERT_TRUE(c != 0); - ASSERT_TRUE(a != b); - ASSERT_TRUE(b != c); - ASSERT_TRUE(a != c); -} - -/* [MQTT-2.3.1-1] / 3.1.1 section 2.3.1: 0 is reserved (means "no Packet - * Identifier"), so the allocator must skip it on wrap. Pre-set the counter - * just below the wrap point to drive the wrap branch. */ -TEST(next_packet_id_skips_zero_on_wrap) -{ - int rc; - - rc = test_init_client(); - ASSERT_EQ(MQTT_CODE_SUCCESS, rc); - - /* Next increment will overflow to 0; allocator must skip to 1. */ - test_client.next_packet_id = 0xFFFF; - ASSERT_EQ(1, MqttClient_NextPacketId(&test_client)); - - /* Subsequent call advances normally. */ - ASSERT_EQ(2, MqttClient_NextPacketId(&test_client)); -} - -#if defined(WOLFMQTT_MULTITHREAD) && !defined(_WIN32) && !defined(USE_WINDOWS_API) -#include -#include - -#define ALLOCATOR_THREAD_COUNT 8 -#define ALLOCATOR_PER_THREAD 4000 -#define ALLOCATOR_TOTAL (ALLOCATOR_THREAD_COUNT * ALLOCATOR_PER_THREAD) - -/* Start gate so all threads enter the allocator loop at roughly the same - * time. Without this, pthread_create finishes serially and the test - * effectively measures sequential calls — the very thing the original - * racy version handled correctly. Spinning on a shared flag forces the - * threads to contend on the increment from the start. */ -static volatile int allocator_start; - -typedef struct { - MqttClient *client; - word16 ids[ALLOCATOR_PER_THREAD]; - int saw_zero; -} allocator_thread_arg_t; - -static void* allocator_worker(void *arg) -{ - allocator_thread_arg_t *ta = (allocator_thread_arg_t*)arg; - int i; - while (!allocator_start) { - /* yield to be friendly on oversubscribed CI runners */ - sched_yield(); - } - ta->saw_zero = 0; - for (i = 0; i < ALLOCATOR_PER_THREAD; i++) { - word16 id = MqttClient_NextPacketId(ta->client); - if (id == 0) { - ta->saw_zero = 1; - } - ta->ids[i] = id; - } - return NULL; -} - -/* HIGH-1 regression: prior to holding lockClient across the counter - * advance, two threads racing through the prologue could both publish the - * same next_packet_id and both return the same value. ALLOCATOR_TOTAL is - * well below the 0xFFFF cap, so a correct allocator must hand out - * ALLOCATOR_TOTAL distinct non-zero ids — any duplicate signals the race - * has reappeared. */ -TEST(next_packet_id_concurrent_no_duplicates) -{ - int rc; - pthread_t threads[ALLOCATOR_THREAD_COUNT]; - allocator_thread_arg_t args[ALLOCATOR_THREAD_COUNT]; - static byte seen[0x10000]; - int t, i; - int spawned = 0; - int spawn_failed = 0; - - rc = test_init_client(); - ASSERT_EQ(MQTT_CODE_SUCCESS, rc); - - allocator_start = 0; - for (t = 0; t < ALLOCATOR_THREAD_COUNT; t++) { - args[t].client = &test_client; - args[t].saw_zero = 0; - rc = pthread_create(&threads[t], NULL, allocator_worker, &args[t]); - if (rc != 0) { - spawn_failed = 1; - break; - } - spawned++; - } - - /* Release any threads we did spawn so their spin-gate exits and we can - * join them — even on the failure path. Skipping the gate would leak a - * spinning thread that consumes a core for the remainder of the run. */ - allocator_start = 1; - for (t = 0; t < spawned; t++) { - (void)pthread_join(threads[t], NULL); - } - ASSERT_EQ(0, spawn_failed); - - XMEMSET(seen, 0, sizeof(seen)); - for (t = 0; t < ALLOCATOR_THREAD_COUNT; t++) { - ASSERT_EQ(0, args[t].saw_zero); - for (i = 0; i < ALLOCATOR_PER_THREAD; i++) { - word16 id = args[t].ids[i]; - if (seen[id]) { - FAIL("duplicate packet_id returned by concurrent allocator"); - } - seen[id] = 1; - } - } - /* seen[0] must remain 0 — confirms no thread observed the 0 sentinel. */ - ASSERT_EQ(0, (int)seen[0]); -} -#endif /* WOLFMQTT_MULTITHREAD && POSIX */ - -#ifdef WOLFMQTT_MULTITHREAD -/* When every legal packet_id (1..0xFFFF) is in flight the allocator must - * surface the documented "back off" sentinel (0) rather than wrapping - * around forever or returning a colliding value. Constructs a synthetic - * pendResp chain covering the full range and asserts the return. */ -TEST(next_packet_id_returns_zero_when_saturated) -{ - int rc; - word32 i; - word32 count = (word32)MAX_PACKET_ID; - MqttPendResp *entries; - - rc = test_init_client(); - ASSERT_EQ(MQTT_CODE_SUCCESS, rc); - - entries = (MqttPendResp*)WOLFMQTT_MALLOC(sizeof(MqttPendResp) * count); - ASSERT_NOT_NULL(entries); - - /* Build a chain with packet_id = 1..MAX_PACKET_ID. The allocator only - * reads packet_id and walks `next`, so other fields can stay zeroed. */ - XMEMSET(entries, 0, sizeof(MqttPendResp) * count); - for (i = 0; i < count; i++) { - entries[i].packet_id = (word16)(i + 1); - entries[i].next = (i + 1 < count) ? &entries[i + 1] : NULL; - entries[i].prev = (i > 0) ? &entries[i - 1] : NULL; - } - test_client.firstPendResp = &entries[0]; - test_client.lastPendResp = &entries[count - 1]; - - ASSERT_EQ(0, MqttClient_NextPacketId(&test_client)); - - /* Detach the synthetic chain before teardown — DeInit must not walk - * into stack memory we are about to free. */ - test_client.firstPendResp = NULL; - test_client.lastPendResp = NULL; - WOLFMQTT_FREE(entries); -} -#endif /* WOLFMQTT_MULTITHREAD */ - -#if defined(WOLFMQTT_MULTITHREAD) && defined(WOLFMQTT_NONBLOCK) -/* The allocator must consult the in-flight set (the pending response list) - * and skip any value that is currently registered. We register an - * in-flight QoS 1 publish at packet_id=5 via the public write-only API, - * then rewind the counter so the natural next allocation would be 5; the - * allocator must walk past it and return 6. */ -TEST(next_packet_id_skips_in_flight) -{ - int rc; - word16 allocated; - MqttPublish publish; - static byte payload[] = "p"; - - rc = test_init_client(); - ASSERT_EQ(MQTT_CODE_SUCCESS, rc); - - test_net.write = mock_net_write_accept; - - /* Park a pendResp with packet_id=5 in the resp list. */ - XMEMSET(&publish, 0, sizeof(publish)); - publish.qos = MQTT_QOS_1; - publish.packet_id = 5; - publish.topic_name = "t"; - publish.buffer = payload; - publish.total_len = (word32)(sizeof(payload) - 1); - publish.buffer_len = publish.total_len; - rc = MqttClient_Publish_WriteOnly(&test_client, &publish, NULL); - ASSERT_EQ(MQTT_CODE_CONTINUE, rc); - - /* Rewind so the next ++ yields 5. The allocator must skip 5 (in-flight) - * and return 6. */ - test_client.next_packet_id = 4; - allocated = MqttClient_NextPacketId(&test_client); - ASSERT_EQ(6, allocated); - - /* Once the in-flight entry is released, 5 becomes reusable again. */ - rc = MqttClient_CancelMessage(&test_client, (MqttObject*)&publish); - ASSERT_EQ(MQTT_CODE_SUCCESS, rc); - test_client.next_packet_id = 4; - allocated = MqttClient_NextPacketId(&test_client); - ASSERT_EQ(5, allocated); -} -#endif /* WOLFMQTT_MULTITHREAD && WOLFMQTT_NONBLOCK */ /* ============================================================================ * MqttClient_WaitMessage Tests @@ -1031,21 +799,6 @@ void run_mqtt_client_tests(void) RUN_TEST(subscribe_in_flight_blocks_publish_with_same_packet_id); #endif - /* MqttClient_NextPacketId tests */ - RUN_TEST(next_packet_id_null_returns_zero); - RUN_TEST(next_packet_id_increments); - RUN_TEST(next_packet_id_skips_zero_on_wrap); -#if defined(WOLFMQTT_MULTITHREAD) && !defined(_WIN32) && \ - !defined(USE_WINDOWS_API) - RUN_TEST(next_packet_id_concurrent_no_duplicates); -#endif -#ifdef WOLFMQTT_MULTITHREAD - RUN_TEST(next_packet_id_returns_zero_when_saturated); -#endif -#if defined(WOLFMQTT_MULTITHREAD) && defined(WOLFMQTT_NONBLOCK) - RUN_TEST(next_packet_id_skips_in_flight); -#endif - /* MqttClient_WaitMessage tests */ RUN_TEST(wait_message_null_client); diff --git a/tests/test_mqtt_packet.c b/tests/test_mqtt_packet.c index 79987a7e7..48e1a58ad 100644 --- a/tests/test_mqtt_packet.c +++ b/tests/test_mqtt_packet.c @@ -471,7 +471,7 @@ TEST(decode_string_utf8_invalid_overlong_4byte) TEST(decode_string_utf8_invalid_surrogate_low) { - /* U+D800 = ED A0 80 — first surrogate, [MQTT-1.5.3-1] forbids. */ + /* U+D800 = ED A0 80 - first surrogate, [MQTT-1.5.3-1] forbids. */ byte t[] = { 0xED, 0xA0, 0x80 }; int rc = decode_subscribe_with_topic(t, (word16)sizeof(t)); ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); @@ -479,7 +479,7 @@ TEST(decode_string_utf8_invalid_surrogate_low) TEST(decode_string_utf8_invalid_surrogate_high) { - /* U+DFFF = ED BF BF — last surrogate */ + /* U+DFFF = ED BF BF - last surrogate */ byte t[] = { 0xED, 0xBF, 0xBF }; int rc = decode_subscribe_with_topic(t, (word16)sizeof(t)); ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); @@ -503,7 +503,7 @@ TEST(decode_string_utf8_invalid_f5_leading) TEST(decode_string_utf8_invalid_lone_continuation) { - /* 0x80 alone — continuation byte without a leading byte. */ + /* 0x80 alone - continuation byte without a leading byte. */ byte t[] = { 0x80 }; int rc = decode_subscribe_with_topic(t, (word16)sizeof(t)); ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); @@ -536,7 +536,7 @@ TEST(decode_string_utf8_invalid_FE_FF) TEST(decode_connect_invalid_utf8_clientid_overlong) { /* CONNECT v3.1.1 with ClientId bytes 0xC0 0xAF (overlong). Reporter's - * dynamic test case `connect_clientid_overlong` — should now refuse. + * dynamic test case `connect_clientid_overlong` - should now refuse. * Wire: 0x10 + remain=14, "MQTT", level=4, flags=0x02, keepalive=60, * client_id_len=0x0002, [C0 AF]. */ byte rx_buf[] = { @@ -618,7 +618,7 @@ TEST(decode_publish_invalid_utf8_topic) #ifdef WOLFMQTT_BROKER /* CONNECT with a binary (non-UTF-8) password must decode successfully, - * because MQTT defines the Password field as Binary Data — not a UTF-8 + * because MQTT defines the Password field as Binary Data - not a UTF-8 * string. The diff routes the password decode around MqttDecode_String to * avoid spuriously rejecting valid binary passwords containing bytes like * 0xC0 (which are not legal UTF-8 leading bytes). */ @@ -688,7 +688,7 @@ TEST(decode_connect_invalid_utf8_username) /* [MQTT-1.5.3-1] / [MQTT-3.1.3-11]: ill-formed UTF-8 in CONNECT User * Name MUST cause the receiver to close the connection. The companion * test above covers a surrogate; this one covers the overlong-encoding - * bucket (C0 AF — the overlong representation of '/'). MqttDecode_String + * bucket (C0 AF - the overlong representation of '/'). MqttDecode_String * routes the field through Utf8WellFormed, which rejects both. */ TEST(decode_connect_invalid_utf8_username_overlong) { @@ -944,17 +944,19 @@ TEST(encode_publish_topic_oversized_rejected) /* Pin the helper truth table for both v3.1.1 (level=0/4) and v5 * (level=5). Wildcards are forbidden in either version * ([MQTT-3.3.2-2]); empty is rejected in v3.1.1 ([MQTT-4.7.3-1]) but - * permitted in v5 (§3.3.2.3.4 — empty Topic Name with Topic Alias). */ + * permitted in v5 (section 3.3.2.3.4 - empty Topic Name with Topic Alias). */ TEST(topic_name_valid_helper_table) { byte v311 = MQTT_CONNECT_PROTOCOL_LEVEL_4; byte v5 = MQTT_CONNECT_PROTOCOL_LEVEL_5; - /* Empty: rejected in v3.1.1, accepted in v5. */ + /* Empty: rejected in v3.1.1, accepted in v5 - but only via empty + * string, not NULL. NULL is API misuse regardless of len; v5 Topic + * Alias placeholders must use "". */ ASSERT_EQ(0, MqttPacket_TopicNameValid(NULL, 0, v311)); ASSERT_EQ(0, MqttPacket_TopicNameValid("", 0, v311)); - ASSERT_EQ(1, MqttPacket_TopicNameValid(NULL, 0, v5)); + ASSERT_EQ(0, MqttPacket_TopicNameValid(NULL, 0, v5)); ASSERT_EQ(1, MqttPacket_TopicNameValid("", 0, v5)); - /* NULL with non-zero len is malformed in any version. */ + /* NULL with non-zero len is also malformed. */ ASSERT_EQ(0, MqttPacket_TopicNameValid(NULL, 1, v5)); /* Plain topics. */ ASSERT_EQ(1, MqttPacket_TopicNameValid("a", 1, v311)); @@ -1005,7 +1007,7 @@ TEST(encode_publish_wildcard_topic_rejected) } /* NULL topic_name is API misuse (separate from a malformed wire - * packet) — surface it as BAD_ARG to match the surrounding NULL-check + * packet) - surface it as BAD_ARG to match the surrounding NULL-check * convention in this function. */ TEST(encode_publish_null_topic_returns_bad_arg) { @@ -1021,7 +1023,7 @@ TEST(encode_publish_null_topic_returns_bad_arg) } #ifdef WOLFMQTT_V5 -/* MQTT v5 §3.3.2.3.4: a zero-length Topic Name is permitted when paired +/* MQTT v5 section 3.3.2.3.4: a zero-length Topic Name is permitted when paired * with a Topic Alias property. The encoder must not reject the empty * topic on the v5 path; the alias-empty pairing is the application's * responsibility. */ @@ -1188,7 +1190,7 @@ TEST(decode_publish_wildcard_plus_topic_rejected) ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); } -/* [MQTT-1.5.3-2] U+0000 forbidden in PUBLISH Topic Name as well — broaden +/* [MQTT-1.5.3-2] U+0000 forbidden in PUBLISH Topic Name as well - broaden * coverage of the new check to a non-CONNECT entry point. */ TEST(decode_publish_topic_contains_u0000_rejected) { @@ -1207,7 +1209,7 @@ TEST(decode_publish_topic_contains_u0000_rejected) } #ifdef WOLFMQTT_V5 -/* MQTT v5 §3.3.2.3.4: a zero-length Topic Name is permitted (paired +/* MQTT v5 section 3.3.2.3.4: a zero-length Topic Name is permitted (paired * with a Topic Alias property at the application layer). Wire shape: * PUBLISH | QoS 0, remain=4, topic_len=0, props_len=0, payload "x". */ TEST(decode_publish_v5_empty_topic_accepted) @@ -1347,7 +1349,7 @@ TEST(decode_publish_v5_rejects_nul_in_string_property) MqttProps_Free(pub.props); } -/* STRING_PAIR (USER_PROPERTY) NUL rejection — first string of pair. The +/* STRING_PAIR (USER_PROPERTY) NUL rejection - first string of pair. The * MqttDecode_Props path runs MqttDecode_String twice for STRING_PAIR; * this pins coverage on the first sub-decode propagating MALFORMED_DATA. */ TEST(decode_publish_v5_rejects_nul_in_user_prop_key) @@ -1371,7 +1373,7 @@ TEST(decode_publish_v5_rejects_nul_in_user_prop_key) MqttProps_Free(pub.props); } -/* STRING_PAIR (USER_PROPERTY) NUL rejection — second string of pair. +/* STRING_PAIR (USER_PROPERTY) NUL rejection - second string of pair. * Pins coverage on the second sub-decode propagating MALFORMED_DATA. */ TEST(decode_publish_v5_rejects_nul_in_user_prop_value) { @@ -1679,7 +1681,7 @@ TEST(encode_subscribe_topic_filter_oversized_rejected) } /* when multiple topics are supplied and a later one is oversized, - * the encoder must still reject — the length-validation loop covers every + * the encoder must still reject - the length-validation loop covers every * entry, not just the first. */ TEST(encode_subscribe_topic_filter_oversized_second_rejected) { @@ -1829,7 +1831,7 @@ TEST(encode_unsubscribe_topic_filter_oversized_rejected) } /* when multiple topics are supplied and a later one is oversized, - * the encoder must still reject — the length-validation loop covers every + * the encoder must still reject - the length-validation loop covers every * entry, not just the first. */ TEST(encode_unsubscribe_topic_filter_oversized_second_rejected) { @@ -2415,7 +2417,7 @@ TEST(decode_connect_password_flag_zero_with_extra_payload_rejected) 0x00, 0x03, 'c', 'i', 'd', /* client_id "cid" */ 0x00, 0x04, 'u', 's', 'e', 'r', /* username "user" */ 0x00, 0x06, 's', 'e', 'c', 'r', /* extra password-shaped bytes */ - 'e', 't' /* "secret" — must not be accepted */ + 'e', 't' /* "secret" - must not be accepted */ }; MqttConnect dec; int rc; @@ -2605,7 +2607,7 @@ TEST(decode_connect_will_retain_with_will_flag_zero_rejected) /* [MQTT-3.1.2-14] Will QoS = 3 is reserved. Flags 0x1E set the full QoS * mask (bits 4-3 = 0b11) along with Will Flag and Clean Session. The - * earlier Will-Flag-0 check would not catch this — only the QoS-value + * earlier Will-Flag-0 check would not catch this - only the QoS-value * check fires. Provides full Will fields so a regression that drops the * QoS=3 check returns success rather than tripping a downstream * OUT_OF_BUFFER. */ @@ -2764,7 +2766,7 @@ TEST(decode_connect_v5_rejects_nul_in_client_id) MqttProps_Free(dec.props); } -/* MQTT v5 section 3.1.2.9 explicitly allows Password without User Name — +/* MQTT v5 section 3.1.2.9 explicitly allows Password without User Name - * "This version of the protocol allows the sending of a Password with no * User Name, where MQTT v3.1.1 did not." Pins the protocol-level gate on * the [MQTT-3.1.2-22] check: a future change that drops the level guard @@ -2797,6 +2799,39 @@ TEST(decode_connect_v5_password_without_username_accepted) ASSERT_EQ(0, XMEMCMP(dec.password, "secret", 6)); MqttProps_Free(dec.props); } + +/* Pins the goto-cleanup path: when a v5 CONNECT decode succeeds through + * the Properties block but fails on a later field (here the client_id + * trips the embedded-NUL check inside MqttDecode_String), the decoder + * must free the already-allocated property list and null the pointer + * before returning. A regression that drops the cleanup label and goes + * back to bare returns would leak the User Property allocation - this + * test alone won't see the leak (no valgrind in CI), but it does catch + * the structural invariant: dec.props == NULL on error. */ +TEST(decode_connect_v5_props_freed_on_client_id_error) +{ + byte buf[] = { + 0x10, 0x1A, /* CONNECT, remain_len = 26 */ + 0x00, 0x04, 'M', 'Q', 'T', 'T', + 0x05, /* protocol level v5 */ + 0x02, /* flags: clean_session */ + 0x00, 0x3C, /* keep alive */ + 0x07, /* props_len VBI = 7 */ + 0x26, /* User Property */ + 0x00, 0x01, 'k', /* key "k" */ + 0x00, 0x01, 'v', /* value "v" */ + 0x00, 0x06, 'a', 'd', 0x00, 'm', 'i', 'n' /* client_id with NUL */ + }; + MqttConnect dec; + int rc; + + XMEMSET(&dec, 0, sizeof(dec)); + dec.protocol_level = MQTT_CONNECT_PROTOCOL_LEVEL_5; + rc = MqttDecode_Connect(buf, (int)sizeof(buf), &dec); + ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); + /* Cleanup must have freed the props list and nulled the pointer. */ + ASSERT_NULL(dec.props); +} #endif /* WOLFMQTT_V5 */ #endif /* WOLFMQTT_BROKER */ @@ -2833,7 +2868,7 @@ TEST(decode_subscribe_v311_single_topic) ASSERT_EQ(0, XMEMCMP(topic_arr[0].topic_filter, "a", 1)); } -/* MQTT 3.1.1 §3.8.3.1: Requested QoS bits (0-1) = 0b11 is reserved and +/* MQTT 3.1.1 section 3.8.3.1: Requested QoS bits (0-1) = 0b11 is reserved and * MUST be rejected. Pre-fix the decoder forwarded the raw value and * relied on the broker's defensive QoS cap; the broker cap is now dead * code on the decoded path but kept for safety. */ @@ -2857,7 +2892,7 @@ TEST(decode_subscribe_v311_qos3_rejected) ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); } -/* MQTT 3.1.1 §3.8.3.1: bits 2-7 of the options byte are reserved and +/* MQTT 3.1.1 section 3.8.3.1: bits 2-7 of the options byte are reserved and * MUST be 0. Wire has the high six bits set (0xFC) with low bits = QoS * 0. The unmasked v3.x decoder used to drop the reserved bits and * accept QoS 0 silently. */ @@ -2934,7 +2969,7 @@ TEST(decode_subscribe_empty_payload_rejected) { byte rx_buf[] = { 0x82, 0x02, - 0x00, 0x01 /* packet_id only — no topics */ + 0x00, 0x01 /* packet_id only - no topics */ }; MqttSubscribe sub; MqttTopic topic_arr[1]; @@ -2948,7 +2983,7 @@ TEST(decode_subscribe_empty_payload_rejected) } #ifdef WOLFMQTT_V5 -/* v5 §3.8.3 carries the same minimum-cardinality requirement as +/* v5 section 3.8.3 carries the same minimum-cardinality requirement as * [MQTT-3.8.3-3]. The v5 path is distinct: it consumes a Properties VBI * before reaching the topic loop. Wire is remain_len=3 = packet_id + * props_len=0, so the topic loop runs zero iterations. Without this @@ -3030,7 +3065,7 @@ TEST(decode_subscribe_empty_topic_filter_rejected) /* [MQTT-4.7.1-2] '#' must be solo or follow '/' and be the last char. */ TEST(decode_subscribe_bad_hash_placement_rejected) { - /* "a#" — '#' embedded in a level. */ + /* "a#" - '#' embedded in a level. */ byte rx_buf[] = { 0x82, 0x07, 0x00, 0x01, @@ -3050,7 +3085,7 @@ TEST(decode_subscribe_bad_hash_placement_rejected) TEST(decode_subscribe_hash_not_last_rejected) { - /* "sp/#/r" — '#' is not the final character. */ + /* "sp/#/r" - '#' is not the final character. */ byte rx_buf[] = { 0x82, 0x0B, 0x00, 0x01, @@ -3071,7 +3106,7 @@ TEST(decode_subscribe_hash_not_last_rejected) /* [MQTT-4.7.1-3] '+' must occupy an entire topic level. */ TEST(decode_subscribe_bad_plus_placement_rejected) { - /* "a+b" — '+' embedded in a level. */ + /* "a+b" - '+' embedded in a level. */ byte rx_buf[] = { 0x82, 0x08, 0x00, 0x01, @@ -3144,7 +3179,7 @@ TEST(decode_subscribe_v5_options_byte_qos_extracted) ASSERT_EQ(MQTT_QOS_1, topic_arr[0].qos); } -/* MQTT v5 §3.8.3.1: Requested QoS = 3 is reserved. The v3.1.1 test +/* MQTT v5 section 3.8.3.1: Requested QoS = 3 is reserved. The v3.1.1 test * above takes a different branch (protocol_level = 0), so without this * test the v5 `(options & 0x03) > MQTT_QOS_2` clause is only covered * transitively through the broader options-byte check. A refactor that @@ -3172,7 +3207,7 @@ TEST(decode_subscribe_v5_qos3_rejected) ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); } -/* MQTT v5 §3.8.3.1: Retain Handling = 3 is reserved and MUST be +/* MQTT v5 section 3.8.3.1: Retain Handling = 3 is reserved and MUST be * rejected. Bits 4-5 = 0b11 sets that condition. */ TEST(decode_subscribe_v5_retain_handling_3_rejected) { @@ -3196,7 +3231,7 @@ TEST(decode_subscribe_v5_retain_handling_3_rejected) ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); } -/* MQTT v5 §3.8.3.1: bits 6-7 of the options byte are reserved and MUST +/* MQTT v5 section 3.8.3.1: bits 6-7 of the options byte are reserved and MUST * be 0. Bits 0-5 are otherwise valid (QoS 0, RH=0, RAP=0, NL=0). */ TEST(decode_subscribe_v5_options_reserved_bits_rejected) { @@ -3226,7 +3261,7 @@ TEST(decode_subscribe_v5_options_reserved_bits_rejected) * ============================================================================ */ /* [MQTT-1.5.3-2] / [MQTT-4.7.3-2]: a topic filter containing U+0000 in an - * UNSUBSCRIBE must be rejected — MqttDecode_Unsubscribe shares the same + * UNSUBSCRIBE must be rejected - MqttDecode_Unsubscribe shares the same * MqttDecode_String chokepoint that SUBSCRIBE uses. */ TEST(decode_unsubscribe_rejects_nul_in_filter) { @@ -3254,7 +3289,7 @@ TEST(decode_unsubscribe_empty_payload_rejected) { byte rx_buf[] = { 0xA2, 0x02, - 0x00, 0x01 /* packet_id only — no topics */ + 0x00, 0x01 /* packet_id only - no topics */ }; MqttUnsubscribe unsub; MqttTopic topic_arr[1]; @@ -3268,7 +3303,7 @@ TEST(decode_unsubscribe_empty_payload_rejected) } #ifdef WOLFMQTT_V5 -/* v5 §3.10.3 carries the same minimum-cardinality requirement as +/* v5 section 3.10.3 carries the same minimum-cardinality requirement as * [MQTT-3.10.3-2]. Wire is remain_len=3 = packet_id + props_len=0. */ TEST(decode_unsubscribe_v5_empty_payload_rejected) { @@ -3293,7 +3328,7 @@ TEST(decode_unsubscribe_v5_empty_payload_rejected) /* UNSUBSCRIBE shares the same Topic Filter syntax rules as SUBSCRIBE * ([MQTT-4.7.3-1], [MQTT-4.7.1-2], [MQTT-4.7.1-3]). The decoder uses * the same MqttPacket_TopicFilterValid helper so a single sample per - * rule is enough — exhaustive coverage lives in the helper table test. */ + * rule is enough - exhaustive coverage lives in the helper table test. */ TEST(decode_unsubscribe_empty_topic_filter_rejected) { byte rx_buf[] = { @@ -3314,7 +3349,7 @@ TEST(decode_unsubscribe_empty_topic_filter_rejected) TEST(decode_unsubscribe_bad_plus_placement_rejected) { - /* "a+b" — '+' embedded in a level. */ + /* "a+b" - '+' embedded in a level. */ byte rx_buf[] = { 0xA2, 0x07, 0x00, 0x01, @@ -3484,7 +3519,7 @@ TEST(suback_return_code_v311_allowed_set) } #ifdef WOLFMQTT_V5 -/* v5 §3.9.3: SUBACK Reason Code set is broader. The decoder must accept +/* v5 section 3.9.3: SUBACK Reason Code set is broader. The decoder must accept * v5 reason codes that are not in the v3.1.1 set when protocol_level=5. */ TEST(decode_suback_v5_not_authorized_accepted) { @@ -3570,9 +3605,9 @@ TEST(decode_publish_resp_malformed_remain_len_one) ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); } -/* MQTT 3.1.1 §3.4-3.7: PUBACK/PUBREC/PUBREL/PUBCOMP have a fixed +/* MQTT 3.1.1 sections 3.4-3.7: PUBACK/PUBREC/PUBREL/PUBCOMP have a fixed * Remaining Length of 2 (the Packet Identifier only). Any extra byte - * after the Packet Identifier is malformed in v3.x. v5 §3.4-3.7 + * after the Packet Identifier is malformed in v3.x. v5 sections 3.4-3.7 * relaxes this with an optional Reason Code and Properties; the * `protocol_level` field on the response struct selects between the * strict and relaxed decoders. The wire carries an extra trailing @@ -3625,7 +3660,7 @@ TEST(decode_pubcomp_v311_extra_payload_rejected) ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); } -/* Positive cases for PUBREC/PUBREL/PUBCOMP — decode_publish_resp_valid +/* Positive cases for PUBREC/PUBREL/PUBCOMP - decode_publish_resp_valid * already covers PUBACK. Without these, a regression that flips the * length check into "always reject" would still leave 3/4 packet types * silently broken with only PUBACK signalling failure. */ @@ -3684,9 +3719,9 @@ TEST(decode_puback_null_resp_extra_payload_rejected) } #ifdef WOLFMQTT_V5 -/* v5 §3.4-3.7 explicitly allow longer PUBACK/PUBREC/PUBREL/PUBCOMP with +/* v5 sections 3.4-3.7 explicitly allow longer PUBACK/PUBREC/PUBREL/PUBCOMP with * a Reason Code (1 byte) and a Properties block. Pins the v5 gate so the - * v3.x exact-length check doesn't regress onto v5 — the wire is + * v3.x exact-length check doesn't regress onto v5 - the wire is * remain_len = 4 = packet_id + reason_code + props_len(0). */ TEST(decode_puback_v5_with_reason_code_accepted) { @@ -3835,8 +3870,8 @@ TEST(decode_unsuback_malformed_remain_len_one) /* ============================================================================ * MqttDecode_Ping (PINGRESP) and MqttDecode_Disconnect length validation * - * MQTT 3.1.1 §3.13 / §3.14 and v5 §3.13: PINGRESP has no variable header - * and no payload. v3.1.1 §3.14: DISCONNECT also has none. The decoders + * MQTT 3.1.1 section 3.13 / section 3.14 and v5 section 3.13: PINGRESP has no variable header + * and no payload. v3.1.1 section 3.14: DISCONNECT also has none. The decoders * must reject Remaining Length != 0; otherwise a peer can smuggle in * trailing bytes that downstream code silently drops. * ============================================================================ */ @@ -3852,7 +3887,7 @@ TEST(decode_pingresp_valid) ASSERT_EQ(2, rc); } -/* PINGRESP with one trailing byte — must be rejected as malformed. +/* PINGRESP with one trailing byte - must be rejected as malformed. * Without the fix the decoder returned 3 (the packet length). */ TEST(decode_pingresp_nonzero_remain_len_rejected) { @@ -3892,7 +3927,7 @@ TEST(decode_disconnect_v311_nonzero_remain_len_rejected) ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); } -/* MQTT 3.1.1 §3.14.1 / [MQTT-2.2.2-2]: DISCONNECT fixed-header low +/* MQTT 3.1.1 section 3.14.1 / [MQTT-2.2.2-2]: DISCONNECT fixed-header low * nibble MUST be 0000. Wire 0xE1 sets bit 0 of the reserved nibble. * The check fires inside MqttDecode_FixedHeader via * MqttPacket_FixedHeaderFlagsValid; this test pins the per-decoder @@ -3911,7 +3946,7 @@ TEST(decode_disconnect_v311_invalid_fixed_header_flags_rejected) #endif /* WOLFMQTT_BROKER && !WOLFMQTT_V5 */ #ifdef WOLFMQTT_V5 -/* v5 §3.14 keeps the same fixed-header reserved-flag rule. Pins the v5 +/* v5 section 3.14 keeps the same fixed-header reserved-flag rule. Pins the v5 * decoder against the same regression on its independent code path. */ TEST(decode_disconnect_v5_invalid_fixed_header_flags_rejected) { @@ -3924,7 +3959,7 @@ TEST(decode_disconnect_v5_invalid_fixed_header_flags_rejected) ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc); } -/* v5 §3.14: DISCONNECT may carry an optional Reason Code (1 byte) and a +/* v5 section 3.14: DISCONNECT may carry an optional Reason Code (1 byte) and a * Properties block. Pins the v5 decoder against a regression that would * tighten the v3.1.1 remain_len rule onto v5 by mistake. Wire is * remain_len = 2 = reason_code + props_len=0. */ @@ -3980,19 +4015,19 @@ TEST(fixed_header_flags_valid_canonical_values) } /* Type 0 (RESERVED) is not a defined MQTT packet type. The helper must - * reject it so callers — including the broker pre-dispatch check — can + * reject it so callers - including the broker pre-dispatch check - can * treat any accepted byte as a known type. */ TEST(fixed_header_flags_valid_reserved_type_rejected) { ASSERT_EQ(0, MqttPacket_FixedHeaderFlagsValid(0x00)); /* Every flag-nibble combination on the reserved type must be - * rejected — the type itself is the failure, not the nibble. */ + * rejected - the type itself is the failure, not the nibble. */ ASSERT_EQ(0, MqttPacket_FixedHeaderFlagsValid(0x01)); ASSERT_EQ(0, MqttPacket_FixedHeaderFlagsValid(0x0F)); } #ifdef WOLFMQTT_BROKER -/* Reserved-type packet on the wire — broker pre-dispatch must reject +/* Reserved-type packet on the wire - broker pre-dispatch must reject * via the FixedHeaderFlagsValid gate. Exercises the decoder boundary * separately from the helper unit test above. */ TEST(decode_fixed_header_reserved_type_rejected) @@ -4572,7 +4607,7 @@ void run_mqtt_packet_tests(void) RUN_TEST(decode_connect_v311_with_lwt); RUN_TEST(decode_connect_rejects_nul_in_client_id); RUN_TEST(decode_connect_rejects_nul_in_username); - /* Note: decode_connect_v311_binary_password covers the password path — + /* Note: decode_connect_v311_binary_password covers the password path - * Password is Binary Data per [MQTT-3.1.3.5] and decoding is routed * around MqttDecode_String, so the U+0000 ban does not apply there. */ RUN_TEST(decode_connect_rejects_nul_in_will_topic); @@ -4591,6 +4626,7 @@ void run_mqtt_packet_tests(void) #ifdef WOLFMQTT_V5 RUN_TEST(decode_connect_v5_rejects_nul_in_client_id); RUN_TEST(decode_connect_v5_password_without_username_accepted); + RUN_TEST(decode_connect_v5_props_freed_on_client_id_error); #endif /* MqttDecode_Subscribe */ diff --git a/wolfmqtt/mqtt_broker.h b/wolfmqtt/mqtt_broker.h index 146b676cd..da85d2428 100644 --- a/wolfmqtt/mqtt_broker.h +++ b/wolfmqtt/mqtt_broker.h @@ -247,12 +247,6 @@ typedef struct BrokerClient { WOLFMQTT_BROKER_TIME_T last_rx; byte clean_session; byte connected; /* set after successful CONNECT handshake */ -#ifdef WOLFMQTT_BROKER_AUTH - /* Actual stored length of password bytes. Tracked separately because - * [MQTT-3.1.3.5] defines Password as Binary Data, which may legally - * contain 0x00 — XSTRLEN would truncate at the first embedded NUL. */ - word16 password_len; -#endif #ifdef WOLFMQTT_BROKER_WILL byte has_will; word16 will_payload_len; @@ -263,6 +257,18 @@ typedef struct BrokerClient { MqttNet net; MqttClient client; struct MqttBroker* broker; /* back-pointer to parent broker context */ +#ifdef ENABLE_MQTT_TLS + byte tls_handshake_done; +#endif +#ifdef ENABLE_MQTT_WEBSOCKET + void *ws_ctx; /* BrokerWsCtx* (NULL for TCP clients) */ +#endif +#ifdef WOLFMQTT_BROKER_AUTH + /* Actual stored length of password bytes. Tracked separately because + * [MQTT-3.1.3.5] defines Password as Binary Data, which may legally + * contain 0x00 - XSTRLEN would truncate at the first embedded NUL. */ + word16 password_len; +#endif /* [MQTT-4.3.3] Inbound QoS 2 packet IDs that have been PUBREC'd but * not yet PUBREL'd. A duplicate PUBLISH carrying one of these IDs is * acked again (PUBREC) but NOT re-fanned-out to subscribers. The @@ -274,12 +280,6 @@ typedef struct BrokerClient { BrokerInboundQos2* qos2_pending; int qos2_pending_count; #endif -#ifdef ENABLE_MQTT_TLS - byte tls_handshake_done; -#endif -#ifdef ENABLE_MQTT_WEBSOCKET - void *ws_ctx; /* BrokerWsCtx* (NULL for TCP clients) */ -#endif } BrokerClient; /* -------------------------------------------------------------------------- */ @@ -438,6 +438,13 @@ WOLFMQTT_API int MqttBrokerNet_Init(MqttBrokerNet* net); /* CLI wrapper interface */ WOLFMQTT_API int wolfmqtt_broker(int argc, char** argv); +/* -------------------------------------------------------------------------- */ +/* Local declarations */ +/* -------------------------------------------------------------------------- */ +WOLFMQTT_LOCAL int BrokerSend_SubAck(BrokerClient* bc, word16 packet_id, + const byte* return_codes, int return_code_count); + + #endif /* WOLFMQTT_BROKER */ #ifdef __cplusplus diff --git a/wolfmqtt/mqtt_client.h b/wolfmqtt/mqtt_client.h index 03bb4f686..ea30cc635 100644 --- a/wolfmqtt/mqtt_client.h +++ b/wolfmqtt/mqtt_client.h @@ -218,7 +218,6 @@ typedef struct _MqttClient { #if defined(WOLFMQTT_NONBLOCK) && defined(WOLFMQTT_DEBUG_CLIENT) int lastRc; #endif - word16 next_packet_id; /* allocator state for MqttClient_NextPacketId */ } MqttClient; #ifdef WOLFMQTT_SN @@ -582,27 +581,6 @@ WOLFMQTT_API int MqttClient_GetProtocolVersion(MqttClient *client); */ WOLFMQTT_API const char* MqttClient_GetProtocolVersionString(MqttClient *client); -/*! \brief Allocate the next currently-unused MQTT Packet Identifier for - * this client. - * - * Per [MQTT-2.3.1-1] / MQTT 3.1.1 section 2.3.1, a new QoS-related Control - * Packet (PUBLISH with QoS > 0, SUBSCRIBE, UNSUBSCRIBE) must use a Packet - * Identifier that is not currently in use; the value only becomes - * reusable after the corresponding acknowledgement flow completes. This - * function is the central allocator: it advances a per-client counter, - * skips zero, and (under WOLFMQTT_MULTITHREAD) skips any value that is - * currently tracked in the pending response list. - * - * \param client Pointer to MqttClient structure - * \return Non-zero Packet Identifier on success, - * 0 if the client pointer is NULL, - * the in-use set is full (all 65535 values active), or - * (under WOLFMQTT_MULTITHREAD) the client lock cannot be - * acquired. In all cases the caller must back off rather - * than reuse a value. - */ -WOLFMQTT_API word16 MqttClient_NextPacketId(MqttClient *client); - #ifndef WOLFMQTT_NO_ERROR_STRINGS /*! \brief Performs lookup of the WOLFMQTT_API return values * \param return_code The return value from a WOLFMQTT_API function diff --git a/wolfmqtt/mqtt_packet.h b/wolfmqtt/mqtt_packet.h index bbe7b0ae4..bc60388f8 100644 --- a/wolfmqtt/mqtt_packet.h +++ b/wolfmqtt/mqtt_packet.h @@ -48,6 +48,17 @@ #define MAX_MQTT_TOPICS 12 #endif +/* WOLFMQTT_NO_UTF8_VALIDATION + * Define to disable RFC 3629 UTF-8 well-formedness validation in + * MqttDecode_String. Spec requirement [MQTT-1.5.3-1] (v3.1.1 1.5.3 / + * v5 1.5.4) makes ill-formed UTF-8 a "MUST close the network + * connection" condition; disabling the check trades that compliance + * for ~300 bytes of .text on x86-64 (~200 bytes on ARM Thumb-2) and + * should only be considered for severely flash-constrained targets + * where the peer is known-trusted. The independent embedded-NUL + * check ([MQTT-1.5.3-2]) remains active either way because it also + * guards downstream C-string handling. */ + #ifdef WOLFMQTT_V5 #define MQTT_PACKET_SZ_MAX 0xA0000005 @@ -675,7 +686,7 @@ WOLFMQTT_API int MqttPacket_TopicFilterValid(const char* filter, word16 len); /*! \brief Return non-zero if the Topic Filter contains a wildcard * ('#' or '+'). Use only on a filter that has already - * passed MqttPacket_TopicFilterValid — wildcard placement + * passed MqttPacket_TopicFilterValid - wildcard placement * is not re-validated here. * \param filter Pointer to the topic filter bytes. * \param len Length of the filter in bytes. @@ -688,14 +699,19 @@ WOLFMQTT_API int MqttPacket_TopicFilterIsWildcard(const char* filter, * Name. Always rejects topics containing the wildcard * characters '#' or '+'. Empty Topic Names are rejected * under v3.1.1 (protocol_level < 5) per [MQTT-4.7.3-1] but - * allowed under v5 (§3.3.2.3.4) because v5 permits a + * allowed under v5 (section 3.3.2.3.4) because v5 permits a * zero-length Topic Name when paired with a Topic Alias * property; the caller is responsible for the alias-empty - * pairing check. - * \param topic_name Pointer to the topic name bytes. + * pairing check. NULL topic_name is rejected regardless of + * len or protocol_level - callers representing the v5 + * Topic Alias placeholder must pass an empty string ("") + * with len==0, not NULL, so the contract matches + * MqttEncode_Publish (which treats NULL as BAD_ARG). + * \param topic_name Pointer to the topic name bytes (must be + * non-NULL; "" is permitted for v5 alias). * \param len Length of the topic name in bytes. * \param protocol_level MQTT protocol level (4 = v3.1.1, 5 = v5). - * \return 1 if well-formed, 0 if malformed. + * \return 1 if well-formed, 0 if malformed (including NULL input). */ WOLFMQTT_API int MqttPacket_TopicNameValid(const char* topic_name, word16 len, byte protocol_level); @@ -742,11 +758,11 @@ WOLFMQTT_API int MqttEncode_PublishResp(byte* tx_buf, int tx_buf_len, byte type, MqttPublishResp *publish_resp); /*! \brief Decode a PUBACK / PUBREC / PUBREL / PUBCOMP packet. * - * \note Per MQTT 3.1.1 §3.4-§3.7 the variable header is exactly the + * \note Per MQTT 3.1.1 sections 3.4-3.7 the variable header is exactly the * two-byte Packet Identifier with no payload; Remaining Length must be * 2. The decoder rejects any extra trailing bytes with - * MQTT_CODE_ERROR_MALFORMED_DATA. MQTT v5 §3.4-§3.7 allows an optional - * Reason Code and Properties block — the longer form is accepted only + * MQTT_CODE_ERROR_MALFORMED_DATA. MQTT v5 sections 3.4-3.7 allows an optional + * Reason Code and Properties block - the longer form is accepted only * when publish_resp is non-NULL and publish_resp->protocol_level is * MQTT_CONNECT_PROTOCOL_LEVEL_5 or higher. Callers integrating against * non-spec brokers that emit extra bytes for v3.x acks must either fix From dce05bed3d8be624fff43b333810f7db9a34279e Mon Sep 17 00:00:00 2001 From: Eric Blankenhorn Date: Fri, 8 May 2026 12:05:49 -0500 Subject: [PATCH 32/32] Fix from review --- tests/test_broker_connect.c | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/tests/test_broker_connect.c b/tests/test_broker_connect.c index fda2bbda5..1c4cb6c76 100644 --- a/tests/test_broker_connect.c +++ b/tests/test_broker_connect.c @@ -1889,12 +1889,17 @@ TEST(broker_no_wildcards_suback_v5_reason_code) * unreachable from production code today; the test calls the helper * directly with a reserved code to pin the rejection branch. * - * BrokerSend_SubAck is forward-declared here because it is internal to - * mqtt_broker.c (no public header); the test harness compiles - * mqtt_broker.c into the test binary so the symbol resolves at link. */ -extern int BrokerSend_SubAck(BrokerClient* bc, word16 packet_id, - const byte* return_codes, int return_code_count); - + * BrokerSend_SubAck is declared as WOLFMQTT_LOCAL in mqtt_broker.h, which + * the test harness has already included above. The test binary compiles + * mqtt_broker.c in directly so the symbol resolves at link. + * + * Gated on dynamic-memory builds: the test substitutes a small external + * tx_buf into BrokerClient, which only works when tx_buf is a pointer + * field. Under WOLFMQTT_STATIC_MEMORY tx_buf is an embedded byte array + * and cannot be reassigned. The validation logic is layout-agnostic, so + * skipping the test in static-memory builds doesn't lose coverage of + * the rejection branch. */ +#ifndef WOLFMQTT_STATIC_MEMORY TEST(broker_suback_reserved_v311_code_rejected) { BrokerClient bc; @@ -1955,6 +1960,7 @@ TEST(broker_suback_valid_v311_failure_code_encoded) ASSERT_EQ(0x01, (int)tx_buf[3]); /* packet_id LSB */ ASSERT_EQ(MQTT_SUBSCRIBE_ACK_CODE_FAILURE, tx_buf[4]); } +#endif /* !WOLFMQTT_STATIC_MEMORY */ #ifdef WOLFMQTT_BROKER_RETAINED /* [MQTT-3.3.1-5] The broker MUST store the Application Message and its @@ -2164,8 +2170,10 @@ int main(int argc, char** argv) #ifdef WOLFMQTT_V5 RUN_TEST(connack_session_present_v5_set_on_resumed_session); #endif +#ifndef WOLFMQTT_STATIC_MEMORY RUN_TEST(broker_suback_reserved_v311_code_rejected); RUN_TEST(broker_suback_valid_v311_failure_code_encoded); +#endif #ifndef WOLFMQTT_BROKER_WILDCARDS RUN_TEST(broker_no_wildcards_suback_failure_for_wildcard_filter); RUN_TEST(broker_no_wildcards_suback_grants_plain_filter);