fix(l2tp): refuse a version nibble other than 2, and settle what IP protocol 115 is (#548) - #589
Conversation
|
✅ GOOD TO GO at head sha |
|
Reviewer: Sonnet; PR authored on Opus 5. Falsify-not-bless pass on PR #589 ( 1. RFC 3931, checked directly
2. Is
|
| This is enforced rather than merely asserted -- :meth:`read` refuses a | ||
| datagram whose nibble is not ``2``, so this hard-coded answer cannot | ||
| disagree with :attr:`info.version <pcapkit.protocols.data.link.l2tp\ | ||
| .L2TP.version>` on the same octets. It did before that guard landed, reporting |
There was a problem hiding this comment.
this \ might not work in Sphinx docs. let's just over-length this line. that's fine.
There was a problem hiding this comment.
Done — the \ is gone and the role target sits on one unbroken line:
disagree with
:attr:`info.version <pcapkit.protocols.data.link.l2tp.L2TP.version>`
on the same octets. It did before that guard landed, reporting ``2``
here and ``3`` there.
It turned out not to need the over-length allowance: on its own line the target is 76 characters including indent, so nothing exceeds what this file already carries (its longest pre-existing line is 150). I took the permission as "don't contort it to stay short" rather than "make it long" — no intermediate variable, no shortened target, :attr: kept, and the rendered prose is unchanged since RST collapses the newlines within the paragraph.
I also unbroke the same \ idiom at two more spots I had written, in tests/protocols/link/test_l2tp_version_unit.py (the module docstring's :attr:L2TPv2.version`` and test_ip_protocol_115_has_no_class_to_dispatch_to's `:meth:` back-reference), on the same reasoning.
One I deliberately left alone: pcapkit/foundation/registry/protocols.py:190 has a pre-existing backslash-continued :class:`~pcapkit.const.reg.\ in _iter_code_targets's docstring, which came in with #570 rather than this PR. Happy to fix it here if you'd like it swept up, but I kept it out to avoid widening the diff into code this change isn't otherwise touching.
Rebased onto 087ed3911 in the same amend — the two changelog conflicts are resolved, with CHANGELOG.md regenerated via util/changelog_md.py rather than hand-merged (--check exits 0). New head 98eb6e4dd.
|
Needs a rebase — and the conflict is confined to the changelog, not to any source file. #585 merged at 22:06:26Z and #586 at 22:08:21Z, putting Measured with Those two files are the whole conflict. Nothing under Resolve the generated file by regenerating it, not by hand
Two traps in that regeneration, both of which have already cost a PR in this batch a round: the converter implements only six rules, so an The verdict will need re-pointing after the rebaseThe ✅ is pinned to What merging this decides, for the recordThis PR declines the registration #548 asked for. RFC 3931 §4.1.1 assigns IP protocol 115 to L2TPv3 over IP, so 115 is a missing class rather than a missing registration and stays unbound; the fix is a version guard that degrades a non-2 Disclosed and deliberately not fixed, each worth its own issue rather than widening this diff: One count to correct while it is in view: the report put the remaining |
…rotocol 115 is (#548) * L2TPv2.read() accepted any version nibble, so an L2TPv3 datagram parsed as v2 with a tunnel and session ID read out of v3's Control Connection ID. RFC 2661 section 3.1 fixes Ver at 2 and reserves 1 for L2F, and L2TPv2.version already documented that "a datagram carrying any other value is a different protocol reached through a different class" -- but nothing enforced it, so the hard-coded Literal[2] property and info.version answered 2 and 3 for the same octets. read() now raises ProtocolError, which degrades the payload to Raw through the existing beholder path with the reason recorded. This affects real captures: RFC 3931 section 4.1.2 puts L2TPv3 on port 1701 too, the port UDP.__proto__ already binds, so Ethernet:IPv4:UDP:L2TPv2:Raw with invented field values becomes Ethernet:IPv4:UDP:Raw with the octets preserved. * #548 asked for TransType.L2TP (115) to be registered and proposed binding L2TPv2 there. Not done, and the reasoning is now recorded in pcapkit/protocols/link/l2tp.py rather than only in a test: RFC 3931 section 4.1.1 gives 115 to L2TPv3 over IP, whose session header is "free of any restrictions imposed by coexistence with L2TPv2 and L2F" and carries no version nibble at all, so a v2 parser cannot even detect that the datagram is not its own. Measured, that binding reported version=4, tunnelid=0x5678 and sessionid=0xff03 from the top half of a Session ID and two octets of the PPP frame behind it. 115 is a missing class, not a missing registration, and stays unbound; no L2TPv3 dissector was invented here. * register_protocol_code's worked example registered L2TPv2 at 115, so a caller following it got exactly that misparse. It names L2TPv3 instead -- the shape is unchanged and v3 genuinely is reachable both ways. * Added tests/protocols/test_dispatch_reachability_unit.py, the coverage #548 asked for: every ProtocolBase descendant whose __index__ returns an enum member must be reachable under that code in the registry its enum type designates, read from the same _CODE_DESTINATIONS table that backs code= so the audit cannot drift from the mechanism. 23 claims verified, no gaps, plus a case that injects a gap to prove the audit has teeth. Unit tier green: 1186 passed, 8 skipped, 2698 subtests. New tests in tests/protocols/link/test_l2tp_version_unit.py proven to fail without the fix (22 failed / 5 passed before, 7 passed / 21 subtests after); pcapkit/protocols/link/l2tpv2.py at 100% statement and branch coverage.
5e5a357 to
98eb6e4
Compare
Fixes #548
TL;DR — the issue's premise does not hold, and the PR says why
#548 reports that IP protocol 115 (
TransType.L2TP) is registered nowhere and anL2TP-over-IP capture falls through to
Raw, and its follow-up comment proposesthat
L2TPv2declareTransType.L2TPalongside the UDP port it already answerson — "one class naming two enclosing layers".
That binding is wrong, and I did not make it. RFC 3931 §4.1.1 is what
protocol 115 designates: "L2TPv3 over IP (both versions) utilizes the
IANA-assigned IP protocol ID 115." 115 is a missing class, not a missing
registration, and it stays unbound. What this PR does instead is fix the real,
live defect that sits underneath the confusion, and land the coverage the issue
asked for in its own "Coverage" section.
No L2TPv3 dissector was invented here.
Did #570's
code=mechanism reduce this to one declaration?The mechanism works exactly as advertised — that part of the expectation holds.
register_protocol_codeinfersInternetfrom aTransTypemember and takes{UDP: 1701}explicitly for a bare port, and[TransType.L2TP, {UDP: 1701}]inone
code=is a legal, working declaration (its own unit test attests/protocols/test_protocol_code_registration_unit.pyproves the plumbing).So the mechanism is not the blocker. The declaration is — it would register
the wrong class. #570's docstring shipped that very line as its worked example:
A caller following it got the misparse below. The example now names
L2TPv3,which genuinely is reachable both ways (RFC 3931 §4.1.1 over IP, §4.1.2 over
UDP 1701). Shape unchanged, subject corrected.
Root cause
Two distinct things, at these lines:
pcapkit/protocols/link/l2tpv2.py:187(pre-fixread()) stored the wireversion nibble unchecked —
version=_flag['version']— whilepcapkit/protocols/link/l2tpv2.py:119-128declaresversionasLiteral[2], returns a hard-coded2, and its docstring already promised"a datagram carrying any other value is a different protocol reached through
a different class". Nothing enforced it, so one object gave two answers:
pcapkit/foundation/registry/protocols.py:262documented theL2TPv2-at-115 registration as the canonical example, contradicting fourother places in the tree that already said 115 waits on
L2TPv3(
pcapkit/protocols/link/l2tp.py:35-41,pcapkit/protocols/link/l2tpv2.py:91-95,pcapkit/const/reg/transtype.py:388— whose IANA citation is literally[:rfc:`3931`]— and a passing test,tests/protocols/test_dispatch_bindings_unit.py:228).Why binding
L2TPv2at 115 cannot workRFC 3931 §4.1.1 again: over IP the v3 session header is "free of any
restrictions imposed by coexistence with L2TPv2 and L2F". A v3 data message
over IP opens with the raw 32-bit Session ID and carries no version nibble at
all, so there is nothing a v2 parser could test to recognise the datagram is
not its own. Measured — registering
L2TPv2at 115 and feeding it a v3-over-IPdata message:
tunnelidandsessionidare read out of the bottom half of the Session ID andthe first two octets of the PPP frame behind it. A complete, confident,
fabricated header where
Rawwas the honest answer.The fix
L2TPv2.read()raisesProtocolErrorwhen the version nibble is not 2 (RFC 2661§3.1 fixes
Verat 2 and reserves 1 for L2F; RFC 3931 uses 3). That degrades thepayload to
Rawthrough the existingbeholderpath — no new error handling— with the reason recorded on the result.
This fixes a live misparse, not a hypothetical one. RFC 3931 §4.1.2 puts
L2TPv3 on port 1701, the port
UDP.__proto__already binds. So real capturetraffic changes:
Ethernet:IPv4:UDP:L2TPv2:Raw, inventedtunnelid/sessionidEthernet:IPv4:UDP:Raw,protocol=1701, octets preservedL2TPv2+ fabricated headerRaw,error='L2TPv2: invalid version: 4', octets preservedRFC 2661 traffic on 1701 is untouched.
Failing-then-passing evidence
Same file, same tree,
PYTHONSAFEPATH=1,pcapkit.__file__asserted inside theworktree, exit code read from a file (a failing subtest still prints PASSED for
its parent under pytest 9.1.1 with no
pytest-subtests).Before (
pcapkit/protocols/link/l2tpv2.pyrestored fromHEAD) — exit code 1:After — exit code 0:
Coverage
coverage run -m pytest(neverpytest-cov):Both new guard branches are exercised (4 branches, 0 partial).
Unit tier (exactly what CI runs) — exit code 0:
The coverage #548 asked for
New
tests/protocols/test_dispatch_reachability_unit.pywalks the problem fromthe class side: every
ProtocolBasedescendant whose__index__()returns anenum member must be reachable under that code in the registry its enum type
designates — read from #570's own
_CODE_DESTINATIONS, so the audit and theregistration mechanism cannot drift apart.
test_dispatch_registry_unit.pychecks the 38 entries that exist can parse;it cannot see a class nobody registered, which is how
OSPFonce shippedreachable from no table. 23 claims verified, no gaps. A companion case
injects a gap and asserts the audit reports it, so this cannot rot into a
permanently green no-op.
It deliberately does not flag 115: nothing claims 115 in the first place, as
both
L2TP.__index__andL2TPv2.__index__raise.Found but deliberately not fixed
InARPandDRARPare reachable through no dispatch of their own. Bothreturn the same
EtherTypeasARP/RARP, and the registry holds the base;selection between them is by opcode, which nothing does. The audit treats a
same-module sibling as reachable and documents why. Pre-existing design, out
of scope here.
Nonefrom__index__instead of raising(
Protocol,Internet,IP,IPsec,Link,Transport), whilepcapkit/protocols/protocol.py:926annotates it-> 'StdlibEnum | AenumEnum'.The concrete bases that raise (
VLAN,L2TP,Application) are the intendedconvention. Not touched — it is a contract question spanning
protocol.py.GH-nnnissue references acrosspcapkit/andtests/,against the
#nnnconvention. Fixed only the one on a line this PR alreadyedits; the rest would be an unrelated sweep touching files other work owns.
isortreportspcapkit/foundation/registry/protocols.pyunsorted, but itdoes so identically on the unmodified file at
HEAD— a venv-vs-project configdifference, not introduced here. No import in that file was changed.
L2TPv2.make()still acceptsversion=other than 2, so construction canbuild octets
read()would now refuse. Left alone: no caller or test does it,the signature is already
Literal[2], and symmetrising it is a construction-pathchange with its own round-trip implications.
Note on the full test tier
pytest tests(all tiers) reports 134 failures in this worktree — all in*_runtime.py/*_regression.py, allFileNotFoundError/GeneratedFixtureInUnitTierErrorfrom a fresh worktree with no generatedcaptures (
python examples/generators/make_samples.pynot run). Zero failuresoutside the fixture tier, and none mentions L2TP. The unit tier above is the CI
gate and is green.