From 45df1a3b045cebd8975a925ec7493eb378773a8a Mon Sep 17 00:00:00 2001 From: Daniele Lacamera Date: Mon, 21 Sep 2026 01:01:05 +0200 Subject: [PATCH 01/17] F-14179: relay transit IP fragments in the forwarding path ip_recv dropped every fragment before the local/forward decision, so a router build relayed none of them. RFC 1812 5.2.6 forbids reassembling transit fragments; the forwarding path (TTL, route, ARP, transmit) needs no reassembly. Move the drop to the local-dispatch entry: only a locally addressed fragment can reach it (reassembly still unimplemented, F-1326 scope), transit fragments fall through to the relay unchanged. --- src/test/unit/unit.c | 2 + src/test/unit/unit_tests_proto.c | 123 +++++++++++++++++++++++++++++++ src/wolfip.c | 9 ++- 3 files changed, 131 insertions(+), 3 deletions(-) diff --git a/src/test/unit/unit.c b/src/test/unit/unit.c index d7207394..b599fd82 100644 --- a/src/test/unit/unit.c +++ b/src/test/unit/unit.c @@ -945,6 +945,8 @@ Suite *wolf_suite(void) tcase_add_test(tc_proto, test_ip_recv_forward_arp_queue_and_flush); tcase_add_test(tc_proto, test_arp_flush_pending_ttl_expired); tcase_add_test(tc_proto, test_wolfip_forwarding_basic); + tcase_add_test(tc_proto, test_ip_recv_forwarding_relays_transit_fragments); + tcase_add_test(tc_proto, test_ip_recv_forwarding_drops_local_fragment); tcase_add_test(tc_proto, test_wolfip_forwarding_ttl_expired); tcase_add_test(tc_proto, test_regression_forwarding_no_ttl_exceeded_for_icmp_error); tcase_add_test(tc_proto, test_forward_packet_ip_filter_drop); diff --git a/src/test/unit/unit_tests_proto.c b/src/test/unit/unit_tests_proto.c index 78584294..f7a2df76 100644 --- a/src/test/unit/unit_tests_proto.c +++ b/src/test/unit/unit_tests_proto.c @@ -3864,6 +3864,129 @@ START_TEST(test_wolfip_forwarding_basic) } END_TEST +/* Regression: a router must relay transit IP fragments without reassembly + * (RFC 1812 5.2.6); ip_recv used to drop every fragment ahead of the + * forwarding decision, so a router build relayed none of them. */ +START_TEST(test_ip_recv_forwarding_relays_transit_fragments) +{ + struct wolfIP s; + uint8_t frame_buf[64]; + struct wolfIP_ip_packet *frame = (struct wolfIP_ip_packet *)frame_buf; + struct wolfIP_ip_packet *fwd; + uint8_t src_mac[6] = {0x52, 0x54, 0x00, 0x12, 0x34, 0x56}; + uint8_t iface1_mac[6] = {0x02, 0x00, 0x00, 0x00, 0x00, 0x02}; + uint8_t next_hop_mac[6] = {0x02, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE}; + uint32_t dest_ip = 0xC0A80164; /* 192.168.1.100 */ + uint8_t initial_ttl = 64; + + wolfIP_init(&s); + mock_link_init(&s); + mock_link_init_idx(&s, TEST_SECOND_IF, iface1_mac); + wolfIP_ipconfig_set(&s, 0xC0A80001, 0xFFFFFF00, 0); + wolfIP_ipconfig_set_ex(&s, TEST_SECOND_IF, 0xC0A80101, 0xFFFFFF00, 0); + s.arp.neighbors[0].ip = dest_ip; + s.arp.neighbors[0].if_idx = TEST_SECOND_IF; + memcpy(s.arp.neighbors[0].mac, next_hop_mac, 6); + + /* First fragment: MF set, offset zero, 8 bytes of payload. */ + memset(frame_buf, 0, sizeof(frame_buf)); + memcpy(frame->eth.dst, s.ll_dev[TEST_PRIMARY_IF].mac, 6); + memcpy(frame->eth.src, src_mac, 6); + frame->eth.type = ee16(ETH_TYPE_IP); + frame->ver_ihl = 0x45; + frame->ttl = initial_ttl; + frame->proto = WI_IPPROTO_UDP; + frame->len = ee16(IP_HEADER_LEN + 8); + frame->flags_fo = ee16(0x2000U); /* MF=1, offset=0 */ + frame->src = ee32(0xC0A800AA); + frame->dst = ee32(dest_ip); + frame->csum = 0; + iphdr_set_checksum(frame); + + memset(last_frame_sent, 0, sizeof(last_frame_sent)); + last_frame_sent_size = 0; + + wolfIP_recv_ex(&s, TEST_PRIMARY_IF, frame, + ETH_HEADER_LEN + IP_HEADER_LEN + 8); + + /* Relayed unchanged, TTL decremented, fragment field intact. */ + ck_assert_uint_eq(last_frame_sent_size, + (uint32_t)(ETH_HEADER_LEN + IP_HEADER_LEN + 8)); + fwd = (struct wolfIP_ip_packet *)last_frame_sent; + ck_assert_mem_eq(fwd->eth.dst, next_hop_mac, 6); + ck_assert_uint_eq(fwd->ttl, (uint8_t)(initial_ttl - 1)); + ck_assert_uint_eq(ee16(fwd->flags_fo), 0x2000U); + + /* Non-first fragment: MF clear, offset non-zero. Same path. */ + memset(frame_buf, 0, sizeof(frame_buf)); + memcpy(frame->eth.dst, s.ll_dev[TEST_PRIMARY_IF].mac, 6); + memcpy(frame->eth.src, src_mac, 6); + frame->eth.type = ee16(ETH_TYPE_IP); + frame->ver_ihl = 0x45; + frame->ttl = initial_ttl; + frame->proto = WI_IPPROTO_UDP; + frame->len = ee16(IP_HEADER_LEN + 8); + frame->flags_fo = ee16(0x0001U); /* MF=0, offset=1 (8 bytes) */ + frame->src = ee32(0xC0A800AA); + frame->dst = ee32(dest_ip); + frame->csum = 0; + iphdr_set_checksum(frame); + + memset(last_frame_sent, 0, sizeof(last_frame_sent)); + last_frame_sent_size = 0; + + wolfIP_recv_ex(&s, TEST_PRIMARY_IF, frame, + ETH_HEADER_LEN + IP_HEADER_LEN + 8); + + ck_assert_uint_eq(last_frame_sent_size, + (uint32_t)(ETH_HEADER_LEN + IP_HEADER_LEN + 8)); + fwd = (struct wolfIP_ip_packet *)last_frame_sent; + ck_assert_uint_eq(fwd->ttl, (uint8_t)(initial_ttl - 1)); + ck_assert_uint_eq(ee16(fwd->flags_fo), 0x0001U); +} +END_TEST + +/* Locally addressed fragments are still dropped: reassembly is not + * implemented (F-1326), and the fix must not start delivering partial + * datagrams to local sockets. */ +START_TEST(test_ip_recv_forwarding_drops_local_fragment) +{ + struct wolfIP s; + uint8_t frame_buf[64]; + struct wolfIP_ip_packet *frame = (struct wolfIP_ip_packet *)frame_buf; + uint8_t src_mac[6] = {0x52, 0x54, 0x00, 0x12, 0x34, 0x56}; + + wolfIP_init(&s); + mock_link_init(&s); + mock_link_init_idx(&s, TEST_SECOND_IF, NULL); + wolfIP_ipconfig_set(&s, 0xC0A80001, 0xFFFFFF00, 0); + wolfIP_ipconfig_set_ex(&s, TEST_SECOND_IF, 0xC0A80101, 0xFFFFFF00, 0); + + memset(frame_buf, 0, sizeof(frame_buf)); + memcpy(frame->eth.dst, s.ll_dev[TEST_PRIMARY_IF].mac, 6); + memcpy(frame->eth.src, src_mac, 6); + frame->eth.type = ee16(ETH_TYPE_IP); + frame->ver_ihl = 0x45; + frame->ttl = 64; + frame->proto = WI_IPPROTO_UDP; + frame->len = ee16(IP_HEADER_LEN + 8); + frame->flags_fo = ee16(0x2000U); /* MF=1, offset=0 */ + frame->src = ee32(0xC0A800AA); + frame->dst = ee32(0xC0A80001); /* our own interface 0 address */ + frame->csum = 0; + iphdr_set_checksum(frame); + + memset(last_frame_sent, 0, sizeof(last_frame_sent)); + last_frame_sent_size = 0; + + wolfIP_recv_ex(&s, TEST_PRIMARY_IF, frame, + ETH_HEADER_LEN + IP_HEADER_LEN + 8); + + /* Dropped: neither forwarded nor delivered. */ + ck_assert_uint_eq(last_frame_sent_size, 0); +} +END_TEST + START_TEST(test_wolfip_forwarding_ttl_expired) { struct wolfIP s; diff --git a/src/wolfip.c b/src/wolfip.c index 7093c0ff..3d9ffa47 100644 --- a/src/wolfip.c +++ b/src/wolfip.c @@ -11006,9 +11006,6 @@ static inline void ip_recv(struct wolfIP *s, unsigned int if_idx, /* validate IP header checksum per RFC 1122 */ if (iphdr_verify_checksum(ip) != 0) return; - /* Fragment reassembly is not implemented; drop all fragments. */ - if ((ee16(ip->flags_fo) & 0x3FFFU) != 0U) - return; /* RFC 1122 §3.2.1.3: discard packets with non-unicast source addresses. */ { ip4 src = ee32(ip->src); @@ -11286,6 +11283,12 @@ static inline void ip_recv(struct wolfIP *s, unsigned int if_idx, } } #endif /* WOLFIP_ENABLE_FORWARDING */ + /* Fragment reassembly is not implemented: only a locally addressed + * fragment can reach this point, since the forwarding path above relays + * transit fragments without reassembly (RFC 1812 5.2.6). Drop it; no + * partial datagram data is ever delivered. */ + if ((ee16(ip->flags_fo) & 0x3FFFU) != 0U) + return; if (bad_opt_off != 0) return; /* malformed IP options: never deliver locally */ #ifdef DEBUG_IP From 2f48f0d1b0ac557bf6d7de15f03195337b42db0a Mon Sep 17 00:00:00 2001 From: Daniele Lacamera Date: Mon, 21 Sep 2026 01:12:06 +0200 Subject: [PATCH 02/17] F-14169: guard flush_tcp_tx fifo_pop to the FIFO tail Retiring a payload-less segment popped the descriptor at the tail even when the cursor had advanced past it, discarding an unacked data descriptor that could never be retransmitted. Only pop when the cursor is at the tail; otherwise leave the descriptor in place with PKT_FLAG_SENT set and let tcp_ack() reclaim it from the tail. --- src/test/unit/unit.c | 1 + src/test/unit/unit_tests_tcp_flow.c | 65 +++++++++++++++++++++++++++++ src/wolfip.c | 17 +++++++- 3 files changed, 82 insertions(+), 1 deletion(-) diff --git a/src/test/unit/unit.c b/src/test/unit/unit.c index b599fd82..979074f5 100644 --- a/src/test/unit/unit.c +++ b/src/test/unit/unit.c @@ -768,6 +768,7 @@ Suite *wolf_suite(void) tcase_add_test(tc_utils, test_tcp_mark_unsacked_retransmits_partially_acked_segment); tcase_add_test(tc_utils, test_tcp_mark_unsacked_rescans_after_clearing_stale_sack); tcase_add_test(tc_utils, test_tcp_mark_unsacked_ignores_zero_ip_len_unsent_ack_only_desc); + tcase_add_test(tc_utils, test_flush_tcp_tx_pure_ack_keeps_unacked_data_desc); tcase_add_test(tc_utils, test_tcp_ack_sack_blocks_clamped_and_dropped); tcase_add_test(tc_utils, test_tcp_recv_ooo_capacity_limit); tcase_add_test(tc_utils, test_tcp_recv_overlapping_ooo_segments_coalesce_on_consume); diff --git a/src/test/unit/unit_tests_tcp_flow.c b/src/test/unit/unit_tests_tcp_flow.c index 9c5f6210..2d1c99fa 100644 --- a/src/test/unit/unit_tests_tcp_flow.c +++ b/src/test/unit/unit_tests_tcp_flow.c @@ -6212,3 +6212,68 @@ START_TEST(test_tcp_ctrl_rto_start_rearm_failure_clears_active) ck_assert_int_eq(ts->sock.tcp.ctrl_rto_active, 0); } END_TEST + +/* Regression: flush_tcp_tx() must not pop an unacked data descriptor when it + * retires a payload-less segment that is not at the FIFO tail. fifo_pop() only + * removes the tail; once the cursor has advanced past the tail, popping there + * discards the data descriptor and the payload can never be retransmitted. */ +START_TEST(test_flush_tcp_tx_pure_ack_keeps_unacked_data_desc) +{ + struct wolfIP s; + struct tsocket *ts; + struct pkt_desc *desc; + struct pkt_desc *data_desc; + + wolfIP_init(&s); + mock_link_init(&s); + wolfIP_ipconfig_set(&s, 0x0A000001U, 0xFFFFFF00U, 0); + s.arp.neighbors[0].ip = 0x0A000002U; + s.arp.neighbors[0].if_idx = TEST_PRIMARY_IF; + memcpy(s.arp.neighbors[0].mac, + (uint8_t[]){0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}, 6); + + ts = &s.tcpsockets[0]; + memset(ts, 0, sizeof(*ts)); + ts->proto = WI_IPPROTO_TCP; + ts->S = &s; + ts->if_idx = TEST_PRIMARY_IF; + ts->sock.tcp.state = TCP_ESTABLISHED; + ts->sock.tcp.ack = 100; + ts->sock.tcp.seq = 1000; + ts->sock.tcp.snd_una = 1000; + ts->sock.tcp.rto = 200; + ts->sock.tcp.cwnd = TXBUF_SIZE; + ts->sock.tcp.peer_rwnd = TXBUF_SIZE; + ts->src_port = 1234; + ts->dst_port = 4321; + ts->local_ip = 0x0A000001U; + ts->remote_ip = 0x0A000002U; + queue_init(&ts->sock.tcp.rxbuf, ts->rxmem, RXBUF_SIZE, ts->sock.tcp.ack); + fifo_init(&ts->sock.tcp.txbuf, ts->txmem, TXBUF_SIZE); + + /* Data descriptor at the tail, pure ACK queued behind it. */ + ck_assert_int_eq(enqueue_tcp_tx(ts, 8, (TCP_FLAG_ACK | TCP_FLAG_PSH)), 0); + data_desc = fifo_peek(&ts->sock.tcp.txbuf); + ck_assert_ptr_nonnull(data_desc); + ck_assert_int_eq(enqueue_tcp_tx(ts, 0, TCP_FLAG_ACK), 0); + + /* Flush: sends the data (marks SENT, advances past the tail), then sends + * the pure ACK. Pre-fix the ACK's fifo_pop() discards data_desc. */ + (void)wolfIP_poll(&s, 200); + + /* The unacked data descriptor must survive the flush. */ + desc = fifo_peek(&ts->sock.tcp.txbuf); + ck_assert_ptr_nonnull(desc); + ck_assert_ptr_eq(desc, data_desc); + ck_assert_int_ne(desc->flags & PKT_FLAG_SENT, 0); + + /* Exactly two descriptors remain queued: data at the tail, the pure + * ACK after it, and nothing beyond (fifo_next() stops at the head). */ + desc = fifo_next(&ts->sock.tcp.txbuf, desc); + ck_assert_ptr_nonnull(desc); + ck_assert_ptr_ne(desc, data_desc); + ck_assert_int_ne(desc->flags & PKT_FLAG_SENT, 0); + desc = fifo_next(&ts->sock.tcp.txbuf, desc); + ck_assert_ptr_null(desc); +} +END_TEST diff --git a/src/wolfip.c b/src/wolfip.c index 3d9ffa47..c929efec 100644 --- a/src/wolfip.c +++ b/src/wolfip.c @@ -12403,7 +12403,22 @@ static void flush_tcp_tx(struct wolfIP *s, uint64_t now) desc->flags |= PKT_FLAG_WAS_RETRANS; desc->time_sent = now; if (size == IP_HEADER_LEN + (uint32_t)(tcp->hlen >> 2)) { - desc = fifo_pop(&ts->sock.tcp.txbuf); + if (desc == fifo_peek(&ts->sock.tcp.txbuf)) { + /* Cursor at the tail: fifo_pop() removes exactly + * this descriptor. */ + desc = fifo_pop(&ts->sock.tcp.txbuf); + } else { + /* fifo_pop() only removes the tail, so popping + * here would discard the unacked data descriptor + * at the tail. Leave the payload-less descriptor + * in place with PKT_FLAG_SENT set; tcp_ack() + * reclaims zero-length sent descriptors from the + * tail once the data ahead of them is acked. */ + next_desc = fifo_next(&ts->sock.tcp.txbuf, desc); + if (next_desc == desc) + break; + desc = next_desc; + } } else { uint32_t payload_len = size - (IP_HEADER_LEN + (tcp->hlen >> 2)); if (ts->sock.tcp.tmr_rto != NO_TIMER) { From 3790531e8f64a1a0fd3774ae3ebb47ed0b5de8fb Mon Sep 17 00:00:00 2001 From: Daniele Lacamera Date: Mon, 21 Sep 2026 01:20:19 +0200 Subject: [PATCH 03/17] F-14186: drop UDP datagrams addressed to non-local destinations udp_try_recv() delivered datagrams to wildcard (INADDR_ANY) bound sockets whenever the destination matched no constraint, so in non-forwarding builds (where ip_recv() compiles out its is_local check) an L2-adjacent sender could inject application traffic addressed to a third party. Gate delivery on the destination being local, a broadcast, a multicast, or the pre-address DHCP exchange (RFC 2131 OFFER/ACK), mirroring the ip_recv() forwarding-build policy and the checks tcp_input()/icmp_input() already apply. --- src/test/unit/unit.c | 1 + src/test/unit/unit_tests_api.c | 41 +++++++++++++++++++++++++++++ src/test/unit/unit_tests_dns_dhcp.c | 6 ++++- src/wolfip.c | 16 +++++++++++ 4 files changed, 63 insertions(+), 1 deletion(-) diff --git a/src/test/unit/unit.c b/src/test/unit/unit.c index 979074f5..37c8c74a 100644 --- a/src/test/unit/unit.c +++ b/src/test/unit/unit.c @@ -993,6 +993,7 @@ Suite *wolf_suite(void) tcase_add_test(tc_proto, test_udp_sendto_and_recvfrom); tcase_add_test(tc_proto, test_udp_sendto_unbound_socket_receives_reply); tcase_add_test(tc_proto, test_udp_wildcard_bind_receives_all_local_addrs); + tcase_add_test(tc_proto, test_udp_wildcard_bind_drops_third_party_dst); tcase_add_test(tc_proto, test_udp_sendto_respects_mtu_api); tcase_add_test(tc_proto, test_udp_recvfrom_sets_remote_ip); tcase_add_test(tc_proto, test_udp_recvfrom_null_src_addr_len); diff --git a/src/test/unit/unit_tests_api.c b/src/test/unit/unit_tests_api.c index b0674e91..5ea3e15f 100644 --- a/src/test/unit/unit_tests_api.c +++ b/src/test/unit/unit_tests_api.c @@ -1013,6 +1013,47 @@ START_TEST(test_udp_wildcard_bind_receives_all_local_addrs) } END_TEST +START_TEST(test_udp_wildcard_bind_drops_third_party_dst) +{ + struct wolfIP s; + int sd; + struct wolfIP_sockaddr_in sin; + uint8_t payload[4] = {1, 2, 3, 4}; + uint8_t rxbuf[LINK_MTU]; + int ret; + + setup_stack_with_two_ifaces(&s, 0x0A000001U, 0x0A010001U); + + sd = wolfIP_sock_socket(&s, AF_INET, IPSTACK_SOCK_DGRAM, WI_IPPROTO_UDP); + ck_assert_int_gt(sd, 0); + memset(&sin, 0, sizeof(sin)); + sin.sin_family = AF_INET; + sin.sin_port = ee16(5353); + sin.sin_addr.s_addr = 0U; /* INADDR_ANY */ + ck_assert_int_eq(wolfIP_sock_bind(&s, sd, (struct wolfIP_sockaddr *)&sin, + sizeof(sin)), 0); + + /* A datagram addressed to a third-party IP is not for this host: RFC + * 1122 requires silent discard. Pre-fix the wildcard bind matched any + * destination, so an L2-adjacent attacker could inject application + * traffic addressed to someone else. */ + inject_udp_datagram(&s, TEST_PRIMARY_IF, 0x0A000064U, 0xC0A80164U, + 60000, 5353, payload, sizeof(payload)); + ret = wolfIP_sock_recvfrom(&s, sd, rxbuf, sizeof(rxbuf), 0, + (struct wolfIP_sockaddr *)NULL, NULL); + ck_assert_int_eq(ret, -WOLFIP_EAGAIN); + + /* The gate must not over-reject: a limited broadcast is still + * delivered to the wildcard bind. */ + inject_udp_datagram(&s, TEST_PRIMARY_IF, 0x0A000064U, 0xFFFFFFFFU, + 60002, 5353, payload, sizeof(payload)); + ret = wolfIP_sock_recvfrom(&s, sd, rxbuf, sizeof(rxbuf), 0, + (struct wolfIP_sockaddr *)NULL, NULL); + ck_assert_int_eq(ret, (int)sizeof(payload)); + ck_assert_mem_eq(rxbuf, payload, sizeof(payload)); +} +END_TEST + START_TEST(test_udp_sendto_respects_mtu_api) { struct wolfIP s; diff --git a/src/test/unit/unit_tests_dns_dhcp.c b/src/test/unit/unit_tests_dns_dhcp.c index 62d707ba..ed928c7c 100644 --- a/src/test/unit/unit_tests_dns_dhcp.c +++ b/src/test/unit/unit_tests_dns_dhcp.c @@ -6641,7 +6641,11 @@ START_TEST(test_udp_try_recv_conf_null) udp->dst_port = ee16(1234); udp->len = ee16(UDP_HEADER_LEN + 4); udp_try_recv(&s, TEST_PRIMARY_IF, udp, (uint32_t)(ETH_HEADER_LEN + IP_HEADER_LEN + UDP_HEADER_LEN + 4)); - ck_assert_ptr_nonnull(fifo_peek(&ts->sock.udp.rxbuf)); + /* With no configured interfaces no destination is local, so the + * datagram is dropped even though the socket's manually set local_ip + * matches the destination (RFC 1122: a host consumes only traffic + * addressed to its own addresses). */ + ck_assert_ptr_eq(fifo_peek(&ts->sock.udp.rxbuf), NULL); } END_TEST diff --git a/src/wolfip.c b/src/wolfip.c index c929efec..ac67a0d3 100644 --- a/src/wolfip.c +++ b/src/wolfip.c @@ -2998,6 +2998,7 @@ static void udp_try_recv(struct wolfIP *s, unsigned int if_idx, { int i; int matched = 0; + int dst_local = 0; ip4 dst_ip; ip4 src_ip; @@ -3041,6 +3042,21 @@ static void udp_try_recv(struct wolfIP *s, unsigned int if_idx, dst_ip = ee32(udp->ip.dst); src_ip = ee32(udp->ip.src); + /* A host consumes only datagrams addressed to itself: one of its own + * interface addresses, a broadcast, a multicast, or the pre-address + * DHCP exchange (RFC 2131: OFFER/ACK may carry a unicast ip.dst the + * client does not own yet). RFC 1122 requires silently dropping + * everything else. Without this gate a wildcard (INADDR_ANY) bind + * delivers third-party traffic in non-forwarding builds, where + * ip_recv() compiles out its is_local check. */ + (void)wolfIP_if_for_local_ip(s, dst_ip, &dst_local); + if (!dst_local && dst_ip != IPADDR_ANY && + !wolfIP_ip_is_broadcast(s, dst_ip) && + !wolfIP_ip_is_multicast(dst_ip) && + !(ee16(udp->src_port) == DHCP_SERVER_PORT && + ee16(udp->dst_port) == DHCP_CLIENT_PORT)) + return; + if (wolfIP_filter_notify_udp(WOLFIP_FILT_RECEIVING, s, if_idx, udp, frame_len, IP_HEADER_LEN) != 0) return; From 0c7345c26fe7e6bc9d5f8336817ae0430c4b21fb Mon Sep 17 00:00:00 2001 From: Daniele Lacamera Date: Mon, 21 Sep 2026 01:27:06 +0200 Subject: [PATCH 04/17] F-14170: keep armed persist timer deadline across polls tcp_persist_start() cancelled and re-inserted the zero-window persist timer on every call, and flush_tcp_tx() calls it on every poll while the peer window is zero. With a poll cadence shorter than TCP_PERSIST_MIN_MS the deadline was pushed forward before it could expire, so the probe never fired and a lost window-reopening ACK stalled the connection forever (RFC 9293 3.8.6.1). Make the start idempotent: return when the timer is already armed. Only tcp_persist_cb() re-arms, after a probe has been sent; it drops the stale handle first since handle_timers() does not clear it. --- src/test/unit/unit.c | 1 + src/test/unit/unit_tests_poll_dispatcher.c | 56 ++++++++++++++++++++++ src/wolfip.c | 11 ++++- 3 files changed, 66 insertions(+), 2 deletions(-) diff --git a/src/test/unit/unit.c b/src/test/unit/unit.c index 37c8c74a..df222176 100644 --- a/src/test/unit/unit.c +++ b/src/test/unit/unit.c @@ -1443,6 +1443,7 @@ Suite *wolf_suite(void) tcase_add_test(tc_core, test_poll_tx_tcp_filter_tcp_blocks_send); tcase_add_test(tc_core, test_poll_tx_tcp_send_eagain_breaks_loop); tcase_add_test(tc_core, test_poll_tx_tcp_zero_window_starts_persist); + tcase_add_test(tc_core, test_poll_tx_tcp_zero_window_probe_fires_under_fast_poll); tcase_add_test(tc_core, test_poll_tx_tcp_retransmit_replay); tcase_add_test(tc_core, test_poll_tx_tcp_loopback_path); tcase_add_test(tc_core, test_poll_tx_udp_sends_on_arp_hit); diff --git a/src/test/unit/unit_tests_poll_dispatcher.c b/src/test/unit/unit_tests_poll_dispatcher.c index 8099656d..e088cc6a 100644 --- a/src/test/unit/unit_tests_poll_dispatcher.c +++ b/src/test/unit/unit_tests_poll_dispatcher.c @@ -792,6 +792,62 @@ START_TEST(test_poll_tx_tcp_zero_window_starts_persist) } END_TEST +/* Regression: tcp_persist_start() used to cancel and re-insert the persist + * timer on every call, and flush_tcp_tx() calls it on every poll while the + * peer window is zero. With a poll cadence shorter than TCP_PERSIST_MIN_MS + * (the normal embedded main-loop pattern) the deadline was pushed forward + * before it could expire, so tcp_persist_cb() never ran and no zero-window + * probe was ever transmitted (RFC 9293 3.8.6.1: the probe is the only + * recovery when the peer's window-reopening ACK is lost). */ +START_TEST(test_poll_tx_tcp_zero_window_probe_fires_under_fast_poll) +{ + struct wolfIP s; + struct tsocket *ts; + struct wolfIP_tcp_seg *probe; + ip4 local_ip = 0x0A000001U; + ip4 remote_ip = 0x0A000002U; + uint8_t peer_mac[6] = {0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0x04}; + uint64_t now; + + wolfIP_init(&s); + mock_link_init(&s); + wolfIP_ipconfig_set(&s, local_ip, 0xFFFFFF00U, 0); + wolfIP_filter_set_callback(NULL, NULL); + + s.arp.neighbors[0].ip = remote_ip; + s.arp.neighbors[0].if_idx = TEST_PRIMARY_IF; + memcpy(s.arp.neighbors[0].mac, peer_mac, 6); + + ts = &s.tcpsockets[0]; + setup_tcp_socket(&s, ts, local_ip, remote_ip, TEST_PRIMARY_IF); + /* Force zero peer window */ + ts->sock.tcp.peer_rwnd = 0; + ts->sock.tcp.cwnd = TCP_MSS; + + ck_assert_int_eq(enqueue_tcp_tx(ts, 4, TCP_FLAG_ACK | TCP_FLAG_PSH), 0); + + mock_link_capture_reset(); + + /* Poll every 100 ms (10x faster than TCP_PERSIST_MIN_MS) up to and + * including the first persist deadline (200 + 1000 ms). */ + for (now = 200; now <= 1200; now += 100) { + (void)wolfIP_poll(&s, now); + if (now < 1200) + ck_assert_uint_eq(last_frame_sent_count, 0U); + } + + /* The probe fired: exactly one frame, the 1-byte zero-window probe + * retransmitting from snd_una. Pre-fix nothing was ever transmitted. */ + ck_assert_uint_eq(last_frame_sent_count, 1U); + ck_assert_int_eq(ts->sock.tcp.persist_backoff, 1); + probe = (struct wolfIP_tcp_seg *)(last_frame_sent + ETH_HEADER_LEN + + IP_HEADER_LEN); + ck_assert_uint_eq(ee32(probe->seq), ts->sock.tcp.snd_una); + /* Probe re-armed with backoff: next deadline 1200 + 1000 ms. */ + ck_assert_int_eq(ts->sock.tcp.persist_active, 1); +} +END_TEST + START_TEST(test_poll_tx_tcp_retransmit_replay) { struct wolfIP s; diff --git a/src/wolfip.c b/src/wolfip.c index ac67a0d3..fd3f1ff8 100644 --- a/src/wolfip.c +++ b/src/wolfip.c @@ -4555,8 +4555,12 @@ static void tcp_persist_start(struct tsocket *t, uint64_t now) return; } if (t->sock.tcp.tmr_persist != NO_TIMER) { - timer_binheap_cancel(&t->S->timers, t->sock.tcp.tmr_persist); - t->sock.tcp.tmr_persist = NO_TIMER; + /* Already armed: keep the existing deadline. flush_tcp_tx() calls + * this on every poll while the peer window is zero; re-arming here + * would push the deadline past every poll cadence shorter than + * TCP_PERSIST_MIN_MS so the probe would never fire. Only + * tcp_persist_cb() re-arms, after a probe has been sent. */ + return; } interval = tcp_persist_interval_ms(t); tmr.expires = now + interval; @@ -4695,6 +4699,9 @@ static void tcp_persist_cb(void *arg) (void)tcp_send_zero_wnd_probe(t); if (t->sock.tcp.persist_backoff < 10) t->sock.tcp.persist_backoff++; + /* The timer that fired is out of the heap; drop the stale handle so + * tcp_persist_start() re-arms instead of seeing it as armed. */ + t->sock.tcp.tmr_persist = NO_TIMER; tcp_persist_start(t, t->S->last_tick); } From 0e7308fc9c07124e7c51b90890050ae8f40d66b1 Mon Sep 17 00:00:00 2001 From: Daniele Lacamera Date: Mon, 21 Sep 2026 07:54:00 +0200 Subject: [PATCH 05/17] F-14186: scope UDP DHCP exception to an active client The 67->68 exception in the udp_try_recv() gate was unconditional, so a datagram with those ports addressed to a third-party IP passed even with DHCP off and reached any wildcard bind on port 68. Now the gate requires DHCP_IS_RUNNING (excludes OFF and BOUND), and a third-party addressed 67->68 datagram may only match the DHCP client's own socket in the delivery loop. The F-11438 L2-group test now sets dhcp_state = DHCP_DISCOVER_SENT to match the OFFER/ACK scenario it models. --- src/test/unit/unit.c | 1 + src/test/unit/unit_tests_api.c | 70 ++++++++++++++++++++++++++ src/test/unit/unit_tests_ip_arp_recv.c | 4 ++ src/wolfip.c | 32 +++++++++--- 4 files changed, 99 insertions(+), 8 deletions(-) diff --git a/src/test/unit/unit.c b/src/test/unit/unit.c index df222176..938e3c1a 100644 --- a/src/test/unit/unit.c +++ b/src/test/unit/unit.c @@ -994,6 +994,7 @@ Suite *wolf_suite(void) tcase_add_test(tc_proto, test_udp_sendto_unbound_socket_receives_reply); tcase_add_test(tc_proto, test_udp_wildcard_bind_receives_all_local_addrs); tcase_add_test(tc_proto, test_udp_wildcard_bind_drops_third_party_dst); + tcase_add_test(tc_proto, test_udp_dhcp_exchange_only_reaches_dhcp_socket); tcase_add_test(tc_proto, test_udp_sendto_respects_mtu_api); tcase_add_test(tc_proto, test_udp_recvfrom_sets_remote_ip); tcase_add_test(tc_proto, test_udp_recvfrom_null_src_addr_len); diff --git a/src/test/unit/unit_tests_api.c b/src/test/unit/unit_tests_api.c index 5ea3e15f..e9d37669 100644 --- a/src/test/unit/unit_tests_api.c +++ b/src/test/unit/unit_tests_api.c @@ -1054,6 +1054,76 @@ START_TEST(test_udp_wildcard_bind_drops_third_party_dst) } END_TEST +/* Regression for the DHCP exception in the udp_try_recv() gate: a + * third-party-addressed 67->68 datagram (the pre-address RFC 2131 + * OFFER/ACK window) must reach only the DHCP client's own socket - never + * a plain wildcard bind on port 68 - and never at all when DHCP is off. + * The phases reuse the single wildcard :68 bind (the stack rejects a + * second wildcard bind on the same port). */ +START_TEST(test_udp_dhcp_exchange_only_reaches_dhcp_socket) +{ + struct wolfIP s; + int sd; + struct wolfIP_sockaddr_in sin; + uint8_t payload[4] = {1, 2, 3, 4}; + uint8_t rxbuf[LINK_MTU]; + int ret; + ip4 third_party = 0xC0A80164U; /* 192.168.1.100, not ours */ + + setup_stack_with_two_ifaces(&s, 0x0A000001U, 0x0A010001U); + + sd = wolfIP_sock_socket(&s, AF_INET, IPSTACK_SOCK_DGRAM, + WI_IPPROTO_UDP); + ck_assert_int_gt(sd, 0); + memset(&sin, 0, sizeof(sin)); + sin.sin_family = AF_INET; + sin.sin_port = ee16(DHCP_CLIENT_PORT); + sin.sin_addr.s_addr = 0U; + ck_assert_int_eq(wolfIP_sock_bind(&s, sd, (struct wolfIP_sockaddr *)&sin, + sizeof(sin)), 0); + + /* Phase 1: a plain application socket, wildcard bound on port 68, + * DHCP exchanging: the third-party addressed datagram must NOT be + * delivered to it. */ + s.dhcp_state = DHCP_DISCOVER_SENT; + s.dhcp_udp_sd = 0; /* no DHCP socket */ + inject_udp_datagram(&s, TEST_PRIMARY_IF, 0x0A000064U, third_party, + DHCP_SERVER_PORT, DHCP_CLIENT_PORT, payload, sizeof(payload)); + ret = wolfIP_sock_recvfrom(&s, sd, rxbuf, sizeof(rxbuf), 0, + (struct wolfIP_sockaddr *)NULL, NULL); + ck_assert_int_eq(ret, -WOLFIP_EAGAIN); + + wolfIP_sock_close(&s, sd); + + /* Phase 2: the same socket registered as the DHCP client socket: + * the legitimate pre-address exchange is delivered. */ + sd = wolfIP_sock_socket(&s, AF_INET, IPSTACK_SOCK_DGRAM, + WI_IPPROTO_UDP); + ck_assert_int_gt(sd, 0); + memset(&sin, 0, sizeof(sin)); + sin.sin_family = AF_INET; + sin.sin_port = ee16(DHCP_CLIENT_PORT); + sin.sin_addr.s_addr = 0U; + ck_assert_int_eq(wolfIP_sock_bind(&s, sd, (struct wolfIP_sockaddr *)&sin, + sizeof(sin)), 0); + s.dhcp_udp_sd = sd; + inject_udp_datagram(&s, TEST_PRIMARY_IF, 0x0A000064U, third_party, + DHCP_SERVER_PORT, DHCP_CLIENT_PORT, payload, sizeof(payload)); + ret = wolfIP_sock_recvfrom(&s, sd, rxbuf, sizeof(rxbuf), 0, + (struct wolfIP_sockaddr *)NULL, NULL); + ck_assert_int_eq(ret, (int)sizeof(payload)); + ck_assert_mem_eq(rxbuf, payload, sizeof(payload)); + + /* Phase 3: DHCP off: the same datagram is dropped at the gate. */ + s.dhcp_state = DHCP_OFF; + inject_udp_datagram(&s, TEST_PRIMARY_IF, 0x0A000064U, third_party, + DHCP_SERVER_PORT, DHCP_CLIENT_PORT, payload, sizeof(payload)); + ret = wolfIP_sock_recvfrom(&s, sd, rxbuf, sizeof(rxbuf), 0, + (struct wolfIP_sockaddr *)NULL, NULL); + ck_assert_int_eq(ret, -WOLFIP_EAGAIN); +} +END_TEST + START_TEST(test_udp_sendto_respects_mtu_api) { struct wolfIP s; diff --git a/src/test/unit/unit_tests_ip_arp_recv.c b/src/test/unit/unit_tests_ip_arp_recv.c index 5bf29cbb..4c35e3c4 100644 --- a/src/test/unit/unit_tests_ip_arp_recv.c +++ b/src/test/unit/unit_tests_ip_arp_recv.c @@ -799,6 +799,10 @@ START_TEST(test_ip_recv_l2_group_dhcp_still_reaches_local_udp) ip4 dest_ip = 0x0A000063U; /* 10.0.0.99 — not yet ours */ setup_stack_with_two_ifaces(&s, primary_ip, secondary_ip); + /* The client is mid-exchange: OFFER/ACK only arrive in this window, and + * the udp_try_recv() gate only admits third-party addressed 67->68 + * datagrams while DHCP is running. */ + s.dhcp_state = DHCP_DISCOVER_SENT; f11438_install_rx_observer(); memset(frame, 0, sizeof(frame)); diff --git a/src/wolfip.c b/src/wolfip.c index fd3f1ff8..1ceb29fe 100644 --- a/src/wolfip.c +++ b/src/wolfip.c @@ -2999,6 +2999,8 @@ static void udp_try_recv(struct wolfIP *s, unsigned int if_idx, int i; int matched = 0; int dst_local = 0; + int dst_local_ok; + int dhcp_exchange; ip4 dst_ip; ip4 src_ip; @@ -3050,11 +3052,16 @@ static void udp_try_recv(struct wolfIP *s, unsigned int if_idx, * delivers third-party traffic in non-forwarding builds, where * ip_recv() compiles out its is_local check. */ (void)wolfIP_if_for_local_ip(s, dst_ip, &dst_local); - if (!dst_local && dst_ip != IPADDR_ANY && - !wolfIP_ip_is_broadcast(s, dst_ip) && - !wolfIP_ip_is_multicast(dst_ip) && - !(ee16(udp->src_port) == DHCP_SERVER_PORT && - ee16(udp->dst_port) == DHCP_CLIENT_PORT)) + dst_local_ok = dst_local || dst_ip == IPADDR_ANY || + wolfIP_ip_is_broadcast(s, dst_ip) || + wolfIP_ip_is_multicast(dst_ip); + /* The DHCP exception is only valid while the client is actively + * exchanging (DHCP_IS_RUNNING excludes OFF and BOUND); with DHCP off a + * 67->68 datagram to a third-party IP is dropped like any other. */ + dhcp_exchange = !dst_local_ok && + ee16(udp->src_port) == DHCP_SERVER_PORT && + ee16(udp->dst_port) == DHCP_CLIENT_PORT; + if (!dst_local_ok && !(dhcp_exchange && DHCP_IS_RUNNING(s))) return; if (wolfIP_filter_notify_udp(WOLFIP_FILT_RECEIVING, s, if_idx, udp, frame_len, @@ -3089,9 +3096,18 @@ static void udp_try_recv(struct wolfIP *s, unsigned int if_idx, int bound_match = (t->local_ip != 0) && ((t->bound_local_ip == IPADDR_ANY) || (t->bound_local_ip == dst_ip)); - int addr_match = - (((t->local_ip == 0) && DHCP_IS_RUNNING(s) && is_dhcp) || - (bound_match && peer_match)); + int addr_match; + if (dhcp_exchange) { + /* A third-party-addressed 67->68 datagram may only reach the + * DHCP client's own socket; a wildcard bind on port 68 must + * not receive it. (The gate above guarantees DHCP is running + * when dhcp_exchange is set.) */ + addr_match = is_dhcp; + } else { + addr_match = + (((t->local_ip == 0) && DHCP_IS_RUNNING(s) && is_dhcp) || + (bound_match && peer_match)); + } #ifdef IP_MULTICAST if (wolfIP_ip_is_multicast(dst_ip)) { addr_match = udp_socket_has_mcast(t, if_idx, dst_ip) && From 299980c536bb0076e86d0f1c1368b8ba21d356ef Mon Sep 17 00:00:00 2001 From: Daniele Lacamera Date: Mon, 21 Sep 2026 08:16:36 +0200 Subject: [PATCH 06/17] F-14169: keep RTT sample when a zero-length desc is parked The tcp_ack() zero-length drain called fifo_pop() unconditionally, which removes the OLDEST descriptor. With a pure-ACK descriptor parked behind an unacked data descriptor (the state the F-14169 flush fix now leaves), an ACK covering the data marked it ACKED, then the drain popped it out from under the cursor: fresh_desc ended up NULL and the RTT sample plus cwnd growth for that ACK were skipped. Pop only when the cursor is on the oldest descriptor; otherwise leave the parked descriptor in place and advance the cursor - it is reclaimed when it becomes the oldest. New unit test pins it: pre-fix the test fails on rto_initialized == 0 (sample lost). --- src/test/unit/unit.c | 1 + src/test/unit/unit_tests_tcp_flow.c | 81 +++++++++++++++++++++++++++++ src/wolfip.c | 17 ++++-- 3 files changed, 95 insertions(+), 4 deletions(-) diff --git a/src/test/unit/unit.c b/src/test/unit/unit.c index 938e3c1a..f41cd03b 100644 --- a/src/test/unit/unit.c +++ b/src/test/unit/unit.c @@ -769,6 +769,7 @@ Suite *wolf_suite(void) tcase_add_test(tc_utils, test_tcp_mark_unsacked_rescans_after_clearing_stale_sack); tcase_add_test(tc_utils, test_tcp_mark_unsacked_ignores_zero_ip_len_unsent_ack_only_desc); tcase_add_test(tc_utils, test_flush_tcp_tx_pure_ack_keeps_unacked_data_desc); + tcase_add_test(tc_utils, test_tcp_ack_parked_zero_desc_keeps_rtt_sample); tcase_add_test(tc_utils, test_tcp_ack_sack_blocks_clamped_and_dropped); tcase_add_test(tc_utils, test_tcp_recv_ooo_capacity_limit); tcase_add_test(tc_utils, test_tcp_recv_overlapping_ooo_segments_coalesce_on_consume); diff --git a/src/test/unit/unit_tests_tcp_flow.c b/src/test/unit/unit_tests_tcp_flow.c index 2d1c99fa..21e52265 100644 --- a/src/test/unit/unit_tests_tcp_flow.c +++ b/src/test/unit/unit_tests_tcp_flow.c @@ -6277,3 +6277,84 @@ START_TEST(test_flush_tcp_tx_pure_ack_keeps_unacked_data_desc) ck_assert_ptr_null(desc); } END_TEST + +/* Regression: the tcp_ack() zero-length drain pops the oldest descriptor, + * so a zero-length descriptor parked behind a just-acked data descriptor + * must not be popped there (that would discard the data descriptor and lose + * the RTT sample for the ACK). The parked descriptor stays until it becomes + * the oldest. */ +START_TEST(test_tcp_ack_parked_zero_desc_keeps_rtt_sample) +{ + struct wolfIP s; + struct tsocket *ts; + struct wolfIP_tcp_seg ackseg; + struct pkt_desc *desc; + struct pkt_desc *data_desc; + + wolfIP_init(&s); + mock_link_init(&s); + wolfIP_ipconfig_set(&s, 0x0A000001U, 0xFFFFFF00U, 0); + s.arp.neighbors[0].ip = 0x0A000002U; + s.arp.neighbors[0].if_idx = TEST_PRIMARY_IF; + memcpy(s.arp.neighbors[0].mac, + (uint8_t[]){0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}, 6); + + ts = &s.tcpsockets[0]; + memset(ts, 0, sizeof(*ts)); + ts->proto = WI_IPPROTO_TCP; + ts->S = &s; + ts->if_idx = TEST_PRIMARY_IF; + ts->sock.tcp.state = TCP_ESTABLISHED; + ts->sock.tcp.ack = 100; + ts->sock.tcp.seq = 1000; + ts->sock.tcp.snd_una = 1000; + ts->sock.tcp.rto = 200; + ts->sock.tcp.cwnd = TXBUF_SIZE; + ts->sock.tcp.peer_rwnd = TXBUF_SIZE; + ts->src_port = 1234; + ts->dst_port = 4321; + ts->local_ip = 0x0A000001U; + ts->remote_ip = 0x0A000002U; + queue_init(&ts->sock.tcp.rxbuf, ts->rxmem, RXBUF_SIZE, ts->sock.tcp.ack); + fifo_init(&ts->sock.tcp.txbuf, ts->txmem, TXBUF_SIZE); + + /* Data at the tail, pure ACK parked behind it; flush sends both. */ + ck_assert_int_eq(enqueue_tcp_tx(ts, 8, (TCP_FLAG_ACK | TCP_FLAG_PSH)), 0); + data_desc = fifo_peek(&ts->sock.tcp.txbuf); + ck_assert_ptr_nonnull(data_desc); + ck_assert_int_eq(enqueue_tcp_tx(ts, 0, TCP_FLAG_ACK), 0); + /* The real send path advances seq as it enqueues; the test helper does + * not, so mirror it here. */ + ts->sock.tcp.seq = 1008; + (void)wolfIP_poll(&s, 200); + + desc = fifo_peek(&ts->sock.tcp.txbuf); + ck_assert_ptr_eq(desc, data_desc); + ck_assert_int_ne(desc->flags & PKT_FLAG_SENT, 0); + desc = fifo_next(&ts->sock.tcp.txbuf, desc); + ck_assert_ptr_nonnull(desc); + ck_assert_int_ne(desc->flags & PKT_FLAG_SENT, 0); + + /* ACK the data. Pre-fix the drain's fifo_pop() discards the just-acked + * data descriptor, fresh_desc ends up NULL and no RTT sample is taken; + * post-fix the sample lands and the parked zero-length descriptor + * survives. */ + memset(&ackseg, 0, sizeof(ackseg)); + ackseg.ip.len = ee16(IP_HEADER_LEN + TCP_HEADER_LEN); + ackseg.hlen = TCP_HEADER_LEN << 2; + ackseg.flags = TCP_FLAG_ACK; + ackseg.ack = ee32(1008); + tcp_ack(ts, &ackseg); + + ck_assert_uint_eq(ts->sock.tcp.snd_una, 1008); + ck_assert_uint_eq(ts->sock.tcp.bytes_in_flight, 0); + ck_assert_int_eq(ts->sock.tcp.rto_initialized, 1); + ck_assert_uint_ne(ts->sock.tcp.rto, 200); + /* The parked zero-length descriptor is still queued, to be reclaimed + * when it becomes the oldest. */ + desc = fifo_peek(&ts->sock.tcp.txbuf); + ck_assert_ptr_nonnull(desc); + ck_assert_ptr_ne(desc, data_desc); + ck_assert_int_ne(desc->flags & PKT_FLAG_SENT, 0); +} +END_TEST diff --git a/src/wolfip.c b/src/wolfip.c index 1ceb29fe..0e06eab6 100644 --- a/src/wolfip.c +++ b/src/wolfip.c @@ -5670,10 +5670,19 @@ static void tcp_ack(struct tsocket *t, const struct wolfIP_tcp_seg *tcp) struct wolfIP_tcp_seg *seg = (struct wolfIP_tcp_seg *)(t->txmem + desc->pos + sizeof(*desc)); uint32_t seg_len = ee16(seg->ip.len) - (IP_HEADER_LEN + (seg->hlen >> 2)); if (seg_len == 0) { - /* Advance the tail and discard */ - desc = fifo_pop(&t->sock.tcp.txbuf); - (void)desc; - desc = fifo_peek(&t->sock.tcp.txbuf); + if (desc == fifo_peek(&t->sock.tcp.txbuf)) { + /* fifo_pop() removes the oldest descriptor, which is the + * cursor: discard it and resume from the new head. */ + desc = fifo_pop(&t->sock.tcp.txbuf); + (void)desc; + desc = fifo_peek(&t->sock.tcp.txbuf); + } else { + /* A zero-length descriptor parked ahead of a newer one: + * leave it in place (popping would remove the newest + * descriptor, not this one) and advance the cursor. It is + * reclaimed when it becomes the oldest. */ + desc = fifo_next(&t->sock.tcp.txbuf, desc); + } continue; } if (tcp_seq_leq(ee32(seg->seq) + seg_len, ack)) { From e138a325d1a6c367a20ec0b678887407737e222b Mon Sep 17 00:00:00 2001 From: Daniele Lacamera Date: Mon, 21 Sep 2026 08:17:23 +0200 Subject: [PATCH 07/17] F-14179: no ICMP errors or L4 filter matches for non-first frags The fragment-relay fix moved the local-dispatch fragment drop below the forwarding block, so transit fragments again reach the router's ICMP generators and the SENDING filter hooks: - RFC 1812 4.3.2.7: a router must not generate an ICMP error for a non-first fragment (it cannot validate what the fragment does not carry). Guard the Parameter Problem, Time Exceeded and Fragmentation Needed sites in the forwarding path; the drops stay silent. - The SENDING filter hooks read the transport header at the IHL offset, which for a non-first fragment is payload: a port-based policy matched on garbage (RFC 1858 evasion). Skip the L4 notify for non-first fragments; the IP-level policy still applies. The first fragment (offset 0) carries a valid L4 header and is notified as usual. - Fix the local-dispatch drop comment: raw sockets and the IP-level filter do observe locally addressed fragments; what is never delivered is L4 data. --- src/wolfip.c | 53 +++++++++++++++++++++++++++++++++------------------- 1 file changed, 34 insertions(+), 19 deletions(-) diff --git a/src/wolfip.c b/src/wolfip.c index 0e06eab6..97a051a1 100644 --- a/src/wolfip.c +++ b/src/wolfip.c @@ -5187,6 +5187,7 @@ static void wolfIP_forward_packet(struct wolfIP *s, unsigned int out_if, * so the filter notify below must read the transport header at the * actual IHL, not a fixed 20-byte offset. */ uint32_t ip_hlen = (uint32_t)(ip->ver_ihl & 0x0fU) << 2; + int nonfirst_frag = (ee16(ip->flags_fo) & 0x1FFFU) != 0U; if (ip_hlen < IP_HEADER_LEN) ip_hlen = IP_HEADER_LEN; @@ -5196,20 +5197,27 @@ static void wolfIP_forward_packet(struct wolfIP *s, unsigned int out_if, else eth_output_add_header(s, out_if, mac, &ip->eth, ETH_TYPE_IP); } - if (ip->proto == WI_IPPROTO_TCP) - drop = wolfIP_filter_notify_tcp(WOLFIP_FILT_SENDING, s, out_if, - (struct wolfIP_tcp_seg *)ip, len, - ip_hlen); - else if (ip->proto == WI_IPPROTO_UDP) - drop = wolfIP_filter_notify_udp(WOLFIP_FILT_SENDING, s, out_if, - (struct wolfIP_udp_datagram *)ip, len, - ip_hlen); - else if (ip->proto == WI_IPPROTO_ICMP) - drop = wolfIP_filter_notify_icmp(WOLFIP_FILT_SENDING, s, out_if, - (struct wolfIP_icmp_packet *)ip, len, - ip_hlen); - if (drop != 0) - return; + /* A non-first fragment carries no L4 header: the bytes at the ip_hlen + * offset are payload, so the L4 filter hooks would match on garbage + * (RFC 1858 policy evasion). Only the IP-level policy applies to such + * fragments; the first fragment (offset 0) still carries a valid L4 + * header and is notified as usual. */ + if (!nonfirst_frag) { + if (ip->proto == WI_IPPROTO_TCP) + drop = wolfIP_filter_notify_tcp(WOLFIP_FILT_SENDING, s, out_if, + (struct wolfIP_tcp_seg *)ip, len, + ip_hlen); + else if (ip->proto == WI_IPPROTO_UDP) + drop = wolfIP_filter_notify_udp(WOLFIP_FILT_SENDING, s, out_if, + (struct wolfIP_udp_datagram *)ip, + len, ip_hlen); + else if (ip->proto == WI_IPPROTO_ICMP) + drop = wolfIP_filter_notify_icmp(WOLFIP_FILT_SENDING, s, out_if, + (struct wolfIP_icmp_packet *)ip, + len, ip_hlen); + if (drop != 0) + return; + } if (wolfIP_filter_notify_ip(WOLFIP_FILT_SENDING, s, out_if, ip, len) != 0) return; if (!wolfIP_ll_is_non_ethernet(s, out_if)) { @@ -11251,19 +11259,24 @@ static inline void ip_recv(struct wolfIP *s, unsigned int if_idx, if (out_if >= 0) { uint8_t mac[6]; int broadcast = 0; + /* RFC 1812 4.3.2.7: no ICMP error may be generated for a + * non-first fragment (the router cannot validate what the + * fragment does not carry); such drops are silent. */ + int nonfirst_frag = (ee16(ip->flags_fo) & 0x1FFFU) != 0U; if (bad_opt_off != 0) { /* RFC 1122 3.2.2.4: a transit datagram with a malformed * IP option gets a Parameter Problem pointing at the * offending option byte, not a silent drop. Multicast * destinations are exempt (RFC 1812 4.3.2.4). */ - if (!wolfIP_ip_is_multicast(dest)) + if (!wolfIP_ip_is_multicast(dest) && !nonfirst_frag) wolfIP_send_param_problem(s, if_idx, ip, (uint8_t)bad_opt_off); return; } if (ip->ttl <= 1) { - wolfIP_send_ttl_exceeded(s, if_idx, ip); + if (!nonfirst_frag) + wolfIP_send_ttl_exceeded(s, if_idx, ip); return; } /* A datagram larger than the egress IP MTU cannot be relayed. @@ -11274,7 +11287,8 @@ static inline void ip_recv(struct wolfIP *s, unsigned int if_idx, if (ee16(ip->len) > (wolfIP_frame_mtu(s, (unsigned int)out_if) - ETH_HEADER_LEN) && (ee16(ip->flags_fo) & 0x4000U) != 0U) { - wolfIP_send_frag_needed(s, if_idx, (unsigned int)out_if, ip); + if (!nonfirst_frag) + wolfIP_send_frag_needed(s, if_idx, (unsigned int)out_if, ip); return; } if (!wolfIP_forward_prepare(s, out_if, next_hop, mac, @@ -11333,8 +11347,9 @@ static inline void ip_recv(struct wolfIP *s, unsigned int if_idx, #endif /* WOLFIP_ENABLE_FORWARDING */ /* Fragment reassembly is not implemented: only a locally addressed * fragment can reach this point, since the forwarding path above relays - * transit fragments without reassembly (RFC 1812 5.2.6). Drop it; no - * partial datagram data is ever delivered. */ + * transit fragments without reassembly (RFC 1812 5.2.6). Drop it before + * L4 dispatch; raw sockets and the IP-level filter observe it as an IP + * datagram, which is the correct granularity for them. */ if ((ee16(ip->flags_fo) & 0x3FFFU) != 0U) return; if (bad_opt_off != 0) From d88f5de0e4afb81e963bf1d1de0158f2bc77ecfb Mon Sep 17 00:00:00 2001 From: Daniele Lacamera Date: Mon, 21 Sep 2026 08:38:15 +0200 Subject: [PATCH 08/17] unit: forwarding-path test module (router build) New src/test/unit/unit_tests_forwarding.c, self-contained (WOLFIP_ENABLE_FORWARDING=1), covers the branches the general ip_recv tests leave open: RFC 1812 4.3.2.7 silent drops for non-first fragments (TTL=1, DF oversize, malformed option) paired with the first-fragment reply cases, RFC 1812 4.3.2.4 multicast Parameter Problem exemption, and RFC 1858 L4 filter notify suppression for non-first fragments. --- Makefile | 3 +- src/test/unit/unit.c | 12 + src/test/unit/unit_tests_forwarding.c | 440 ++++++++++++++++++++++++++ 3 files changed, 454 insertions(+), 1 deletion(-) create mode 100644 src/test/unit/unit_tests_forwarding.c diff --git a/Makefile b/Makefile index 915e8d89..6a42f664 100644 --- a/Makefile +++ b/Makefile @@ -978,7 +978,8 @@ UNIT_TEST_SRCS:=src/test/unit/unit.c \ src/test/unit/unit_tests_arp_regression.c \ src/test/unit/unit_tests_dns_edges.c \ src/test/unit/unit_tests_misc_edges.c \ - src/test/unit/unit_tests_vlan.c + src/test/unit/unit_tests_vlan.c \ + src/test/unit/unit_tests_forwarding.c unit: build/test/unit diff --git a/src/test/unit/unit.c b/src/test/unit/unit.c index f41cd03b..6212a059 100644 --- a/src/test/unit/unit.c +++ b/src/test/unit/unit.c @@ -38,6 +38,7 @@ #include "unit_tests_dns_edges.c" #include "unit_tests_misc_edges.c" #include "unit_tests_vlan.c" +#include "unit_tests_forwarding.c" Suite *wolf_suite(void) { @@ -1798,6 +1799,17 @@ Suite *wolf_suite(void) #endif #endif /* WOLFIP_VLAN */ +#if WOLFIP_ENABLE_FORWARDING + /* --- unit_tests_forwarding.c (router build) --- */ + tcase_add_test(tc_proto, test_fwd_nonfirst_frag_ttl1_silent_drop); + tcase_add_test(tc_proto, test_fwd_first_frag_ttl1_sends_ttl_exceeded); + tcase_add_test(tc_proto, test_fwd_nonfirst_frag_df_oversize_silent_drop); + tcase_add_test(tc_proto, test_fwd_first_frag_df_oversize_sends_frag_needed); + tcase_add_test(tc_proto, test_fwd_nonfirst_frag_bad_option_silent_drop); + tcase_add_test(tc_proto, test_fwd_multicast_dest_bad_option_silent_drop); + tcase_add_test(tc_proto, test_fwd_nonfirst_frag_l4_filter_not_notified); +#endif /* WOLFIP_ENABLE_FORWARDING */ + suite_add_tcase(s, tc_core); suite_add_tcase(s, tc_utils); suite_add_tcase(s, tc_proto); diff --git a/src/test/unit/unit_tests_forwarding.c b/src/test/unit/unit_tests_forwarding.c new file mode 100644 index 00000000..42d9e673 --- /dev/null +++ b/src/test/unit/unit_tests_forwarding.c @@ -0,0 +1,440 @@ +/* unit_tests_forwarding.c + * + * Copyright (C) 2026 wolfSSL Inc. + * + * This file is part of wolfIP TCP/IP stack. + * + * wolfIP 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. + * + * wolfIP 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 + */ + +/* Forwarding-path tests (router build). This module forces + * WOLFIP_ENABLE_FORWARDING=1 so it is self-contained: every test here is + * dead code in a non-forwarding build, and the module must not silently + * lose coverage if the shared harness default changes. + * + * Covers the branches the general ip_recv tests leave open: + * - RFC 1812 4.3.2.7: no ICMP error for a non-first fragment (Time + * Exceeded, Fragmentation Needed, Parameter Problem all suppressed), + * while the first fragment of the same datagram still gets its reply. + * - RFC 1812 4.3.2.4: Parameter Problem suppressed for multicast + * destinations. + * - RFC 1858: the SENDING L4 filter hooks must not be notified for a + * non-first fragment, whose bytes at the IHL offset are payload, not a + * transport header (port-based policy matching on garbage). + */ +#undef WOLFIP_ENABLE_FORWARDING +#define WOLFIP_ENABLE_FORWARDING 1 + +/* Test-local filter counters for the L4-notify suppression test. */ +static int fwd_filter_notify_count; +static uint16_t fwd_filter_last_dport; + +static int fwd_filter_count_cb(void *arg, + const struct wolfIP_filter_event *event) +{ + (void)arg; + if (event->reason == WOLFIP_FILT_SENDING && + event->meta.ip_proto == WOLFIP_FILTER_PROTO_TCP) { + fwd_filter_notify_count++; + fwd_filter_last_dport = event->meta.l4.tcp.dst_port; + } + return 0; +} + +static void fwd_arp_store(struct wolfIP *s, unsigned int if_idx, ip4 ip, + const uint8_t *mac) +{ + s->arp.neighbors[0].ip = ip; + s->arp.neighbors[0].if_idx = if_idx; + memcpy(s->arp.neighbors[0].mac, mac, 6); +} + +/* ========================================================================= + * RFC 1812 4.3.2.7: non-first fragment, TTL=1 - silent drop + * ========================================================================= + * A router must not generate an ICMP error for a non-first fragment: it + * cannot validate what the fragment does not carry. The datagram is not + * relayed (TTL expired) and no Time Exceeded is sent. + */ +START_TEST(test_fwd_nonfirst_frag_ttl1_silent_drop) +{ + struct wolfIP s; + uint8_t frame[ETH_HEADER_LEN + IP_HEADER_LEN + 8]; + struct wolfIP_ip_packet *ip = (struct wolfIP_ip_packet *)frame; + ip4 primary_ip = 0x0A000001U; + ip4 secondary_ip = 0xC0A80101U; + ip4 dest_ip = 0xC0A80155U; + ip4 src_ip = 0x0A000002U; + static const uint8_t dest_mac[6] = {0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}; + + setup_stack_with_two_ifaces(&s, primary_ip, secondary_ip); + wolfIP_filter_set_callback(NULL, NULL); + fwd_arp_store(&s, TEST_SECOND_IF, dest_ip, dest_mac); + last_frame_sent_count = 0; + + memset(frame, 0, sizeof(frame)); + memcpy(ip->eth.dst, s.ll_dev[TEST_PRIMARY_IF].mac, 6); + memcpy(ip->eth.src, "\x01\x02\x03\x04\x05\x06", 6); + ip->eth.type = ee16(ETH_TYPE_IP); + ip->ver_ihl = 0x45; + ip->flags_fo = ee16(0x0001U); /* MF=0, offset=1 (8 bytes) */ + ip->ttl = 1; + ip->proto = WI_IPPROTO_UDP; + ip->len = ee16(IP_HEADER_LEN + 8); + ip->src = ee32(src_ip); + ip->dst = ee32(dest_ip); + fix_ip_checksum(ip); + + ip_recv(&s, TEST_PRIMARY_IF, ip, (uint32_t)sizeof(frame)); + + /* Silent: no Time Exceeded, no relay. */ + ck_assert_uint_eq(last_frame_sent_count, 0); +} +END_TEST + +/* ========================================================================= + * RFC 1812 4.3.2.7 selectivity: first fragment, TTL=1 - Time Exceeded + * ========================================================================= + * The same datagram's first fragment (offset 0) carries a valid transport + * header, so the Time Exceeded reply is generated. Pairs with the + * non-first-fragment silent drop above to prove the guard is selective. + */ +START_TEST(test_fwd_first_frag_ttl1_sends_ttl_exceeded) +{ + struct wolfIP s; + uint8_t frame[ETH_HEADER_LEN + IP_HEADER_LEN + 8]; + struct wolfIP_ip_packet *ip = (struct wolfIP_ip_packet *)frame; + ip4 primary_ip = 0x0A000001U; + ip4 secondary_ip = 0xC0A80101U; + ip4 dest_ip = 0xC0A80155U; + ip4 src_ip = 0x0A000002U; + static const uint8_t dest_mac[6] = {0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}; + + setup_stack_with_two_ifaces(&s, primary_ip, secondary_ip); + wolfIP_filter_set_callback(NULL, NULL); + fwd_arp_store(&s, TEST_SECOND_IF, dest_ip, dest_mac); + last_frame_sent_count = 0; + + memset(frame, 0, sizeof(frame)); + memcpy(ip->eth.dst, s.ll_dev[TEST_PRIMARY_IF].mac, 6); + memcpy(ip->eth.src, "\x01\x02\x03\x04\x05\x06", 6); + ip->eth.type = ee16(ETH_TYPE_IP); + ip->ver_ihl = 0x45; + ip->flags_fo = ee16(0x2000U); /* MF=1, offset=0 */ + ip->ttl = 1; + ip->proto = WI_IPPROTO_UDP; + ip->len = ee16(IP_HEADER_LEN + 8); + ip->src = ee32(src_ip); + ip->dst = ee32(dest_ip); + fix_ip_checksum(ip); + + ip_recv(&s, TEST_PRIMARY_IF, ip, (uint32_t)sizeof(frame)); + + /* Time Exceeded quoting header + the 8 payload bytes present. */ + ck_assert_uint_eq(last_frame_sent_count, 1); + ck_assert_uint_eq(last_frame_sent_size, + (uint32_t)(ETH_HEADER_LEN + IP_HEADER_LEN + 8 + IP_HEADER_LEN + 8)); + ck_assert_uint_eq(last_frame_sent[ETH_HEADER_LEN + IP_HEADER_LEN], + ICMP_TTL_EXCEEDED); +} +END_TEST + +/* ========================================================================= + * RFC 1812 4.3.2.7: non-first fragment, DF set, larger than egress MTU + * - silent drop + * ========================================================================= + * The Fragmentation Needed reply is suppressed for a non-first fragment; + * the datagram is dropped (it does not fit the egress and cannot be + * fragmented further). + */ +START_TEST(test_fwd_nonfirst_frag_df_oversize_silent_drop) +{ + struct wolfIP s; + uint8_t frame[ETH_HEADER_LEN + IP_HEADER_LEN + 580]; + struct wolfIP_ip_packet *ip = (struct wolfIP_ip_packet *)frame; + ip4 primary_ip = 0x0A000001U; + ip4 secondary_ip = 0xC0A80101U; + ip4 dest_ip = 0xC0A80155U; + ip4 src_ip = 0x0A000002U; + static const uint8_t dest_mac[6] = {0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}; + + setup_stack_with_two_ifaces(&s, primary_ip, secondary_ip); + wolfIP_filter_set_callback(NULL, NULL); + /* Egress frame budget 590 bytes: IP MTU 576 (the IPv4 minimum). */ + s.ll_dev[TEST_SECOND_IF].mtu = 590; + fwd_arp_store(&s, TEST_SECOND_IF, dest_ip, dest_mac); + last_frame_sent_count = 0; + + memset(frame, 0, sizeof(frame)); + memcpy(ip->eth.dst, s.ll_dev[TEST_PRIMARY_IF].mac, 6); + memcpy(ip->eth.src, "\x01\x02\x03\x04\x05\x06", 6); + ip->eth.type = ee16(ETH_TYPE_IP); + ip->ver_ihl = 0x45; + ip->flags_fo = ee16(0x4001U); /* DF=1, offset=1 (8 bytes) */ + ip->ttl = 64; + ip->proto = WI_IPPROTO_UDP; + ip->len = ee16(IP_HEADER_LEN + 580); /* 600 > egress MTU 576 */ + ip->src = ee32(src_ip); + ip->dst = ee32(dest_ip); + fix_ip_checksum(ip); + + ip_recv(&s, TEST_PRIMARY_IF, ip, (uint32_t)sizeof(frame)); + + /* Silent: no Fragmentation Needed, no relay. */ + ck_assert_uint_eq(last_frame_sent_count, 0); +} +END_TEST + +/* ========================================================================= + * RFC 1812 4.3.2.7 selectivity: first fragment, DF set, larger than + * egress MTU - Fragmentation Needed + * ========================================================================= + * Pairs with the non-first-fragment silent drop above: the first fragment + * carries a valid transport header, so the Fragmentation Needed reply is + * generated. + */ +START_TEST(test_fwd_first_frag_df_oversize_sends_frag_needed) +{ + struct wolfIP s; + uint8_t frame[ETH_HEADER_LEN + IP_HEADER_LEN + 580]; + struct wolfIP_ip_packet *ip = (struct wolfIP_ip_packet *)frame; + ip4 primary_ip = 0x0A000001U; + ip4 secondary_ip = 0xC0A80101U; + ip4 dest_ip = 0xC0A80155U; + ip4 src_ip = 0x0A000002U; + static const uint8_t dest_mac[6] = {0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}; + + setup_stack_with_two_ifaces(&s, primary_ip, secondary_ip); + wolfIP_filter_set_callback(NULL, NULL); + s.ll_dev[TEST_SECOND_IF].mtu = 590; + fwd_arp_store(&s, TEST_SECOND_IF, dest_ip, dest_mac); + last_frame_sent_count = 0; + + memset(frame, 0, sizeof(frame)); + memcpy(ip->eth.dst, s.ll_dev[TEST_PRIMARY_IF].mac, 6); + memcpy(ip->eth.src, "\x01\x02\x03\x04\x05\x06", 6); + ip->eth.type = ee16(ETH_TYPE_IP); + ip->ver_ihl = 0x45; + ip->flags_fo = ee16(0x4000U); /* DF=1, offset=0 */ + ip->ttl = 64; + ip->proto = WI_IPPROTO_UDP; + ip->len = ee16(IP_HEADER_LEN + 580); /* 600 > egress MTU 576 */ + ip->src = ee32(src_ip); + ip->dst = ee32(dest_ip); + fix_ip_checksum(ip); + + ip_recv(&s, TEST_PRIMARY_IF, ip, (uint32_t)sizeof(frame)); + + /* Fragmentation Needed with the egress next-hop MTU (576 = 0x0240). */ + ck_assert_uint_eq(last_frame_sent_count, 1); + ck_assert_uint_eq(last_frame_sent[ETH_HEADER_LEN + IP_HEADER_LEN], + ICMP_DEST_UNREACH); + ck_assert_uint_eq(last_frame_sent[ETH_HEADER_LEN + IP_HEADER_LEN + 1], + ICMP_FRAG_NEEDED); + ck_assert_uint_eq(last_frame_sent[ETH_HEADER_LEN + IP_HEADER_LEN + 6], + 0x02); + ck_assert_uint_eq(last_frame_sent[ETH_HEADER_LEN + IP_HEADER_LEN + 7], + 0x40); +} +END_TEST + +/* ========================================================================= + * RFC 1812 4.3.2.7: non-first fragment with a malformed IP option - + * silent drop + * ========================================================================= + * The Parameter Problem reply is suppressed for a non-first fragment; the + * datagram is dropped either way. + */ +START_TEST(test_fwd_nonfirst_frag_bad_option_silent_drop) +{ + struct wolfIP s; + uint8_t frame[ETH_HEADER_LEN + 24 + 8]; + struct wolfIP_ip_packet *ip = (struct wolfIP_ip_packet *)frame; + ip4 primary_ip = 0x0A000001U; + ip4 secondary_ip = 0xC0A80101U; + ip4 dest_ip = 0xC0A80155U; + ip4 src_ip = 0x0A000002U; + static const uint8_t dest_mac[6] = {0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}; + uint8_t *opt; + + setup_stack_with_two_ifaces(&s, primary_ip, secondary_ip); + wolfIP_filter_set_callback(NULL, NULL); + fwd_arp_store(&s, TEST_SECOND_IF, dest_ip, dest_mac); + last_frame_sent_count = 0; + + memset(frame, 0, sizeof(frame)); + memcpy(ip->eth.dst, s.ll_dev[TEST_PRIMARY_IF].mac, 6); + memcpy(ip->eth.src, "\x01\x02\x03\x04\x05\x06", 6); + ip->eth.type = ee16(ETH_TYPE_IP); + ip->ver_ihl = 0x46; /* IHL 6: 20-byte header + 4 option bytes */ + ip->flags_fo = ee16(0x0001U); /* MF=0, offset=1 (8 bytes) */ + ip->ttl = 64; + ip->proto = WI_IPPROTO_UDP; + ip->len = ee16(24 + 8); + ip->src = ee32(src_ip); + ip->dst = ee32(dest_ip); + /* Record Route option at offset 20 with length 100: runs past the end + * of the 4-byte option area. */ + opt = frame + ETH_HEADER_LEN + IP_HEADER_LEN; + opt[0] = 0x44; + opt[1] = 100; + fix_ip_checksum(ip); + + ip_recv(&s, TEST_PRIMARY_IF, ip, (uint32_t)sizeof(frame)); + + /* Silent: no Parameter Problem, no relay. */ + ck_assert_uint_eq(last_frame_sent_count, 0); +} +END_TEST + +/* ========================================================================= + * RFC 1812 4.3.2.4: multicast destination with a malformed IP option - + * silent drop + * ========================================================================= + * Multicast destinations are exempt from the Parameter Problem reply + * (error storms on group traffic); the datagram is dropped. + */ +START_TEST(test_fwd_multicast_dest_bad_option_silent_drop) +{ + struct wolfIP s; + uint8_t frame[ETH_HEADER_LEN + 24 + 8]; + struct wolfIP_ip_packet *ip = (struct wolfIP_ip_packet *)frame; + ip4 primary_ip = 0x0A000001U; + ip4 secondary_ip = 0xC0A80101U; + ip4 dest_ip = 0xE0000001U; /* 224.0.0.1, all-hosts */ + ip4 src_ip = 0x0A000002U; + ip4 gw_ip = 0xC0A801FEU; + static const uint8_t gw_mac[6] = {0x20, 0x21, 0x22, 0x23, 0x24, 0x25}; + uint8_t *opt; + + setup_stack_with_two_ifaces(&s, primary_ip, secondary_ip); + wolfIP_filter_set_callback(NULL, NULL); + /* The multicast destination is not on any connected subnet: a default + * route gives the forwarding path an egress so the option check runs. */ + ck_assert_int_eq(wolfIP_route_add(&s, TEST_SECOND_IF, 0x00000000U, 0, + gw_ip), 0); + fwd_arp_store(&s, TEST_SECOND_IF, gw_ip, gw_mac); + last_frame_sent_count = 0; + + memset(frame, 0, sizeof(frame)); + memcpy(ip->eth.dst, s.ll_dev[TEST_PRIMARY_IF].mac, 6); + memcpy(ip->eth.src, "\x01\x02\x03\x04\x05\x06", 6); + ip->eth.type = ee16(ETH_TYPE_IP); + ip->ver_ihl = 0x46; /* IHL 6: 20-byte header + 4 option bytes */ + ip->flags_fo = 0; + ip->ttl = 64; + ip->proto = WI_IPPROTO_UDP; + ip->len = ee16(24 + 8); + ip->src = ee32(src_ip); + ip->dst = ee32(dest_ip); + opt = frame + ETH_HEADER_LEN + IP_HEADER_LEN; + opt[0] = 0x44; + opt[1] = 100; + fix_ip_checksum(ip); + + ip_recv(&s, TEST_PRIMARY_IF, ip, (uint32_t)sizeof(frame)); + + /* Silent: multicast destination is exempt, no relay. */ + ck_assert_uint_eq(last_frame_sent_count, 0); +} +END_TEST + +/* ========================================================================= + * RFC 1858: SENDING L4 filter hooks are not notified for a non-first + * fragment + * ========================================================================= + * The bytes at the IHL offset of a non-first fragment are payload, not a + * transport header. Notifying the TCP/UDP hooks would let port-based + * policy match on garbage. The non-first fragment is relayed without a + * TCP notification; the first fragment of a TCP datagram is notified with + * the real header's destination port. + */ +START_TEST(test_fwd_nonfirst_frag_l4_filter_not_notified) +{ + struct wolfIP s; + uint8_t frame[ETH_HEADER_LEN + IP_HEADER_LEN + TCP_HEADER_LEN]; + struct wolfIP_ip_packet *ip = (struct wolfIP_ip_packet *)frame; + ip4 primary_ip = 0x0A000001U; + ip4 secondary_ip = 0xC0A80101U; + ip4 dest_ip = 0xC0A80155U; + ip4 src_ip = 0x0A000002U; + static const uint8_t dest_mac[6] = {0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}; + uint8_t *payload; + + setup_stack_with_two_ifaces(&s, primary_ip, secondary_ip); + wolfIP_filter_set_callback(fwd_filter_count_cb, NULL); + wolfIP_filter_set_tcp_mask(WOLFIP_FILT_MASK(WOLFIP_FILT_SENDING)); + wolfIP_filter_set_udp_mask(0); + wolfIP_filter_set_icmp_mask(0); + wolfIP_filter_set_ip_mask(0); + fwd_arp_store(&s, TEST_SECOND_IF, dest_ip, dest_mac); + fwd_filter_notify_count = 0; + fwd_filter_last_dport = 0; + last_frame_sent_count = 0; + + /* Non-first fragment: the 20 payload bytes are crafted to look like a + * TCP header (src port 80, dst port 443). If the notify fired on them, + * the counter would move. */ + memset(frame, 0, sizeof(frame)); + memcpy(ip->eth.dst, s.ll_dev[TEST_PRIMARY_IF].mac, 6); + memcpy(ip->eth.src, "\x01\x02\x03\x04\x05\x06", 6); + ip->eth.type = ee16(ETH_TYPE_IP); + ip->ver_ihl = 0x45; + ip->flags_fo = ee16(0x0001U); /* MF=0, offset=1: non-first fragment */ + ip->ttl = 64; + ip->proto = WI_IPPROTO_TCP; + ip->len = ee16(IP_HEADER_LEN + TCP_HEADER_LEN); + ip->src = ee32(src_ip); + ip->dst = ee32(dest_ip); + payload = frame + ETH_HEADER_LEN + IP_HEADER_LEN; + payload[0] = 0x00; payload[1] = 0x50; /* src port 80 */ + payload[2] = 0x01; payload[3] = 0xBB; /* dst port 443 */ + fix_ip_checksum(ip); + + ip_recv(&s, TEST_PRIMARY_IF, ip, (uint32_t)sizeof(frame)); + + /* Relayed, and no TCP notification was generated from the payload. */ + ck_assert_uint_eq(last_frame_sent_count, 1); + ck_assert_uint_eq(fwd_filter_notify_count, 0); + + /* First fragment of a TCP datagram: the notify fires with the real + * header's destination port. */ + memset(frame, 0, sizeof(frame)); + memcpy(ip->eth.dst, s.ll_dev[TEST_PRIMARY_IF].mac, 6); + memcpy(ip->eth.src, "\x01\x02\x03\x04\x05\x06", 6); + ip->eth.type = ee16(ETH_TYPE_IP); + ip->ver_ihl = 0x45; + ip->flags_fo = ee16(0x2000U); /* MF=1, offset=0 */ + ip->ttl = 64; + ip->proto = WI_IPPROTO_TCP; + ip->len = ee16(IP_HEADER_LEN + TCP_HEADER_LEN); + ip->src = ee32(src_ip); + ip->dst = ee32(dest_ip); + payload = frame + ETH_HEADER_LEN + IP_HEADER_LEN; + payload[0] = 0x00; payload[1] = 0x50; /* src port 80 */ + payload[2] = 0x01; payload[3] = 0xBB; /* dst port 443 */ + fix_ip_checksum(ip); + + last_frame_sent_count = 0; + ip_recv(&s, TEST_PRIMARY_IF, ip, (uint32_t)sizeof(frame)); + + ck_assert_uint_eq(last_frame_sent_count, 1); + ck_assert_uint_eq(fwd_filter_notify_count, 1); + ck_assert_uint_eq(fwd_filter_last_dport, ee16(443)); + + wolfIP_filter_set_callback(NULL, NULL); + wolfIP_filter_set_tcp_mask(0); +} +END_TEST From cf069aed581bff74f94cb86654f347a2a380c77f Mon Sep 17 00:00:00 2001 From: Daniele Lacamera Date: Mon, 21 Sep 2026 09:11:40 +0200 Subject: [PATCH 09/17] unit: drive ip_recv loopback tests through real frames The two loopback-address drop tests injected via the demux helper, which bypasses the ip_recv source/destination gate they claim to verify. Build real frames and call ip_recv instead; the wildcard socket stays as the must-not-receive witness. --- src/test/unit/unit_tests_ip_arp_recv.c | 49 +++++++++++++++++++++++--- 1 file changed, 44 insertions(+), 5 deletions(-) diff --git a/src/test/unit/unit_tests_ip_arp_recv.c b/src/test/unit/unit_tests_ip_arp_recv.c index 4c35e3c4..6cbe11ec 100644 --- a/src/test/unit/unit_tests_ip_arp_recv.c +++ b/src/test/unit/unit_tests_ip_arp_recv.c @@ -1124,6 +1124,9 @@ END_TEST START_TEST(test_ip_recv_loopback_dst_on_non_loopback_dropped) { struct wolfIP s; + uint8_t frame[ETH_HEADER_LEN + IP_HEADER_LEN + UDP_HEADER_LEN]; + struct wolfIP_ip_packet *ip = (struct wolfIP_ip_packet *)frame; + uint8_t *udp_hdr = frame + ETH_HEADER_LEN + IP_HEADER_LEN; struct tsocket *ts; ip4 local_ip = 0x0A000001U; ip4 remote_ip = 0x0A000002U; @@ -1133,16 +1136,33 @@ START_TEST(test_ip_recv_loopback_dst_on_non_loopback_dropped) mock_link_init(&s); wolfIP_ipconfig_set(&s, local_ip, 0xFFFFFF00U, 0); + /* A wildcard socket on the destination port: the drop must happen in + * ip_recv, before any demux can see the datagram. */ ts = udp_new_socket(&s); ck_assert_ptr_nonnull(ts); ts->src_port = 1234; ts->local_ip = IPADDR_ANY; /* Inject from non-loopback interface to loopback destination */ - inject_udp_datagram(&s, TEST_PRIMARY_IF, remote_ip, loop_dst, - 9999, 1234, NULL, 0); + memset(frame, 0, sizeof(frame)); + memcpy(ip->eth.dst, s.ll_dev[TEST_PRIMARY_IF].mac, 6); + memcpy(ip->eth.src, "\x01\x02\x03\x04\x05\x06", 6); + ip->eth.type = ee16(ETH_TYPE_IP); + ip->ver_ihl = 0x45; + ip->ttl = 64; + ip->proto = WI_IPPROTO_UDP; + ip->len = ee16(IP_HEADER_LEN + UDP_HEADER_LEN); + ip->src = ee32(remote_ip); + ip->dst = ee32(loop_dst); + fix_ip_checksum(ip); + udp_hdr[0] = 0x27; udp_hdr[1] = 0x0F; /* src port 9999 */ + udp_hdr[2] = 0x04; udp_hdr[3] = 0xD2; /* dst port 1234 */ + udp_hdr[4] = 0x00; udp_hdr[5] = UDP_HEADER_LEN; + /* csum left 0: validation is skipped, the drop is upstream of it. */ + + ip_recv(&s, TEST_PRIMARY_IF, ip, (uint32_t)sizeof(frame)); - /* Must be dropped — loopback addresses must not arrive on wire */ + /* Must be dropped - loopback addresses must not arrive on wire */ ck_assert_ptr_eq(fifo_peek(&ts->sock.udp.rxbuf), NULL); ck_assert_uint_eq(ts->events & CB_EVENT_READABLE, 0); } @@ -1156,6 +1176,9 @@ END_TEST START_TEST(test_ip_recv_loopback_src_on_non_loopback_dropped) { struct wolfIP s; + uint8_t frame[ETH_HEADER_LEN + IP_HEADER_LEN + UDP_HEADER_LEN]; + struct wolfIP_ip_packet *ip = (struct wolfIP_ip_packet *)frame; + uint8_t *udp_hdr = frame + ETH_HEADER_LEN + IP_HEADER_LEN; struct tsocket *ts; ip4 local_ip = 0x0A000001U; ip4 loop_src = 0x7F000002U; /* 127.0.0.2 as source */ @@ -1169,8 +1192,24 @@ START_TEST(test_ip_recv_loopback_src_on_non_loopback_dropped) ts->src_port = 1234; ts->local_ip = IPADDR_ANY; - inject_udp_datagram(&s, TEST_PRIMARY_IF, loop_src, local_ip, - 9999, 1234, NULL, 0); + /* Loopback source, valid local destination: the symmetric source + * check in ip_recv must drop it. */ + memset(frame, 0, sizeof(frame)); + memcpy(ip->eth.dst, s.ll_dev[TEST_PRIMARY_IF].mac, 6); + memcpy(ip->eth.src, "\x01\x02\x03\x04\x05\x06", 6); + ip->eth.type = ee16(ETH_TYPE_IP); + ip->ver_ihl = 0x45; + ip->ttl = 64; + ip->proto = WI_IPPROTO_UDP; + ip->len = ee16(IP_HEADER_LEN + UDP_HEADER_LEN); + ip->src = ee32(loop_src); + ip->dst = ee32(local_ip); + fix_ip_checksum(ip); + udp_hdr[0] = 0x27; udp_hdr[1] = 0x0F; /* src port 9999 */ + udp_hdr[2] = 0x04; udp_hdr[3] = 0xD2; /* dst port 1234 */ + udp_hdr[4] = 0x00; udp_hdr[5] = UDP_HEADER_LEN; + + ip_recv(&s, TEST_PRIMARY_IF, ip, (uint32_t)sizeof(frame)); ck_assert_ptr_eq(fifo_peek(&ts->sock.udp.rxbuf), NULL); } From 313896b53c4b2735fb00396c107379c7ebf0a550 Mon Sep 17 00:00:00 2001 From: Daniele Lacamera Date: Mon, 21 Sep 2026 09:12:18 +0200 Subject: [PATCH 10/17] F-14187: deliver wildcard-bound UDP after the address arrives A socket bound before any interface had an address latched local_ip == 0 and was skipped by the ingress match for its whole life, while the stack answered port-unreachable for every datagram. Match on src_port != 0 (a bound slot) instead; liveness no longer rides on the egress snapshot. Tests cover the embedded start-up order for both protocols: UDP bind and TCP bind+listen before ipconfig_set, then delivery/accept after. --- src/test/unit/unit.c | 2 + src/test/unit/unit_tests_api.c | 85 ++++++++++++++++++++++++++++++++++ src/wolfip.c | 10 ++-- 3 files changed, 92 insertions(+), 5 deletions(-) diff --git a/src/test/unit/unit.c b/src/test/unit/unit.c index 6212a059..e3322c0e 100644 --- a/src/test/unit/unit.c +++ b/src/test/unit/unit.c @@ -264,6 +264,7 @@ Suite *wolf_suite(void) tcase_add_test(tc_utils, test_sock_accept_negative_fd); tcase_add_test(tc_utils, test_sock_accept_invalid_tcp_fd); tcase_add_test(tc_utils, test_sock_accept_success_sets_addr); + tcase_add_test(tc_utils, test_tcp_listen_before_ipconfig_accepts_after); tcase_add_test(tc_utils, test_sock_accept_listener_resets_paws_state); tcase_add_test(tc_utils, test_syn_rcvd_rst_listener_resets_paws_state); tcase_add_test(tc_utils, test_sock_accept_no_available_socket); @@ -995,6 +996,7 @@ Suite *wolf_suite(void) tcase_add_test(tc_proto, test_udp_sendto_and_recvfrom); tcase_add_test(tc_proto, test_udp_sendto_unbound_socket_receives_reply); tcase_add_test(tc_proto, test_udp_wildcard_bind_receives_all_local_addrs); + tcase_add_test(tc_proto, test_udp_wildcard_bind_before_ipconfig_receives_after); tcase_add_test(tc_proto, test_udp_wildcard_bind_drops_third_party_dst); tcase_add_test(tc_proto, test_udp_dhcp_exchange_only_reaches_dhcp_socket); tcase_add_test(tc_proto, test_udp_sendto_respects_mtu_api); diff --git a/src/test/unit/unit_tests_api.c b/src/test/unit/unit_tests_api.c index e9d37669..cb555c5e 100644 --- a/src/test/unit/unit_tests_api.c +++ b/src/test/unit/unit_tests_api.c @@ -1013,6 +1013,91 @@ START_TEST(test_udp_wildcard_bind_receives_all_local_addrs) } END_TEST +/* Embedded start-up order: the socket is bound before the interface has + * any address. The bind snapshot latches local_ip == 0; the stack must + * still deliver once the address arrives, not answer port-unreachable + * for the life of the socket. */ +START_TEST(test_udp_wildcard_bind_before_ipconfig_receives_after) +{ + struct wolfIP s; + int sd; + struct wolfIP_sockaddr_in sin; + struct wolfIP_sockaddr_in from; + socklen_t from_len = sizeof(from); + uint8_t payload[4] = {1, 2, 3, 4}; + uint8_t rxbuf[8]; + ip4 local_ip = 0x0A000001U; + int ret; + + wolfIP_init(&s); + mock_link_init(&s); + + sd = wolfIP_sock_socket(&s, AF_INET, IPSTACK_SOCK_DGRAM, WI_IPPROTO_UDP); + ck_assert_int_gt(sd, 0); + memset(&sin, 0, sizeof(sin)); + sin.sin_family = AF_INET; + sin.sin_port = ee16(5353); + sin.sin_addr.s_addr = 0U; /* INADDR_ANY */ + ck_assert_int_eq(wolfIP_sock_bind(&s, sd, (struct wolfIP_sockaddr *)&sin, + sizeof(sin)), 0); + /* No interface address existed at bind time: the snapshot is 0. */ + ck_assert_uint_eq(s.udpsockets[SOCKET_UNMARK(sd)].local_ip, 0); + + wolfIP_ipconfig_set(&s, local_ip, 0xFFFFFF00U, 0); + + inject_udp_datagram(&s, TEST_PRIMARY_IF, 0x0A000064U, local_ip, + 60000, 5353, payload, sizeof(payload)); + + memset(&from, 0, sizeof(from)); + ret = wolfIP_sock_recvfrom(&s, sd, rxbuf, sizeof(rxbuf), 0, + (struct wolfIP_sockaddr *)&from, &from_len); + ck_assert_int_eq(ret, (int)sizeof(payload)); + ck_assert_mem_eq(rxbuf, payload, sizeof(payload)); + /* Delivered, not bounced with ICMP Port Unreachable. */ + ck_assert_uint_eq(last_frame_sent_count, 0); +} +END_TEST + +/* Same start-up order on the TCP side: bind + listen before the address + * is configured. A SYN addressed to the just-configured address must be + * taken by the wildcard listener and accepted. */ +START_TEST(test_tcp_listen_before_ipconfig_accepts_after) +{ + struct wolfIP s; + int listen_sd; + int client_sd; + struct tsocket *listener; + struct wolfIP_sockaddr_in sin; + socklen_t alen = sizeof(sin); + ip4 local_ip = 0x0A000001U; + + wolfIP_init(&s); + mock_link_init(&s); + + listen_sd = wolfIP_sock_socket(&s, AF_INET, IPSTACK_SOCK_STREAM, + WI_IPPROTO_TCP); + ck_assert_int_gt(listen_sd, 0); + memset(&sin, 0, sizeof(sin)); + sin.sin_family = AF_INET; + sin.sin_port = ee16(8080); + sin.sin_addr.s_addr = 0U; /* INADDR_ANY */ + ck_assert_int_eq(wolfIP_sock_bind(&s, listen_sd, + (struct wolfIP_sockaddr *)&sin, sizeof(sin)), 0); + ck_assert_int_eq(wolfIP_sock_listen(&s, listen_sd, 1), 0); + ck_assert_uint_eq(s.tcpsockets[SOCKET_UNMARK(listen_sd)].local_ip, 0); + + wolfIP_ipconfig_set(&s, local_ip, 0xFFFFFF00U, 0); + + inject_tcp_syn(&s, TEST_PRIMARY_IF, local_ip, 8080); + listener = &s.tcpsockets[SOCKET_UNMARK(listen_sd)]; + ck_assert_int_eq(listener->sock.tcp.state, TCP_SYN_RCVD); + + client_sd = wolfIP_sock_accept(&s, listen_sd, (struct wolfIP_sockaddr *)&sin, + &alen); + ck_assert_int_gt(client_sd, 0); +} +END_TEST + START_TEST(test_udp_wildcard_bind_drops_third_party_dst) { struct wolfIP s; diff --git a/src/wolfip.c b/src/wolfip.c index 97a051a1..33dc817f 100644 --- a/src/wolfip.c +++ b/src/wolfip.c @@ -3089,11 +3089,11 @@ static void udp_try_recv(struct wolfIP *s, unsigned int if_idx, * selected into local_ip at bind time: a wildcard (INADDR_ANY) * bind must receive datagrams addressed to any local address * (POSIX), the same rule the TCP LISTEN match applies via - * bound_local_ip. local_ip/if_idx stay egress-only. The - * t->local_ip != 0 guard keeps an unbound socket (local_ip == 0) - * out of the match: only the DHCP relaxation above may deliver - * to one. */ - int bound_match = (t->local_ip != 0) && + * bound_local_ip. local_ip/if_idx stay egress-only. Liveness is + * src_port != 0 (a bound slot), not local_ip != 0: a socket bound + * before any interface had an address snapshots local_ip == 0 and + * must still receive once the address arrives. */ + int bound_match = (t->src_port != 0) && ((t->bound_local_ip == IPADDR_ANY) || (t->bound_local_ip == dst_ip)); int addr_match; From dc6e9069ef3803153d35f14c9cd43e376bd81edd Mon Sep 17 00:00:00 2001 From: Daniele Lacamera Date: Mon, 21 Sep 2026 09:31:10 +0200 Subject: [PATCH 11/17] F-14174: gem.h: state the -1 vs TRM error code contract amd_eth_init returns -1 on alloc failure while the TRM port contract says TRM errors. The header now names the contract so the two are not read as a bug; no behavior change. --- src/port/amd/common/gem.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/port/amd/common/gem.h b/src/port/amd/common/gem.h index 56b72c35..a445315b 100644 --- a/src/port/amd/common/gem.h +++ b/src/port/amd/common/gem.h @@ -17,7 +17,8 @@ /* Initialize the GEM, its clock + reset, the PHY, and populate the wolfIP * link-layer device. Returns: - * < 0 on error (negated TRM code) + * < 0 on error (driver-local sentinel, not a TRM code: + * -10 = no PHY responded on MDIO, -11 = gem_phy_init failed) * bits [7:0] PHY MDIO address used * bit [8] link_up flag (1 = link is up at end of init) */ From 4a049f0ba2d8b2d3d40645951fd397990e95e92e Mon Sep 17 00:00:00 2001 From: Daniele Lacamera Date: Mon, 21 Sep 2026 09:31:26 +0200 Subject: [PATCH 12/17] F-14171: tcp_persist_start: do not set persist_active on timer insert failure With the timer heap full the persist probe would never fire while the flag claims it is active, stalling the sender on a zero-window peer. Only arm the flag when the insert succeeds. Test fills the heap and asserts the flag stays clear. --- src/test/unit/unit.c | 1 + src/test/unit/unit_tests_proto.c | 35 ++++++++++++++++++++++++++++++++ src/wolfip.c | 7 +++++++ 3 files changed, 43 insertions(+) diff --git a/src/test/unit/unit.c b/src/test/unit/unit.c index e3322c0e..8065a561 100644 --- a/src/test/unit/unit.c +++ b/src/test/unit/unit.c @@ -421,6 +421,7 @@ Suite *wolf_suite(void) tcase_add_test(tc_utils, test_poll_tcp_residual_window_allows_exact_fit); tcase_add_test(tc_utils, test_poll_tcp_zero_window_arms_persist); tcase_add_test(tc_utils, test_tcp_persist_start_stops_when_window_reopens_or_no_unsent_payload); + tcase_add_test(tc_utils, test_tcp_persist_start_no_active_flag_when_timer_heap_full); tcase_add_test(tc_utils, test_tcp_persist_helpers_ignore_non_tcp_and_null_inputs); tcase_add_test(tc_utils, test_tcp_has_pending_unsent_payload_ignores_zero_ip_len_ack_only_desc); tcase_add_test(tc_utils, test_tcp_initial_cwnd_caps_to_iw10_and_half_rwnd); diff --git a/src/test/unit/unit_tests_proto.c b/src/test/unit/unit_tests_proto.c index f7a2df76..4e7f76b6 100644 --- a/src/test/unit/unit_tests_proto.c +++ b/src/test/unit/unit_tests_proto.c @@ -1584,6 +1584,41 @@ START_TEST(test_tcp_persist_start_stops_when_window_reopens_or_no_unsent_payload } END_TEST +/* A persist arm that cannot take a timer slot must not leave the active + * flag set: with no timer behind it the probe would never fire and the + * sender would stall on a zero-window peer (F-14171). */ +START_TEST(test_tcp_persist_start_no_active_flag_when_timer_heap_full) +{ + struct wolfIP s; + struct tsocket *ts; + struct wolfIP_timer dummy; + uint32_t i; + + wolfIP_init(&s); + mock_link_init(&s); + + ts = &s.tcpsockets[0]; + memset(ts, 0, sizeof(*ts)); + ts->proto = WI_IPPROTO_TCP; + ts->S = &s; + ts->sock.tcp.state = TCP_ESTABLISHED; + ts->sock.tcp.peer_rwnd = 0; + fifo_init(&ts->sock.tcp.txbuf, ts->txmem, TXBUF_SIZE); + ck_assert_int_eq(enqueue_tcp_tx(ts, 8, (TCP_FLAG_ACK | TCP_FLAG_PSH)), 0); + + /* Fill the timer heap so the persist arm cannot take a slot. */ + memset(&dummy, 0, sizeof(dummy)); + for (i = 0; i < MAX_TIMERS; i++) { + dummy.expires = 1000000U + i; + ck_assert_int_ne(timers_binheap_insert(&s.timers, dummy), NO_TIMER); + } + + tcp_persist_start(ts, 1000); + ck_assert_uint_eq(ts->sock.tcp.persist_active, 0); + ck_assert_int_eq(ts->sock.tcp.tmr_persist, NO_TIMER); +} +END_TEST + START_TEST(test_tcp_persist_helpers_ignore_non_tcp_and_null_inputs) { struct wolfIP s; diff --git a/src/wolfip.c b/src/wolfip.c index 33dc817f..5a7baabf 100644 --- a/src/wolfip.c +++ b/src/wolfip.c @@ -4583,6 +4583,13 @@ static void tcp_persist_start(struct tsocket *t, uint64_t now) tmr.arg = t; tmr.cb = tcp_persist_cb; t->sock.tcp.tmr_persist = timers_binheap_insert(&t->S->timers, tmr); + /* Only mark persist active when the timer actually took a slot: + * an active flag with no timer behind it would never fire and would + * stall the sender on a zero-window peer until a later event cleared + * it (same guard as the control RTO arm). */ + if (t->sock.tcp.tmr_persist == NO_TIMER) { + return; + } t->sock.tcp.persist_active = 1; } From 87a74b64a5b138c268c87b9105889a134d6e42cd Mon Sep 17 00:00:00 2001 From: Daniele Lacamera Date: Mon, 21 Sep 2026 09:31:26 +0200 Subject: [PATCH 13/17] F-8566: tcp_ctrl_rto_stop: reinitialize base RTO after a control timeout RFC 6298 5.7: if SYN retransmits used a sub-3s base RTO, reset the base to 3s when the control sequence completes. Test covers the reset and the no-timeout no-op case. --- src/test/unit/unit.c | 1 + src/test/unit/unit_tests_proto.c | 30 ++++++++++++++++++++++++++++++ src/wolfip.c | 10 ++++++++++ 3 files changed, 41 insertions(+) diff --git a/src/test/unit/unit.c b/src/test/unit/unit.c index 8065a561..ad69e576 100644 --- a/src/test/unit/unit.c +++ b/src/test/unit/unit.c @@ -422,6 +422,7 @@ Suite *wolf_suite(void) tcase_add_test(tc_utils, test_poll_tcp_zero_window_arms_persist); tcase_add_test(tc_utils, test_tcp_persist_start_stops_when_window_reopens_or_no_unsent_payload); tcase_add_test(tc_utils, test_tcp_persist_start_no_active_flag_when_timer_heap_full); + tcase_add_test(tc_utils, test_tcp_ctrl_rto_stop_resets_base_rto_after_control_timeout); tcase_add_test(tc_utils, test_tcp_persist_helpers_ignore_non_tcp_and_null_inputs); tcase_add_test(tc_utils, test_tcp_has_pending_unsent_payload_ignores_zero_ip_len_ack_only_desc); tcase_add_test(tc_utils, test_tcp_initial_cwnd_caps_to_iw10_and_half_rwnd); diff --git a/src/test/unit/unit_tests_proto.c b/src/test/unit/unit_tests_proto.c index 4e7f76b6..5d1e7a2c 100644 --- a/src/test/unit/unit_tests_proto.c +++ b/src/test/unit/unit_tests_proto.c @@ -1619,6 +1619,36 @@ START_TEST(test_tcp_persist_start_no_active_flag_when_timer_heap_full) } END_TEST +/* RFC 6298 5.7: if a control timeout (SYN retransmit) occurred while the + * base RTO was below 3 s, the base must be reinitialized to 3 s when the + * control sequence completes (F-8566). */ +START_TEST(test_tcp_ctrl_rto_stop_resets_base_rto_after_control_timeout) +{ + struct wolfIP s; + struct tsocket *ts; + + wolfIP_init(&s); + mock_link_init(&s); + + ts = &s.tcpsockets[0]; + memset(ts, 0, sizeof(*ts)); + ts->proto = WI_IPPROTO_TCP; + ts->S = &s; + ts->sock.tcp.tmr_rto = NO_TIMER; + + ts->sock.tcp.rto = 1000; + ts->sock.tcp.ctrl_rto_retries = 2; + tcp_ctrl_rto_stop(ts); + ck_assert_uint_eq(ts->sock.tcp.rto, 3000U); + ck_assert_uint_eq(ts->sock.tcp.ctrl_rto_retries, 0); + + /* Without a control timeout the base RTO is left untouched. */ + ts->sock.tcp.rto = 1000; + tcp_ctrl_rto_stop(ts); + ck_assert_uint_eq(ts->sock.tcp.rto, 1000U); +} +END_TEST + START_TEST(test_tcp_persist_helpers_ignore_non_tcp_and_null_inputs) { struct wolfIP s; diff --git a/src/wolfip.c b/src/wolfip.c index 5a7baabf..11208d7e 100644 --- a/src/wolfip.c +++ b/src/wolfip.c @@ -169,6 +169,8 @@ struct wolfIP_icmp_packet; #define TCP_RTO_G_MS 1U /* RFC 6298 §5.5: maximum timer value G; caps the backed-off RTO. */ #define TCP_RTO_BACKOFF_MAX_MS 64000U +/* RFC 6298 §5.7: base RTO to reinitialize after a SYN timeout. */ +#define TCP_RTO_SYN_INIT_MS 3000U #define TCP_PERSIST_MIN_MS 1000U #define TCP_PERSIST_MAX_MS 60000U #ifndef TCP_FIN_WAIT_2_TIMEOUT_MS @@ -4297,6 +4299,14 @@ static void tcp_ctrl_rto_stop(struct tsocket *t) timer_binheap_cancel(&t->S->timers, t->sock.tcp.tmr_rto); t->sock.tcp.tmr_rto = NO_TIMER; } + /* RFC 6298 §5.7: a control timeout (e.g. SYN retransmit) while the + * base RTO was below 3 s means the first RTT estimate is stale; reset + * the base to 3 s now that the control sequence is done and data may + * flow. RTT sampling re-derives the RTO from there. */ + if (t->sock.tcp.ctrl_rto_retries > 0 && + t->sock.tcp.rto < TCP_RTO_SYN_INIT_MS) { + t->sock.tcp.rto = TCP_RTO_SYN_INIT_MS; + } t->sock.tcp.ctrl_rto_active = 0; t->sock.tcp.ctrl_rto_retries = 0; } From a96711505511bf017c94eea685930e5066f1bca1 Mon Sep 17 00:00:00 2001 From: Daniele Lacamera Date: Mon, 21 Sep 2026 09:31:26 +0200 Subject: [PATCH 14/17] F-14172: dns_schedule_timer: drop the dead overflow guard The single increment site caps dns_retry_count at DNS_QUERY_RETRIES, so the shift (max 3) can never overflow and the guard was dead code. The test now pins the reachable maximum shift instead of an unreachable state. --- src/test/unit/unit.c | 2 +- src/test/unit/unit_tests_dns_dhcp.c | 9 ++++++--- src/wolfip.c | 9 +++------ 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/test/unit/unit.c b/src/test/unit/unit.c index ad69e576..e005dec8 100644 --- a/src/test/unit/unit.c +++ b/src/test/unit/unit.c @@ -551,7 +551,7 @@ Suite *wolf_suite(void) tcase_add_test(tc_utils, test_sock_opts_and_names); tcase_add_test(tc_utils, test_dns_send_query_errors); tcase_add_test(tc_utils, test_dns_schedule_timer_initial_jitter_and_cancel); - tcase_add_test(tc_utils, test_dns_schedule_timer_caps_large_retry_shift); + tcase_add_test(tc_utils, test_dns_schedule_timer_caps_retry_shift); tcase_add_test(tc_utils, test_dns_send_query_schedules_timeout); tcase_add_test(tc_utils, test_dns_send_query_timer_heap_full_aborts); tcase_add_test(tc_utils, test_dns_send_query_send_failure_clears_outstanding_state); diff --git a/src/test/unit/unit_tests_dns_dhcp.c b/src/test/unit/unit_tests_dns_dhcp.c index ed928c7c..e42f0dbc 100644 --- a/src/test/unit/unit_tests_dns_dhcp.c +++ b/src/test/unit/unit_tests_dns_dhcp.c @@ -3367,17 +3367,20 @@ START_TEST(test_dns_schedule_timer_initial_jitter_and_cancel) } END_TEST -START_TEST(test_dns_schedule_timer_caps_large_retry_shift) +START_TEST(test_dns_schedule_timer_caps_retry_shift) { struct wolfIP s; wolfIP_init(&s); s.last_tick = 100U; - s.dns_retry_count = 64U; + /* The largest shift reachable: the single increment site caps the + * count at DNS_QUERY_RETRIES. */ + s.dns_retry_count = DNS_QUERY_RETRIES; dns_schedule_timer(&s); ck_assert_int_ne(s.dns_timer, NO_TIMER); - ck_assert_uint_eq(find_timer_expiry(&s, s.dns_timer), UINT64_MAX); + ck_assert_uint_eq(find_timer_expiry(&s, s.dns_timer), + 100U + (DNS_QUERY_TIMEOUT << DNS_QUERY_RETRIES)); } END_TEST diff --git a/src/wolfip.c b/src/wolfip.c index 11208d7e..e43428c7 100644 --- a/src/wolfip.c +++ b/src/wolfip.c @@ -11879,7 +11879,6 @@ static int dns_schedule_timer(struct wolfIP *s) { struct wolfIP_timer tmr = { }; uint64_t interval = DNS_QUERY_TIMEOUT; - uint8_t shift; if (!s) return -1; @@ -11890,11 +11889,9 @@ static int dns_schedule_timer(struct wolfIP *s) interval = DNS_QUERY_TIMEOUT_INITIAL + (wolfIP_getrandom() % DNS_QUERY_TIMEOUT_INITIAL_JITTER); } else { - shift = s->dns_retry_count; - if (shift >= 64U || interval > (UINT64_MAX >> shift)) - interval = UINT64_MAX - s->last_tick; - else - interval <<= shift; + /* dns_retry_count is capped at DNS_QUERY_RETRIES by its single + * increment site, so the shift cannot overflow. */ + interval <<= s->dns_retry_count; } tmr.expires = s->last_tick + interval; tmr.arg = s; From 32875e77dff4e41bb94386f324c3b35bdcbbc7e4 Mon Sep 17 00:00:00 2001 From: Daniele Lacamera Date: Mon, 21 Sep 2026 09:31:26 +0200 Subject: [PATCH 15/17] F-14188: wolfIP_send_port_unreachable: clamp quote to the datagram The quoted original is capped at ihl+8 without checking the declared datagram length, so short datagrams quote bytes past the packet (the sibling ICMP senders clamp via orig->len). Same clamp here. Test verifies a 24-byte datagram produces a 24-byte quote. --- src/test/unit/unit.c | 1 + src/test/unit/unit_tests_proto.c | 27 +++++++++++++++++++++++++++ src/wolfip.c | 9 +++++++++ 3 files changed, 37 insertions(+) diff --git a/src/test/unit/unit.c b/src/test/unit/unit.c index e005dec8..5d55585b 100644 --- a/src/test/unit/unit.c +++ b/src/test/unit/unit.c @@ -164,6 +164,7 @@ Suite *wolf_suite(void) tcase_add_test(tc_utils, test_wolfip_send_port_unreachable_ignores_missing_link_sender); tcase_add_test(tc_utils, test_wolfip_send_port_unreachable_non_ethernet_skips_eth_filter); tcase_add_test(tc_utils, test_wolfip_send_port_unreachable_sets_df); + tcase_add_test(tc_utils, test_wolfip_send_port_unreachable_quotes_no_more_than_datagram); tcase_add_test(tc_utils, test_tcp_adv_win_clamps_and_applies_window_scale); tcase_add_test(tc_utils, test_tcp_segment_acceptable_zero_window_and_overlap_cases); tcase_add_test(tc_utils, test_tcp_segment_acceptable_counts_syn_in_segment_length); diff --git a/src/test/unit/unit_tests_proto.c b/src/test/unit/unit_tests_proto.c index 5d1e7a2c..9b8ce409 100644 --- a/src/test/unit/unit_tests_proto.c +++ b/src/test/unit/unit_tests_proto.c @@ -3537,6 +3537,8 @@ START_TEST(test_wolfip_send_port_unreachable_non_ethernet_skips_eth_filter) last_frame_sent_size = 0; memset(orig_buf, 0, sizeof(orig_buf)); + orig->ver_ihl = 0x45; + orig->len = ee16(TTL_EXCEEDED_ORIG_PACKET_SIZE_DEFAULT); orig->src = ee32(0x0A000002U); wolfIP_send_port_unreachable(&s, TEST_PRIMARY_IF, orig); @@ -3572,6 +3574,31 @@ START_TEST(test_wolfip_send_port_unreachable_sets_df) } END_TEST +START_TEST(test_wolfip_send_port_unreachable_quotes_no_more_than_datagram) +{ + struct wolfIP s; + uint8_t orig_buf[ETH_HEADER_LEN + 24]; + struct wolfIP_ip_packet *orig = (struct wolfIP_ip_packet *)orig_buf; + + wolfIP_init(&s); + mock_link_init(&s); + wolfIP_ipconfig_set(&s, 0x0A000001U, 0xFFFFFF00U, 0); + last_frame_sent_size = 0; + + memset(orig_buf, 0, sizeof(orig_buf)); + orig->ver_ihl = 0x45; + orig->len = ee16(24); /* 20-byte IP header + 4-byte UDP header */ + orig->src = ee32(0x0A000002U); + orig->dst = ee32(0x0A000001U); + + wolfIP_send_port_unreachable(&s, TEST_PRIMARY_IF, orig); + /* The quoted part is the 24-byte datagram, not the usual ihl + 8: + * the frame is ETH + 20 + 8 + 24. */ + ck_assert_uint_eq(last_frame_sent_size, + (uint32_t)(ETH_HEADER_LEN + IP_HEADER_LEN + 8 + 24)); +} +END_TEST + START_TEST(test_tcp_adv_win_clamps_and_applies_window_scale) { struct tsocket ts; diff --git a/src/wolfip.c b/src/wolfip.c index e43428c7..0cd80d92 100644 --- a/src/wolfip.c +++ b/src/wolfip.c @@ -2677,6 +2677,7 @@ static void wolfIP_send_port_unreachable(struct wolfIP *s, unsigned int if_idx, struct wolfIP_icmp_dest_unreachable_packet icmp = {0}; struct wolfIP_icmp_packet *icmp_pkt = (struct wolfIP_icmp_packet *)&icmp; uint32_t orig_ihl = (orig->ver_ihl & 0x0F) * 4; + uint32_t orig_total; uint32_t orig_copy; uint32_t icmp_data_len; #if !CONFIG_IPFILTER @@ -2699,7 +2700,15 @@ static void wolfIP_send_port_unreachable(struct wolfIP *s, unsigned int if_idx, #endif if (orig_ihl < IP_HEADER_LEN) orig_ihl = IP_HEADER_LEN; + /* Quote the original header plus up to 8 payload bytes, or as much of + * the datagram as exists (same clamp as the other ICMP error senders). + */ + orig_total = ee16(orig->len); + if (orig_total < orig_ihl) + orig_total = orig_ihl; orig_copy = orig_ihl + 8; + if (orig_copy > orig_total) + orig_copy = orig_total; if (orig_copy > TTL_EXCEEDED_ORIG_PACKET_SIZE_MAX) orig_copy = TTL_EXCEEDED_ORIG_PACKET_SIZE_MAX; icmp_data_len = 8 + orig_copy; From 416eecd299a17debb14331041b4868037a022cbf Mon Sep 17 00:00:00 2001 From: Daniele Lacamera Date: Mon, 21 Sep 2026 10:54:55 +0200 Subject: [PATCH 16/17] tcp_ack: reclaim ACKed descriptors parked behind a pure ACK The marking scan only walks SENT descriptors and the drain only ran when the segment marked new ones, so with [data1, pure-ACK, data2] all acked, data2 was marked ACKED behind the parked zero-length descriptor, never drained, and sat at the FIFO head forever: the scan stopped at it for every later segment, so new data was never marked acked and RTOs retransmitted already-acked segments until the connection died. The drain now runs on every ACK and reclaims zero-length SENT descriptors alongside ACKED ones. Regression test covers the parked case; the RTT-sample test now expects the parked descriptor reclaimed. --- src/test/unit/unit.c | 1 + src/test/unit/unit_tests_tcp_ack.c | 84 +++++++++++++++++++++++++++++ src/test/unit/unit_tests_tcp_flow.c | 14 +++-- src/wolfip.c | 32 ++++++++--- 4 files changed, 115 insertions(+), 16 deletions(-) diff --git a/src/test/unit/unit.c b/src/test/unit/unit.c index 5d55585b..68fd3318 100644 --- a/src/test/unit/unit.c +++ b/src/test/unit/unit.c @@ -756,6 +756,7 @@ Suite *wolf_suite(void) tcase_add_test(tc_utils, test_tcp_consume_ooo_wrap_drop_fully_acked); tcase_add_test(tc_utils, test_tcp_store_ooo_overlap_does_not_exhaust_cache); tcase_add_test(tc_utils, test_tcp_ack_sack_early_retransmit_before_three_dupack); + tcase_add_test(tc_utils, test_tcp_ack_reclaims_data_acked_behind_pure_ack); tcase_add_test(tc_utils, test_tcp_ack_forward_ack_after_retransmit_not_duplicate); tcase_add_test(tc_utils, test_tcp_input_listen_syn_without_sack_disables_sack); tcase_add_test(tc_utils, test_tcp_input_listen_syn_arms_control_rto); diff --git a/src/test/unit/unit_tests_tcp_ack.c b/src/test/unit/unit_tests_tcp_ack.c index a5794d5e..39da78ad 100644 --- a/src/test/unit/unit_tests_tcp_ack.c +++ b/src/test/unit/unit_tests_tcp_ack.c @@ -5065,6 +5065,90 @@ START_TEST(test_tcp_ack_sack_early_retransmit_before_three_dupack) } END_TEST +/* A zero-length (pure-ACK) descriptor parked between two data descriptors + * must not strand the second: an ACK covering both marks both data + * descriptors ACKED, and the cleanup must reclaim all three, not stop at + * the parked zero-length one (which would block the marking scan, which + * only walks SENT descriptors, for every later segment). */ +START_TEST(test_tcp_ack_reclaims_data_acked_behind_pure_ack) +{ + struct wolfIP s; + struct tsocket *ts; + struct tcp_seg_buf segbuf1; + struct tcp_seg_buf segbuf2; + struct tcp_seg_buf segbuf3; + struct wolfIP_tcp_seg *seg1; + struct wolfIP_tcp_seg *seg2; + struct wolfIP_tcp_seg *seg3; + uint8_t ackbuf[sizeof(struct wolfIP_tcp_seg)]; + struct wolfIP_tcp_seg *ackseg = (struct wolfIP_tcp_seg *)ackbuf; + struct pkt_desc *desc; + + wolfIP_init(&s); + ts = &s.tcpsockets[0]; + memset(ts, 0, sizeof(*ts)); + ts->proto = WI_IPPROTO_TCP; + ts->S = &s; + ts->sock.tcp.state = TCP_ESTABLISHED; + ts->sock.tcp.seq = 102; + ts->sock.tcp.snd_una = 100; + ts->sock.tcp.bytes_in_flight = 2; + ts->sock.tcp.cwnd = TCP_MSS * 4; + ts->sock.tcp.peer_rwnd = TCP_MSS * 4; + fifo_init(&ts->sock.tcp.txbuf, ts->txmem, TXBUF_SIZE); + + /* data1: 1 byte at seq 100. */ + memset(&segbuf1, 0, sizeof(segbuf1)); + seg1 = &segbuf1.seg; + seg1->ip.len = ee16(IP_HEADER_LEN + TCP_HEADER_LEN + 1); + seg1->hlen = TCP_HEADER_LEN << 2; + seg1->seq = ee32(100); + ck_assert_int_eq(fifo_push(&ts->sock.tcp.txbuf, &segbuf1, + sizeof(segbuf1)), 0); + desc = fifo_peek(&ts->sock.tcp.txbuf); + ck_assert_ptr_nonnull(desc); + desc->flags |= PKT_FLAG_SENT; + + /* pure ACK: zero length at seq 101. */ + memset(&segbuf2, 0, sizeof(segbuf2)); + seg2 = &segbuf2.seg; + seg2->ip.len = ee16(IP_HEADER_LEN + TCP_HEADER_LEN); + seg2->hlen = TCP_HEADER_LEN << 2; + seg2->seq = ee32(101); + ck_assert_int_eq(fifo_push(&ts->sock.tcp.txbuf, &segbuf2, + sizeof(segbuf2)), 0); + desc = fifo_next(&ts->sock.tcp.txbuf, desc); + ck_assert_ptr_nonnull(desc); + desc->flags |= PKT_FLAG_SENT; + + /* data2: 1 byte at seq 101. */ + memset(&segbuf3, 0, sizeof(segbuf3)); + seg3 = &segbuf3.seg; + seg3->ip.len = ee16(IP_HEADER_LEN + TCP_HEADER_LEN + 1); + seg3->hlen = TCP_HEADER_LEN << 2; + seg3->seq = ee32(101); + ck_assert_int_eq(fifo_push(&ts->sock.tcp.txbuf, &segbuf3, + sizeof(segbuf3)), 0); + desc = fifo_next(&ts->sock.tcp.txbuf, desc); + ck_assert_ptr_nonnull(desc); + desc->flags |= PKT_FLAG_SENT; + + /* One ACK covering everything: ack = 102. */ + memset(ackbuf, 0, sizeof(ackbuf)); + ackseg->ack = ee32(102); + ackseg->hlen = TCP_HEADER_LEN << 2; + ackseg->flags = TCP_FLAG_ACK; + + tcp_ack(ts, ackseg); + + /* All three descriptors reclaimed: the ACKED data2 behind the parked + * pure ACK included. */ + ck_assert_ptr_eq(fifo_peek(&ts->sock.tcp.txbuf), NULL); + ck_assert_uint_eq(ts->sock.tcp.bytes_in_flight, 0); + ck_assert_uint_eq(ts->sock.tcp.snd_una, 102); +} +END_TEST + /* F-13765: a forward ACK that fully acknowledges a segment whose * PKT_FLAG_SENT was cleared by the retransmit marker advances * snd_una, but the marking loop counts zero descriptors, so the diff --git a/src/test/unit/unit_tests_tcp_flow.c b/src/test/unit/unit_tests_tcp_flow.c index 21e52265..3d816d53 100644 --- a/src/test/unit/unit_tests_tcp_flow.c +++ b/src/test/unit/unit_tests_tcp_flow.c @@ -6337,8 +6337,8 @@ START_TEST(test_tcp_ack_parked_zero_desc_keeps_rtt_sample) /* ACK the data. Pre-fix the drain's fifo_pop() discards the just-acked * data descriptor, fresh_desc ends up NULL and no RTT sample is taken; - * post-fix the sample lands and the parked zero-length descriptor - * survives. */ + * post-fix the sample lands and the parked zero-length descriptor is + * reclaimed with it. */ memset(&ackseg, 0, sizeof(ackseg)); ackseg.ip.len = ee16(IP_HEADER_LEN + TCP_HEADER_LEN); ackseg.hlen = TCP_HEADER_LEN << 2; @@ -6350,11 +6350,9 @@ START_TEST(test_tcp_ack_parked_zero_desc_keeps_rtt_sample) ck_assert_uint_eq(ts->sock.tcp.bytes_in_flight, 0); ck_assert_int_eq(ts->sock.tcp.rto_initialized, 1); ck_assert_uint_ne(ts->sock.tcp.rto, 200); - /* The parked zero-length descriptor is still queued, to be reclaimed - * when it becomes the oldest. */ - desc = fifo_peek(&ts->sock.tcp.txbuf); - ck_assert_ptr_nonnull(desc); - ck_assert_ptr_ne(desc, data_desc); - ck_assert_int_ne(desc->flags & PKT_FLAG_SENT, 0); + /* The parked zero-length descriptor is reclaimed along with the ACKed + * data: it carries no in-flight bytes, and keeping it around would + * block the marking scan if ACKed data ever sat behind it. */ + ck_assert_ptr_eq(fifo_peek(&ts->sock.tcp.txbuf), NULL); } END_TEST diff --git a/src/wolfip.c b/src/wolfip.c index 0cd80d92..65bcb760 100644 --- a/src/wolfip.c +++ b/src/wolfip.c @@ -5713,8 +5713,8 @@ static void tcp_ack(struct tsocket *t, const struct wolfIP_tcp_seg *tcp) } else { /* A zero-length descriptor parked ahead of a newer one: * leave it in place (popping would remove the newest - * descriptor, not this one) and advance the cursor. It is - * reclaimed when it becomes the oldest. */ + * descriptor, not this one) and advance the cursor. The + * cleanup drain below reclaims it. */ desc = fifo_next(&t->sock.tcp.txbuf, desc); } continue; @@ -5796,16 +5796,31 @@ static void tcp_ack(struct tsocket *t, const struct wolfIP_tcp_seg *tcp) } ack_advanced = 1; } - if (ack_count > 0) { + { struct pkt_desc *fresh_desc = NULL; uint32_t ack_ip_len = ee16(tcp->ip.len); uint32_t ack_hdr_len = IP_HEADER_LEN + tcp_data_offset_bytes(tcp->hlen); uint32_t ack_frame_len = 0; - /* This ACK ackwnowledged some data. */ + /* Reclaim descriptors the peer has already accounted for: ACKED + * data and zero-length (pure-ACK) descriptors, which carry no + * in-flight bytes. Runs on every ACK, not only when this segment + * marked new descriptors: the marking scan above only walks SENT + * descriptors, so an ACKED descriptor parked behind a zero-length + * one would otherwise sit at the FIFO head forever, blocking the + * scan (and retransmission) for every later segment. */ desc = fifo_peek(&t->sock.tcp.txbuf); - while (desc && (desc->flags & PKT_FLAG_ACKED)) { - fresh_desc = fifo_pop(&t->sock.tcp.txbuf); - desc = fifo_peek(&t->sock.tcp.txbuf); + while (desc) { + struct wolfIP_tcp_seg *seg = + (struct wolfIP_tcp_seg *)(t->txmem + desc->pos + sizeof(*desc)); + uint32_t seg_len = + ee16(seg->ip.len) - (IP_HEADER_LEN + (seg->hlen >> 2)); + if ((desc->flags & PKT_FLAG_ACKED) || + ((desc->flags & PKT_FLAG_SENT) && (seg_len == 0))) { + fresh_desc = fifo_pop(&t->sock.tcp.txbuf); + desc = fifo_peek(&t->sock.tcp.txbuf); + } else { + break; + } } if (fresh_desc) { /* Karn rule: ignore RTT samples for retransmitted segments. */ @@ -5847,7 +5862,8 @@ static void tcp_ack(struct tsocket *t, const struct wolfIP_tcp_seg *tcp) if (tx_has_writable_space(t)) t->events |= CB_EVENT_WRITABLE; } - } else { + } + if (ack_count == 0) { /* Duplicate ack (no advance in snd_una). RFC 5681: only a segment * that carries no data and repeats the previously advertised * receive window counts as a duplicate ACK, so data-bearing From 32526ca3e1c2283e179eed8928db72e8e4ea45f5 Mon Sep 17 00:00:00 2001 From: Daniele Lacamera Date: Mon, 21 Sep 2026 12:36:21 +0200 Subject: [PATCH 17/17] tcp: sample RTT from ACKED data, not a zero-length desc Fenrir review of 416eecd: the reclaim drain overwrote fresh_desc with the zero-length pure-ACK descriptor popped after the data, so the RTT sample came from the later pure-ACK time_sent and underestimated the RTT. Only ACKED descriptors now become the sample source. Pins the exact sample in test_tcp_ack_parked_zero_desc_keeps_rtt_sample (rtt == 10 from the data desc; red at 5 without the guard). --- src/test/unit/unit_tests_tcp_flow.c | 8 ++++++++ src/wolfip.c | 7 ++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/test/unit/unit_tests_tcp_flow.c b/src/test/unit/unit_tests_tcp_flow.c index 3d816d53..f84b3000 100644 --- a/src/test/unit/unit_tests_tcp_flow.c +++ b/src/test/unit/unit_tests_tcp_flow.c @@ -6335,6 +6335,13 @@ START_TEST(test_tcp_ack_parked_zero_desc_keeps_rtt_sample) ck_assert_ptr_nonnull(desc); ck_assert_int_ne(desc->flags & PKT_FLAG_SENT, 0); + /* Distinct send timestamps: the RTT sample must come from the data + * descriptor (100 - 90 = 10), not the parked pure ACK behind it + * (which would give 100 - 95 = 5). */ + data_desc->time_sent = 90; + desc->time_sent = 95; + s.last_tick = 100; + /* ACK the data. Pre-fix the drain's fifo_pop() discards the just-acked * data descriptor, fresh_desc ends up NULL and no RTT sample is taken; * post-fix the sample lands and the parked zero-length descriptor is @@ -6349,6 +6356,7 @@ START_TEST(test_tcp_ack_parked_zero_desc_keeps_rtt_sample) ck_assert_uint_eq(ts->sock.tcp.snd_una, 1008); ck_assert_uint_eq(ts->sock.tcp.bytes_in_flight, 0); ck_assert_int_eq(ts->sock.tcp.rto_initialized, 1); + ck_assert_uint_eq(ts->sock.tcp.rtt, 10); ck_assert_uint_ne(ts->sock.tcp.rto, 200); /* The parked zero-length descriptor is reclaimed along with the ACKed * data: it carries no in-flight bytes, and keeping it around would diff --git a/src/wolfip.c b/src/wolfip.c index 65bcb760..03692fa7 100644 --- a/src/wolfip.c +++ b/src/wolfip.c @@ -5816,7 +5816,12 @@ static void tcp_ack(struct tsocket *t, const struct wolfIP_tcp_seg *tcp) ee16(seg->ip.len) - (IP_HEADER_LEN + (seg->hlen >> 2)); if ((desc->flags & PKT_FLAG_ACKED) || ((desc->flags & PKT_FLAG_SENT) && (seg_len == 0))) { - fresh_desc = fifo_pop(&t->sock.tcp.txbuf); + struct pkt_desc *popped = fifo_pop(&t->sock.tcp.txbuf); + /* RTT sample source: only an ACKED (data) descriptor. + * A zero-length descriptor popped after it carries a + * later time_sent and would underestimate the RTT. */ + if (popped->flags & PKT_FLAG_ACKED) + fresh_desc = popped; desc = fifo_peek(&t->sock.tcp.txbuf); } else { break;