Skip to content

(POC) Harden legacy UDP sync notifier send/receive paths - #5953

Draft
netmindz wants to merge 2 commits into
wled:mainfrom
netmindz:udp-sync-hardening
Draft

netmindz wants to merge 2 commits into
wled:mainfrom
netmindz:udp-sync-hardening

Conversation

@netmindz

@netmindz netmindz commented Nov 3, 2026 •

Copy link
Copy Markdown
Member

This is Phase 0 of #5952. It hardens the existing (legacy v12) UDP sync notifier, independently of any future "sync v2". New firmware will keep receiving legacy packets, so this path has to be safe either way.

All changes are in wled00/udp.cpp.

Changes

Receive: bounds checking (parseNotifyPacket)

  • parseNotifyPacket() now receives the packet length. Before, it read fixed offsets up to byte 40, plus segment records whose count and stride come from the sender (udpIn[39]/udpIn[40]). That loop was bounded only against UDP_IN_MAXSIZE, not the real packet size, so it could read past the receive buffer.
  • Each protocol version now has a minimum length (12/16/24/29/36/37/39/41 bytes), checked before any field is read.
  • The segment record stride must be at least the record size for that version: 28 bytes for v11, 36 for v12.
  • Segment data is ignored unless all declared segments fit in the packet. Without this, a truncated packet with "receive segment bounds" enabled would delete local segments.
  • A defensive check covers read() returning -1.

Send (notify)

  • Only the segments actually packed are sent. Before, the full WLEDPACKETSIZE buffer went out regardless: 617 bytes on ESP8266, 1193 on ESP32 and 2345 on PSRAM builds, with uninitialised stack after the active segments.
  • UDP datagrams are capped at 1460 bytes, which is 39 segments.
    • arduino-esp32's WiFiUDP::write() sends what it has as a datagram each time its 1460-byte tx buffer fills, then starts a new one.
    • So PSRAM builds (64 segments) emitted a second datagram that started at an arbitrary byte of the buffer.
    • Receivers then interpreted it as a notifier packet, a realtime packet (1–5) or UDP API input, depending on that byte.
  • ESP-NOW still carries all segments, since it fragments anyway.

Retries

  • Retries no longer extend the 1 s receive hold-off. Before, each retry reset notificationSentTime, so with e.g. 29 retries a node ignored the whole group for about 8 s after its own change. A separate notificationRetryTime now drives the retry interval.
  • A received notification for one of our send groups cancels our pending retries, because the newer state supersedes ours.
  • Retries now also run when syncing via ESP-NOW only. Before, they were gated on udpConnected.

ESP-NOW

  • The reassembled sync message is handed to the main loop (handleNotifications()) instead of being applied directly in espNowReceiveCB().
    • On ESP32, QuickEspNow runs the receive callback in its own receive_handle FreeRTOS task. That task has the same priority and core as loopTask, so segments could be modified while the main loop was using them.
    • On ESP32 the hand-over is a std::atomic pointer exchange.
    • ESP8266 has no 32-bit atomics. There the callback runs from an os_timer, which never preempts the main loop, so a plain pointer swap is safe.
  • A first fragment shorter than the 41-byte header is rejected.

User-visible behaviour changes

  • More than 39 segments: a node only sends the first 39 over UDP. A receiver with "receive segment bounds" enabled will drop its segments beyond 39. Before, such packets were broken anyway: split on ESP32 and rejected by receivers.
  • Nearly simultaneous changes: if two nodes change within a second of each other, they now converge on the later change instead of potentially staying out of sync.
  • Malformed packets are ignored instead of being partially applied.

