[Detail Bug] KNX/IP TCP tunnelling: a malformed frame causes subsequent coalesced frames to be silently dropped
Nobody has claimed this yet.
- Dominant language
- Python
- Stars
- 4
- Forks
- 0
- Avg merge
- 15h 38m
- Merged PRs (30d)
- 37
Description
Detail Bug Report
Introduced in 00999b7091926a0f5eb5211b383aa7392dd5da17 by @kewde on Jul 27, 2026
Summary
- Context:
_client_data_receivedinapps/knx-gui/src/knx_gui/knxip_tunnelling_gateway.pyis the TCP framing layer that pulls KNX/IP frames out of the byte stream from the single connected tunnelling client and dispatches each one to its per-frame handler. - Bug: The broad
except Exceptionbranch — present so that one malformed/unfamiliar frame does not tear down the TCP tunnel — silently discards the entire receive buffer on a parse error: the previously-buffered prefix, the rest of the offending segment, and any well-formed frames that followed the bad one in the samedata_receivedcallback. No length-based stream re-synchronisation is attempted, so a bad frame followed by a good frame in one TCP segment loses the good frame. - Actual vs. expected: The inline comment's literal claim — "not tear down the whole connection over one bad/unfamiliar frame" — is satisfied (the TCP socket stays open). The bug is an un-commented latent side-effect of the simple
return: the broad-except keeps the connection alive but, instead of skipping the single bad frame and continuing thewhile data:loop, it discards every frame after the bad one in the segment. There is no documented guarantee being violated; the comment promises only to keep the TCP transport up, and that is what the code does. The trailing-frame drop is a behaviour improvement opportunity — skip-and-resync over drop-and-return — not a contract breach. - Impact: Bounded and rare in practice. The gateway acts on (has side effects for) exactly one body a tunnelling client sends unsolicited in steady state:
TUNNELLING_REQUEST(_cemi_count += 1,_on_cemi/_forward_cemi). Every other dispatched body is request/response, which a strictly conformant client emits only after receiving the gateway's reply to the previous frame — i.e. by construction the bug-free split path. The broad-except fires before_handle_body, so the gateway sends no reply to the offending frame; a conformant client therefore waits for its response timeout before sending anything else, landing the bad frame and a following frame in separatedata_receivedcallbacks by construction. The trigger thus requires a non-conformant or bursting client that pipelines a frame without waiting for the (here-absent) reply, plus TCP coalescing collapsing the two frames into onedata_received. The offending (unfamiliar) frame is lost by design; that is the broad-except's intended behaviour. The bug is the loss of the following good frame(s) in the same segment. Severity: low — the mechanism is real and the fix is small and correct, but the trigger requires a non-conformant pipelining client, the maximum loss per offending segment is bounded to the trailing frames in that one segment, and there is no persistent desync (§4).
Code with Bug
def _client_data_received(self, data: bytes) -> None:
if self._logger:
self._logger.debug("tcp data received", hex=data.hex(" "))
if self._buffer:
data = self._buffer + data
self._buffer = b"" # <-- prefix cleared up-front
while data:
try:
frame, rest = KNXIPFrame.from_knx(data)
except IncompleteKNXIPFrame:
self._buffer = data # correct: re-buffer the partial tail
return
except Exception as e:
# Catch broadly, not just the documented CouldNotParseKNXIP:
# xknx's own frame parsing (e.g. an unrecognized SRP type
# in SearchRequestExtended) can raise a plain ValueError
# instead, which would otherwise tear down the whole
# connection over one bad/unfamiliar frame.
if self._logger:
self._logger.warning(
"could not parse frame", error=str(e), hex=data.hex(" ")
)
return # <-- BUG 🔴 drops bad frame *and* all trailing good frames in this TCP segment
...
self._handle_body(frame.body)
data = rest
Explanation
- When
KNXIPFrame.from_knx(data)raises any exception other thanIncompleteKNXIPFrame, the broadexcept Exceptionlogs and immediatelyreturns. - At that point
datastill contains the unparsed bytes for the current segment (bad frame plus any subsequent frames coalesced by TCP), and_bufferhas already been cleared (if it existed). Thereturntherefore discards:- any previously buffered prefix,
- the offending frame,
- and any subsequent well-formed frames that arrived in the same
data_receivedcallback.
- This was reproduced with real handler behavior: after a
CONNECT_REQUEST, sending a coalescedbad_frame + TUNNELLING_REQUESTresults in_cemi_countstaying at 0 (no_on_cemicall), while sending the same two frames split across separatedata_receivedcalls increments_cemi_countto 1. - The issue is not persistent desync: subsequent clean frames received in later callbacks parse normally; the loss is bounded to frames in (or straddling) the segment where the parse error occurs.
Recommended Fix
The fix is a length-based re-sync that skips the single bad frame when the KNX/IP header was well-formed enough to yield a usable total_length.
except Exception as e:
if self._logger:
self._logger.warning("could not parse frame", error=str(e), hex=data.hex(" "))
# Re-sync: skip just the bad frame when its KNX/IP header was
# well-formed enough to give us a usable total_length. This
# covers the forward-compat case (unknown KNXIPServiceType,
# wrong protocol version, body ValueError on an unknown enum)
# -- the "unfamiliar frame" group the broad-except exists for.
# `KNXIPHeader.from_knx` raises CouldNotParseKNXIP *before*
# setting total_length only when data[0] != 0x06 (genuine
# stream corruption); that case keeps the existing drop-and-
# return behaviour unchanged.
if len(data) >= 6 and data[0] == 0x06:
total_length = data[4] * 256 + data[5]
if 6 <= total_length <= len(data):
data = data[total_length:]
continue
# No usable total_length: either genuine stream corruption
# (data[0] != 0x06) or a declared total_length that exceeds
# the bytes actually received. Do not re-buffer -- the bytes
# are not a known-good frame prefix and re-buffering them
# would misalign the next segment. Drop the rest of this
# segment (as today) and keep the transport open (as today).
return
History
This bug was introduced in commit 00999b7. That commit rewrote the proxy from a UDP/multicast RoutingProxy (which had no TCP byte-stream framing) into a TCP-based KNXnet/IP tunnelling server, adding the new _client_data_received framing loop in its entirety; the broad except Exception: return was intended to keep the TCP transport up over one unfamiliar frame, but it slipped in by clearing self._buffer up-front and returning without any length-based re-sync, silently dropping any well-formed frames trailing the bad one in the same data_received callback. The two later commits that touched the file (fba940f splitting the proxy into its own plugin, and 97b7511 extracting the gateway into knxip_tunnelling_gateway.py) only moved or renamed the buggy block verbatim — they did not modify the framing logic.
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in apps/knx-gui/src/knx_gui/knxip_tunnelling_gateway.py at _client_data_received and review the framing loop and its broad parse-error branch. Reproduce the coalesced malformed-frame plus TUNNELLING_REQUEST scenario described in the issue, then verify the malformed frame is discarded, the trailing valid frame is handled, and the TCP connection remains open.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- backend, networking
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 76/100