fix(esp): the extension= keyword is accepted, stored and never read #895
Description
Describe the bug
ESP accepts an extension= keyword, stores it, and then never reads it. Extension mode is dead code for that one protocol.
pcapkit/protocols/internet/esp.py:1276 assigns self._extf = extension, and no line anywhere in the file reads self._extf. Contrast pcapkit/protocols/internet/ah.py, its sibling under IPsec, which reads it three times (:75, :87, :99) to guard name, protochain and payload, and assigns it at :233.
Counted across the eight implemented IPv6 extension headers:
_extf UnsupportedCall if extension: return
HOPOPT 4 7 1
IPv6_Route 4 7 1
IPv6_Opts 4 7 1
IPv6_Frag 4 7 1
MH 4 7 1
HIP 4 7 1
AH 4 7 1
ESP 1 0 0
ESP is the only outlier on all three markers: it has no UnsupportedCall guard properties and no if extension: return short-circuit in read.
Reproduction
Not run against a capture — read from source at origin/main. ESP.read declares extension: 'bool' = False at :996-997 and __post_init__ at :1259-1260, so ESP(..., extension=True) is accepted; :1276 records it; nothing consumes it. The parse therefore behaves identically whether extension is True or False.
Expected behavior
One of two things, and which is a design decision rather than a mechanical fix:
- ESP should support extension mode — then it needs the three
UnsupportedCallguard properties and theif extension: returnshort-circuit its seven siblings have, and_extfbecomes load-bearing. - ESP should not — then
extension=should be removed fromreadand__post_init__rather than accepted and silently discarded, and:1276deleted.
Accepting a keyword that changes nothing is the worst of the three, because a caller cannot tell it had no effect.
System information
Source-only; no library version involved. Read at origin/main.
Additional context
Found while measuring the IPv6_Ext shared-base proposal on #891, where it decides whether ESP joins that base. Noted there in #891 so the refactor is not silently made to carry this fix: giving ESP the base would supply the missing guards as a side effect, turning a behaviour-preserving refactor into a behaviour change. This issue exists so that question gets answered on its own merits.
Metadata
Metadata
Assignees
Labels
Projects
- StatusShow more project fieldsDone
Activity
JarryShaw commentedon Sep 29, 2026
Direction settled by the maintainer's ruling on #891, verbatim:
The condition holds. IANA's
protocol-numbers-1.csvmarks50,ESP,Encap Security Payload,**Y**,[RFC4303]in theIPv6 Extension Headercolumn, alongside51,AH,...,Y,[RFC4302]— and this library's own IANA-derived registry already says the same atpcapkit/const/ipv6/extension_header.py:33(ESP = 50).So of the two options in the body above, the first is chosen:
ESPgains the extension-mode contract. Theextension=keyword is not removed.Concretely that means giving
ESPwhat its seven siblings have — the threeUnsupportedCallguard properties readingself._extf(compareah.py:75,87,99) and theif extension: returnshort-circuit inread— so that_extf, already assigned atesp.py:1276, becomes load-bearing.Sequencing: the maintainer also ruled that
ESPdouble-inherits the forthcomingIPv6_Extbase asESP(IPsec, IPv6_Ext), mirroringAH. Since that base supplies exactly these members, this fix and that re-parent are one change rather than two — implementing the guards by hand now and then hoisting them a PR later would be wasted work. So this issue is deliberately held until theIPv6_Extbase lands, and it is then satisfied by the re-parent.Marking
blockedaccordingly. Checkable blocker: theIPv6_Extbase issue, which is itself filed only after #891 merges. Two commands settle whether this is still blocked at any moment:gh issue list -R JarryShaw/PyPCAPKit --state open --search "IPv6_Ext in:title" gh issue view 891 -R JarryShaw/PyPCAPKit --json state -q .stateIf it turns out the base slips or is abandoned, this unblocks and gets the guards written by hand instead — the defect is real either way and must not be lost to the sequencing.
JarryShaw commentedon Sep 29, 2026
Scope correction, so this is not closed wholesale when #924 merges: #924 discharges the defect here but not the optimisation.
The substantive half is satisfied by construction.
_extfwas assigned atesp.pyand never read anywhere — a keyword accepted and silently ignored, which is what this issue is about. Under #924 it becomes load-bearing forESP:payloadandprotochainread it through the newIPv6_Extbase, andprotocolreads it by hand. Soextension=now changes behaviour rather than being swallowed.What #924 does not do:
ESP.readstill has noif extension: returnshort-circuit, which its seven siblings all have. That saves wasted decode work rather than changing behaviour, so it is an optimisation this issue never actually asked for — but it is the one difference left betweenESPand the other extension headers, and it will look like an oversight to whoever reads the family next.So when #924 merges, the right move is to close this on the defect and either drop the short-circuit or re-file it as its own small
perfitem, rather than closing silently and leaving the asymmetry unrecorded. I am not deciding that — flagging it while the context is fresh.blockedstays until #924 merges; #917 is still open.refactor(ipv6): rename IPv6_GenericExt to IPv6_Ext and make it the sh…
refactor(ipv6): rename IPv6_GenericExt to IPv6_Ext and make it the sh…
refactor(ipv6): rename IPv6_GenericExt to IPv6_Ext and make it the sh…
JarryShaw commentedon Sep 29, 2026
Unblocked — #917 closed and #924 merged as
93cf940b3.The defect is discharged.
_extfwas assigned atesp.pyand never read anywhere, which is exactly what this issue reported: a keyword accepted and silently ignored. Under #924 it is load-bearing —payloadandprotochainread it through the newIPv6_Extbase, andprotocolreads it by hand — soextension=now changes behaviour.What remains is one asymmetry, and it is not what this issue asked for.
ESP.readstill has noif extension: returnshort-circuit, which its seven siblings all have. That saves wasted decode work rather than changing behaviour.So this can be closed on the defect. I am not closing it myself because the choice is yours: close it now and let the short-circuit go unrecorded, or close it and let me file the short-circuit as a small
perffollow-up so the difference betweenESPand the other seven is written down somewhere. I would file the follow-up — an unexplained asymmetry in a family of eight is the kind of thing that reads as an oversight to whoever touches it next.Removing
blocked; leaving the issue open pending that choice.JarryShaw commentedon Sep 29, 2026
i say the short-circuit must be done in
ESPcause it's part of the IPv6 extension headers.JarryShaw commentedon Sep 29, 2026
unless RFC tells otherwise.
1 remaining item
JarryShaw commentedon Sep 29, 2026
I am reversing my own PR. Your qualifier — "unless RFC tells otherwise" — resolves against the change, and #928 as written is wrong.
I posted my record of the ruling before your qualifier arrived; I read the channel at 18:28 and did not re-read before replying at 18:35. That is my miss, and the qualifier turns out to be the operative half.
The RFC does tell otherwise, and the library already encodes it. :rfc:
8200#section-4.5sets ESP aside — "For this purpose, the Encapsulating Security Payload (ESP) is not considered an extension header" — and the next sentence lists ESP among "examples of upper-layer headers".IPv6.__generic_ext_codes__(pcapkit/protocols/internet/ipv6.py:116) matches that exactly, and it is seven codes, not eight:So the premise "it's part of the IPv6 extension headers" is the one thing that does not hold for ESP — AH is in that set, ESP deliberately is not.
Two consequences, both against the change:
IPv6._decode_next_layeronly passesextension=Truefor codes in that frozenset, so it never constructs ESP that way. The short-circuit can only fire for a caller who passesextension=Trueby hand.IPv6's walk cannot read it and cannot continue past ESP. The decrypted plaintext reaches_decode_next_layerand nowhere else —esp.py:1186-1187is the only placeinneris passed on. Skipping it discards every inner layer with nothing downstream to recover them. That is the opposite of the seven siblings, wherenextis in cleartext and the walk genuinely does the work instead.So my recommendation is to close #928 unmerged, and record the asymmetry as deliberate rather than as an oversight: ESP has no short-circuit because it is not an extension header, which is the same reason it is absent from
__generic_ext_codes__.That leaves #895 fully discharged by #924 on the defect, with nothing outstanding. Labelling
needs: decisionsince this reverses your stated premise and I would rather you confirmed than have me act on my own reading of the RFC. If you would rather keep the guard for the hand-passed case, say so and I will keep it but restrict it to the undecrypted path, where there is no plaintext to lose.JarryShaw commentedon Sep 29, 2026
Reversal confirmed by an independent review and by my own measurement. My recommendation to close #928 stands, with one refinement that makes the claim narrower and more defensible.
Measured by me on RFC 3602 §4 case 7 (tunnel-mode ESP, AES-CBC-128), the vector already in
tests/protocols/internet/test_esp_unit.py:The refinement: it is not byte loss.
info.plaintextis byte-identical either way, and so isinfo.next. What is destroyed is every parsed inner layer —info.ipv4, a public data-model field holding the fully parsed inner datagram with ICMP under it, is simply gone — together with the__next_type__/__next_name__keys that_decode_next_layerwrites (internet.py:227-228) and thatprotocol.py:1657,1663reads back to descend. So even a caller who knew to look has no handle. That is the accurate framing; "the plaintext is thrown away" would have been the stronger claim and it is false.Two further findings from the review, both against the change:
ESP.readfound exactly 9Returnnodes — 7 ×_make_opaque, the newreturn esp, and the_decode_next_layercall — with_make_opaquecalled only fromread. So the defect is that the guards should not exist, not that one was missing.read-tail guard had no runtime test at all. All five of my tests take the no-keys_make_opaqueroute; the decrypted guard was covered only by the source-level family sweep, which cannot see behaviour. A field-by-field decrypted comparison would have failed oninfo.ipv4immediately. That is the gap that let me write a wrong change and believe it tested.So: close #928 unmerged, and record ESP's lack of a short-circuit as deliberate — the same reason it is absent from
IPv6.__generic_ext_codes__. #895 is then fully discharged by #924 on the defect, with nothing outstanding.Still
needs: decision, because closing it reverses your stated premise and that is your call, not mine. If you would rather keep a guard for the hand-passed case, the_make_opaquehalf is safe in isolation — there is no plaintext to lose on the undecrypted path — but it would be unreachable from the chain walk and I would not bother.JarryShaw commentedon Sep 29, 2026
confused. what exactly does RFC say about ESP? is it an extension header? if so, then it should follow the other extension headers with the short circuits.
JarryShaw commentedon Sep 29, 2026
IPv6.__generic_ext_codes__does not have ESP probably bc we didnt implement ESP back then. that's a miss not a pattern.JarryShaw commentedon Sep 29, 2026
Both answered with measurement, and I owe you a correction on my own framing first.
I said "the RFC does tell otherwise" leaning on :rfc:
8200#section-4.5. That was one-sided. The RFCs are split, and by the authority that matters most, ESP is an extension header:[0, 43, 44, 50, 51, 60, 135, 139, 140, 253, 254]. :rfc:8200#section-4makes that registry authoritative for what counts.8200#section-4.5scopes its exclusion: "For this purpose," — the fragmentation discussion — ESP "is not considered an extension header", and the next sentence lists it among upper-layer headers.4303#section-3.1.1places ESP after the IP header in both IPv4 and IPv6, i.e. in the chain.So your instinct was right on taxonomy: ESP is an extension header. My earlier framing overstated §4.5 by dropping its scoping clause, which is exactly the overstatement #926 fixed in the prose two hours ago. I should not have repeated it.
Your second point is refuted, though, and this is the part that decides it. The
__generic_ext_codes__exclusion is not a legacy miss:It was written today, two weeks after ESP shipped, by a commit that mentions ESP eleven times — and it carries an explicit reason at
ipv6.py:90-100: "it is excluded because :rfc:4303places the real Next Header byte inside the encrypted trailer."Which is why "it's an extension header, so it should short-circuit" does not follow. The seven siblings can short-circuit because their
nextis readable in cleartext, soIPv6._decode_next_layergenuinely continues the walk and does the work instead. ESP'snextis inside the encrypted trailer, so the walk cannot read it and stops. Nothing downstream picks up the plaintext — which is what I measured on RFC 3602 case 7:info.ipv4present without the guard, absent with it.So ESP is an extension header and it cannot short-circuit — not a contradiction, just a consequence of where its Next Header lives. Still
needs: decision, and I would close #928.JarryShaw commentedon Sep 29, 2026
okay, that makes sense now.
JarryShaw commentedon Sep 29, 2026
Ruling recorded, verbatim:
Taken as accepting the reversal, so #928 is closed unmerged and this issue closes with it.
Where that leaves #895. The defect it reported —
extension=accepted and silently ignored — was discharged by #924 as93cf940b3:_extfbecame load-bearing, sopayload,protochainandprotocolall consult it. The short-circuit half is settled as not applicable to ESP, on the mechanism rather than on taxonomy:8200#section-4makes that registry authoritative. :rfc:8200#section-4.5's exclusion is scoped to "For this purpose," — the fragmentation discussion — and I was wrong to quote it unscoped earlier.4303places the real Next Header byte inside the encrypted trailer. The seven siblings can defer toIPv6._decode_next_layerprecisely because theirnextis readable in cleartext; ESP's is not, so the walk stops there and nothing downstream picks up the plaintext.Measured on RFC 3602 §4 case 7, tunnel-mode ESP: with the guard,
info.ipv4— the fully parsed inner datagram, ICMP beneath it — is absent, andprotochainraisesUnsupportedCall, where without it they readESP:IPv4:ICMP.info.plaintextandinfo.nextsurvive byte-identically, so it is loss of every parsed inner layer rather than loss of bytes.Already documented, so nothing new needs writing:
pcapkit/protocols/internet/ipv6.py:90-100states the exclusion and its reason, added by #904 today — two weeks after ESP shipped in #378, by a commit mentioning ESP eleven times. So it was a deliberate choice with full knowledge of ESP, not the legacy miss it looked like.Closing. Branch
fix/895-esp-extension-short-circuitdeleted as abandoned.