Deliberately not included

  • Nightlight ignores receive filters (item 7 in UDP sync: current problems and proposal for a v2 protocol #5952). Fixing it means choosing which filter should control nightlight. That's a behaviour decision for the issue discussion.
  • Removing the receive VLA in handleNotifications().
    • A fixed udpIn[UDP_IN_MAXSIZE+1] made the function's stack frame grow from 176 to 1616 bytes on every call on ESP8266 (checked in the disassembly).
    • The VLA is already bounded by the packetSize > UDP_IN_MAXSIZE check just above it, so I left it as is.
  • Zeroing udpOut. Every byte that is sent is now written, so it is no longer needed.

Testing

  • Builds: compile-tested on nodemcuv2, esp8266_2m_min (ESP-NOW disabled), esp32dev and esp32s3dev_8MB_opi (PSRAM). There are no new warnings, and npm test passes.
  • Not yet hardware-tested. Suggested checks:
    • Send fuzz packets at a device: 1, 12 and 40 bytes; a header claiming 200 segments; strides of 0 and 255; a truncated segment; a valid 2-segment packet. Watch the WLED_DEBUG serial log for crashes. I can share a small Python script that does this.
    • Check in Wireshark that a PSRAM build emits a single datagram of at most 1460 bytes.
    • Check mixed sync between this build and a stock 16.x node, in both directions.
    • Check ESP-NOW sync between two nodes in AP mode.

AI disclosure

This PR is partially AI-generated with Claude Code. AI-written blocks are marked with // AI: below section was generated by an AI / // AI: end. The analysis and the changes were reviewed against the source.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Improved reliability of device synchronization by handling incomplete or invalid notifications more safely.
    • Notifications now retry at shorter intervals when retries are available, including for ESP-NOW-only synchronization.
    • Corrected segment information in outgoing notifications and prevented truncated segment data from being applied.
    • Receiving a notification for the matching sync group now stops pending retries.

- parseNotifyPacket() now receives the packet length: enforce a minimum
  length per protocol version, require a sane segment record stride and
  ignore segment data unless all declared segments fit in the packet
  (previously a short packet could make it read past the receive buffer)
- notify() only sends the segments actually packed, and caps the UDP
  datagram at 1460 bytes (39 segments): arduino-esp32 WiFiUDP flushes
  every 1460 bytes, so PSRAM builds (64 segments, 2345 bytes) emitted a
  second datagram starting with arbitrary buffer content
- retries no longer extend the 1 s receive hold-off, retries also run
  when syncing via ESP-NOW only, and a received notification for one of
  our send groups cancels pending retries
- ESP-NOW: hand the reassembled message to the main loop instead of
  applying it from the QuickEspNow receive task (which on ESP32 runs
  concurrently with the main loop); reject a first fragment shorter
  than the 41-byte header

Refs wled#5952. Partially AI generated (marked with // AI: comments).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@netmindz netmindz added bug AI Partly generated by an AI. Make sure that the contributor fully understands the code! labels Nov 3, 2026
@coderabbitai

coderabbitai Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review.

Recent review info

Run configuration

  • Source mode: Auto
  • Repository: wled/WLED
  • Branch: main
  • File: .coderabbit.yaml
  • Commit: fce41f2
  • Layers: Repository YAML (base), Repository UI (inherited), Organization UI (inherited)
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 63b3ed0a-30a7-4f35-abf2-f5ff75319c9d
Commits

Reviewing files that changed from the base of the PR and between fce41f2 and 68075c2.

Files selected for processing: 1

  • wled00/udp.cpp

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 9 remain after this review.

Recent review details

Context from checks skipped due to timeout: 32

  • GitHub Check: wled_build / Build Environments (esp32s3dev_4MB_qspi_hub75)
  • GitHub Check: wled_build / Build Environments (esp32_wrover)
  • GitHub Check: wled_build / Build Environments (nodemcuv2_compat)
  • GitHub Check: wled_build / Build Environments (esp32s3dev_8MB_qspi)
  • GitHub Check: wled_build / Build Environments (esp32c5dev)
  • GitHub Check: wled_build / Build Environments (esp01_1m_full)
  • GitHub Check: wled_build / Build Environments (esp32dev_hub75_forum_pinout)
  • GitHub Check: wled_build / Build Environments (esp32s3dev_16MB_opi_hub75)
  • GitHub Check: wled_build / Build Environments (esp32c3dev_qio)
  • GitHub Check: wled_build / Build Environments (esp32s3dev_8MB_opi)
  • GitHub Check: wled_build / Build Environments (esp32_eth_V4)
  • GitHub Check: wled_build / Build Environments (esp01_1m_full_compat)
  • GitHub Check: wled_build / Build Environments (esp32s3dev_16MB_opi)
  • GitHub Check: wled_build / Build Environments (esp32c3dev)
  • GitHub Check: wled_build / Build Environments (usermods)
  • GitHub Check: wled_build / Build Environments (esp8266_2m_compat)
  • GitHub Check: wled_build / Build Environments (esp32c6dev_4MB)
  • GitHub Check: wled_build / Build Environments (esp32c5dev_8MB_qspi)
  • GitHub Check: wled_build / Build Environments (esp32p4_16MB)
  • GitHub Check: wled_build / Build Environments (esp32dev_debug)
  • GitHub Check: wled_build / Build Environments (esp32s3_4M_qspi)
  • GitHub Check: wled_build / Build Environments (esp32s3dev_8MB_none)
  • GitHub Check: wled_build / Build Environments (esp32dev)
  • GitHub Check: wled_build / Build Environments (esp32S3_wroom2)
  • GitHub Check: wled_build / Build Environments (esp32s3_4M_qspi)
  • GitHub Check: wled_build / Build Environments (lolin_s2_mini)
  • GitHub Check: wled_build / Build Environments (esp32_eth)
  • GitHub Check: wled_build / Build Environments (esp8266_2m)
  • GitHub Check: wled_build / Build Environments (esp32dev_V4)
  • GitHub Check: wled_build / Build Environments (nodemcuv2)
  • GitHub Check: CodeRabbit
  • GitHub Check: wled_build / Test cdata.js
Additional context used

Code guidelines: 4
docs/hardening.instructions.md — configured
docs/securecode.instructions.md — configured
AGENTS.md — configured
docs/cpp.instructions.md — configured
Path-based instructions: 4
Apply the WLED security hardening rules from docs/hardening.instructions.md, and consult docs/securecode.instructions.md when more details are needed for actionable recommendations.

CodeRabbit configuration file

Files:

  • wled00/udp.cpp
Follow the C++ coding conventions documented in docs/cpp.instructions.md and the general project guidelines in AGENTS.md and .github/copilot-instructions.md.

CodeRabbit configuration file

Files:

  • wled00/udp.cpp
Applicability reference: AGENTS.md: # AGENTS.md — WLED AI Coding Agent & AI Code Review Reference See also: `.github/copilot-instructions.md`, `.github/agent-build.instructions.md`, `docs/cpp.instructions.md`, `docs/web.in...

CodeRabbit inference engine (docs/hardening.instructions.md)

Files:

  • wled00/udp.cpp
Source excerpt: Follow the existing style in the file you are editing Source excerpt: Avoid unexplained "magic numbers".

CodeRabbit inference engine (AGENTS.md)

Files:

  • wled00/udp.cpp
Learnings: 1
Common learnings
Learnt from: willmmiles
Repo: wled/WLED PR: 4859
File: usermods/udp_name_sync/udp_name_sync.cpp:62-70
Timestamp: 2025-08-21T00:00:17.196Z
Learning: In WLED codebase, UDP packet processing code must never rely on null termination being present on the wire and must always validate packet size first. The codebase already has some vulnerabilities where malformed data can crash the software, so avoiding introduction of new ones is a priority.
Additional comments: 1

wled00/udp.cpp: 1

13-22: LGTM!

Also applies to: 31-50, 135-135, 180-180, 226-245, 247-274, 313-321, 335-335, 471-474, 542-564, 617-617, 654-654, 1024-1040, 1057-1058, 1071-1086

Walkthrough

UDP notifier packets are limited to 1460 bytes, and their advertised segment count matches the records sent. Receive parsing validates packet lengths and segment bounds. ESP-NOW messages are reassembled into owned buffers and parsed from the main loop.

Changes

UDP notifier handling

Layer / File(s) Summary
Outbound packet sizing and retries
wled00/udp.cpp
Notifier sends cap segment records to those that fit in a 1460-byte datagram and advertise the packed record count. Retry timing is tracked separately from the initial-send time.
Incoming packet and segment validation
wled00/udp.cpp
The parser checks packet length, supported versions, and segment record bounds before applying data. Matching sync-group notifications cancel pending retries.
ESP-NOW reassembly and main-loop parsing
wled00/udp.cpp
Receive callbacks queue completed messages with their lengths for main-loop parsing. Invalid, replaced, or rate-limited buffers are freed.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Bug fix

Sequence Diagram(s)

sequenceDiagram
  participant Callback as ESP-NOW receive callback
  participant Buffer as Pending sync buffer
  participant MainLoop as Main loop
  participant Parser as parseNotifyPacket
  Callback->>Buffer: Store completed message and length
  MainLoop->>Buffer: Take pending message
  MainLoop->>Parser: Parse message with stored length
  MainLoop->>Buffer: Free parsed message
Loading

Possibly related issues

  • wled/WLED#5942 — The PR validates packet lengths and ignores incomplete segment records, matching the issue’s short-packet concern.
  • wled/WLED#5951 — The PR limits notifications to one 1460-byte datagram and sends only packed segments.
  • wled/WLED#5952 — The PR changes the legacy UDP sync packet, retry, and ESP-NOW handling described in the issue.

Possibly related PRs

  • wled/WLED#5932 — Both PRs harden UDP notifier receive parsing and ESP-NOW message assembly.

Suggested reviewers: dedehai

Merge Risk: Minimal · up to 68075

No new merge-blocking issue was established. Hardware testing of the notifier changes remains advisable.

Pre-merge checks: Passed 4 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 60.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documenta… Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 4 passed

Check name Status Explanation
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check Passed The title accurately and clearly summarizes the main change: hardening the legacy UDP sync notifier send and receive paths. The “POC” prefix is additional context but does not make the title misleadin…
Full details: Title check

Explanation

The title accurately and clearly summarizes the main change: hardening the legacy UDP sync notifier send and receive paths. The “POC” prefix is additional context but does not make the title misleading.

Full details: Docstring Coverage

Explanation

Docstring coverage is 60.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documentation-only edits and deletions within surviving functions are included; unchanged diff context and fully deleted functions are excluded. Analyzed 5 functions across 1 files.

Resolution

Write docstrings for the functions missing them to satisfy the coverage threshold.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

@softhack007

Copy link
Copy Markdown
Member

@netmindz thanks, i'll keep the PR open as a draft.

The real fix is already in work with #5932, but this PR is very good as a "second opinion" reference 👍

@softhack007
softhack007 marked this pull request as draft November 3, 2026 16:12
@softhack007 softhack007 changed the title Harden legacy UDP sync notifier send/receive paths (POC) Harden legacy UDP sync notifier send/receive paths Nov 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI Partly generated by an AI. Make sure that the contributor fully understands the code! bug

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants