Skip to content

Commit 2b5ed38

Browse files
authored
fix(protocols): set NoPayload on terminal AH extension layer (#2486)
- AH in extension mode returns before _decode_next_layer, so it never set _next. Protocol.__contains__ reads _next, so 'ESP' in an AH taken from IPv6.extension_headers raised AttributeError. - The layer now holds NoPayload, as read() gives an undecoded layer: the upper layer belongs to the enclosing IPv6 header, so membership walks nothing past AH and returns False. - Tests build the frames in memory: AH membership for non-matching selectors, ESP after AH, ESP extension layer and IPv4 AH are unchanged.
1 parent 7d789ad commit 2b5ed38

2 files changed

Lines changed: 86 additions & 0 deletions

File tree

‎pcapkit/protocols/internet/ah.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
from pcapkit.protocols.data.internet.ah import AH as Data_AH
3131
from pcapkit.protocols.internet.ipsec import IPsec
3232
from pcapkit.protocols.internet.ipv6_ext import IPv6_Ext
33+
from pcapkit.protocols.misc.null import NoPayload
3334
from pcapkit.protocols.schema.internet.ah import AH as Schema_AH
3435
from pcapkit.protocols.schema.internet.ah import AH_HEADER_LEN
3536
from pcapkit.utilities.exceptions import UnsupportedCall
@@ -190,6 +191,9 @@ def read(self, length: 'Optional[int]' = None, *, version: 'Literal[4, 6]' = 4,
190191
)
191192

192193
if extension:
194+
# NOTE: As an IPv6 extension header the upper layer belongs to the enclosing
195+
# IPv6 header, so hold NoPayload here so that membership does not raise (#2484).
196+
self._next = NoPayload() # pylint: disable=attribute-defined-outside-init
193197
return ah
194198
return self._decode_next_layer(ah, schema.next, length - ah.length)
195199

‎tests/protocols/internet/test_ah_unit.py‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
from __future__ import annotations
22

33
import importlib.util
4+
import io
45
import unittest
6+
import warnings
57
from unittest import mock
68

79
from tests._support import reimport_once_per_class
@@ -185,5 +187,85 @@ def test_docstring_cites_the_extension_header_registry(self) -> None:
185187
self.assertIn('qualifies it for a base besides', doc)
186188

187189

190+
@unittest.skipUnless(HAS_RUNTIME, 'runtime dependencies not installed')
191+
class AHMembershipTests(unittest.TestCase):
192+
"""``in`` on a terminal AH layer parsed from an IPv6 extension chain (#2484).
193+
194+
The AH layer is taken from ``IPv6.extension_headers`` in extension mode.
195+
``in`` on it does not raise, and is False for a selector that does not match AH.
196+
Frames are built in memory; no capture is read.
197+
"""
198+
199+
def setUp(self) -> None:
200+
reimport_once_per_class(self)
201+
202+
@staticmethod
203+
def _ipv6(first: int, body: bytes) -> bytes:
204+
import struct # pylint: disable=import-outside-toplevel
205+
206+
return struct.pack('!IHBB', 6 << 28, len(body), first, 64) + bytes(32) + body
207+
208+
@staticmethod
209+
def _ah(next_: int) -> bytes:
210+
import struct # pylint: disable=import-outside-toplevel
211+
212+
# 12-octet ICV: AH is 24 octets, so the Payload Len field is 4
213+
return struct.pack('!BBHII', next_, 4, 0, 0x1234, 1) + bytes(12)
214+
215+
@staticmethod
216+
def _esp() -> bytes:
217+
import struct # pylint: disable=import-outside-toplevel
218+
219+
return struct.pack('!II', 0x1234, 1) + bytes(16)
220+
221+
def _parse_ipv6(self, data: bytes): # type: ignore[no-untyped-def]
222+
from pcapkit.protocols.internet.ipv6 import IPv6
223+
224+
with warnings.catch_warnings():
225+
warnings.simplefilter('ignore')
226+
return IPv6(io.BytesIO(data), len(data))
227+
228+
def test_terminal_ah_membership_is_false_for_other_selectors(self) -> None:
229+
from pcapkit.protocols.internet.ah import AH
230+
231+
ipv6 = self._parse_ipv6(self._ipv6(51, self._ah(59)))
232+
(ah,) = [ext for ext in ipv6.extension_headers.values() if isinstance(ext, AH)]
233+
234+
self.assertIn('AH', ah)
235+
self.assertNotIn('ESP', ah)
236+
self.assertNotIn('TCP', ah)
237+
238+
def test_ah_followed_by_esp_membership_does_not_raise(self) -> None:
239+
from pcapkit.protocols.internet.ah import AH
240+
241+
ipv6 = self._parse_ipv6(self._ipv6(51, self._ah(50) + self._esp()))
242+
ah = next(ext for ext in ipv6.extension_headers.values() if isinstance(ext, AH))
243+
244+
self.assertNotIn('ESP', ah)
245+
246+
def test_esp_extension_layer_membership_is_unchanged(self) -> None:
247+
from pcapkit.protocols.internet.esp import ESP
248+
249+
ipv6 = self._parse_ipv6(self._ipv6(50, self._esp()))
250+
esp = next(ext for ext in ipv6.extension_headers.values() if isinstance(ext, ESP))
251+
252+
self.assertIn('ESP', esp)
253+
self.assertNotIn('AH', esp)
254+
255+
def test_ipv4_ah_membership_is_unchanged(self) -> None:
256+
import struct # pylint: disable=import-outside-toplevel
257+
258+
from pcapkit.protocols.internet.ipv4 import IPv4
259+
260+
body = self._ah(59)
261+
data = struct.pack('!BBHHHBBH', 0x45, 0, 20 + len(body), 0, 0, 64, 51, 0) + bytes(8) + body
262+
with warnings.catch_warnings():
263+
warnings.simplefilter('ignore')
264+
ipv4 = IPv4(io.BytesIO(data), len(data))
265+
266+
self.assertNotIn('ESP', ipv4.payload)
267+
self.assertIn('AH', ipv4.payload)
268+
269+
188270
if __name__ == '__main__':
189271
unittest.main()

0 commit comments

Comments
 (0)