Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/source/pcapkit/protocols/internet/hip.rst
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,7 @@ Auxiliary Functions

.. autofunction:: pcapkit.protocols.schema.internet.hip.locator_value_selector
.. autofunction:: pcapkit.protocols.schema.internet.hip.host_id_hi_selector
.. autofunction:: pcapkit.protocols.schema.internet.hip.registration_type_list_len

Data Models
-----------
Expand Down
1 change: 1 addition & 0 deletions docs/source/pcapkit/protocols/internet/hopopt.rst
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@ Auxiliary Functions

.. autofunction:: pcapkit.protocols.schema.internet.hopopt.smf_dpd_data_selector
.. autofunction:: pcapkit.protocols.schema.internet.hopopt.smf_i_dpd_tid_selector
.. autofunction:: pcapkit.protocols.schema.internet.hopopt.smf_i_dpd_id_len
.. autofunction:: pcapkit.protocols.schema.internet.hopopt.quick_start_data_selector

Data Models
Expand Down
1 change: 1 addition & 0 deletions docs/source/pcapkit/protocols/internet/ipv6_opts.rst
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@ Auxiliary Functions

.. autofunction:: pcapkit.protocols.schema.internet.ipv6_opts.smf_dpd_data_selector
.. autofunction:: pcapkit.protocols.schema.internet.ipv6_opts.smf_i_dpd_tid_selector
.. autofunction:: pcapkit.protocols.schema.internet.ipv6_opts.smf_i_dpd_id_len
.. autofunction:: pcapkit.protocols.schema.internet.ipv6_opts.quick_start_data_selector

Data Models
Expand Down
33 changes: 30 additions & 3 deletions pcapkit/protocols/schema/internet/hip.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,33 @@ def host_id_hi_selector(pkt: 'dict[str, Any]') -> 'Field':
return SchemaField(length=pkt['hi_len'], schema=schema)


def registration_type_list_len(pkt: 'dict[str, Any]') -> 'int':
Comment thread
JarryShaw marked this conversation as resolved.
"""Return registration type list length.

Used by the ``reg_request``, ``reg_response`` and ``reg_failed`` fields of
:class:`RegRequestParameter`, :class:`RegResponseParameter` and
:class:`RegFailedParameter` respectively, each of which follows a single
``lifetime`` octet with a list of registration type octets sized by the
remainder of the parameter.

Args:
pkt: Parameter unpacked schema.

Returns:
Registration type list length.

Raises:
FieldValueError: If the parameter's ``Length`` on the wire is too
short to hold the ``lifetime`` octet already read, which would
otherwise underflow the list length below zero.

"""
length = pkt['len'] - 1
if length < 0:
raise FieldValueError(f'HIP: invalid parameter length: {pkt["len"]}')
return length


class Parameter(EnumSchema[Enum_Parameter]):
"""Base schema for HIP parameters."""

Expand Down Expand Up @@ -696,7 +723,7 @@ class RegRequestParameter(Parameter, code=Enum_Parameter.REG_REQUEST):
lifetime: 'int' = UInt8Field()
#: Registration types.
reg_request: 'list[Enum_Registration]' = ListField(
length=lambda pkt: pkt['len'] - 1,
length=registration_type_list_len,
item_type=EnumField(length=1, namespace=Enum_Registration),
)
#: Padding.
Expand All @@ -714,7 +741,7 @@ class RegResponseParameter(Parameter, code=Enum_Parameter.REG_RESPONSE):
lifetime: 'int' = UInt8Field()
#: Registration types.
reg_response: 'list[Enum_Registration]' = ListField(
length=lambda pkt: pkt['len'] - 1,
length=registration_type_list_len,
item_type=EnumField(length=1, namespace=Enum_Registration),
)
#: Padding.
Expand All @@ -732,7 +759,7 @@ class RegFailedParameter(Parameter, code=Enum_Parameter.REG_FAILED):
lifetime: 'int' = UInt8Field()
#: Registration types.
reg_failed: 'list[Enum_RegistrationFailure]' = ListField(
length=lambda pkt: pkt['len'] - 1,
length=registration_type_list_len,
item_type=EnumField(length=1, namespace=Enum_RegistrationFailure),
)
#: Padding.
Expand Down
25 changes: 22 additions & 3 deletions pcapkit/protocols/schema/internet/hopopt.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,27 @@ def mpl_opt_seed_id_len(pkt: 'dict[str, Any]') -> 'int':
raise FieldValueError(f'HOPOPT: invalid MPL Seed-ID type: {s_type}')


def smf_i_dpd_id_len(pkt: 'dict[str, Any]') -> 'int':
Comment thread
JarryShaw marked this conversation as resolved.
"""Return SMF I-DPD identifier length.

Args:
pkt: SMF identification-based DPD option unpacked schema.

Returns:
SMF I-DPD identifier length.

Raises:
FieldValueError: If ``Opt Data Len`` on the wire is too short to hold
the TaggerID it declares, which would otherwise underflow the
identifier length below zero.

"""
length = pkt['len'] - (1 if pkt['info']['type'] == 0 else (pkt['info']['len'] + 2))
if length < 0:
raise FieldValueError(f'HOPOPT: invalid SMF I-DPD option length: {pkt["len"]}')
return length


def smf_dpd_data_selector(pkt: 'dict[str, Any]') -> 'Field':
"""Selector function for :attr:`_SMFDPDOption.data` field.

Expand Down Expand Up @@ -443,9 +464,7 @@ class SMFIdentificationBasedDPDOption(SMFDPDOption, code=Enum_SMFDPDMode.I_DPD):
lambda pkt: pkt['info']['type'] != 0,
)
#: Identifier.
id: 'bytes' = BytesField(length=lambda pkt: pkt['len'] - (
1 if pkt['info']['type'] == 0 else (pkt['info']['len'] + 2)
))
id: 'bytes' = BytesField(length=smf_i_dpd_id_len)

def post_process(self, packet: 'dict[str, Any]') -> 'SMFIdentificationBasedDPDOption':
"""Revise ``schema`` data after unpacking process.
Expand Down
28 changes: 22 additions & 6 deletions pcapkit/protocols/schema/internet/ipv6_opts.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,27 @@ def mpl_opt_seed_id_len(pkt: 'dict[str, Any]') -> 'int':
raise FieldValueError(f'IPv6-Opts: invalid MPL Seed-ID type: {s_type}')


def smf_i_dpd_id_len(pkt: 'dict[str, Any]') -> 'int':
Comment thread
JarryShaw marked this conversation as resolved.
"""Return SMF I-DPD identifier length.

Args:
pkt: SMF identification-based DPD option unpacked schema.

Returns:
SMF I-DPD identifier length.

Raises:
FieldValueError: If ``Opt Data Len`` on the wire is too short to hold
the TaggerID it declares, which would otherwise underflow the
identifier length below zero.

"""
length = pkt['len'] - (1 if pkt['info']['type'] == 0 else (pkt['info']['len'] + 2))
if length < 0:
raise FieldValueError(f'IPv6-Opts: invalid SMF I-DPD option length: {pkt["len"]}')
return length


def smf_dpd_data_selector(pkt: 'dict[str, Any]') -> 'Field':
"""Selector function for :attr:`_SMFDPDOption.data` field.

Expand Down Expand Up @@ -431,9 +452,6 @@ class SMFDPDOption(Option, EnumSchema[Enum_SMFDPDMode]):
class SMFIdentificationBasedDPDOption(SMFDPDOption, code=Enum_SMFDPDMode.I_DPD):
"""Header schema for IPv6-Opts SMF identification-based DPD options."""

test: 'SMFDPDTestFlag' = ForwardMatchField(BitField(length=1, namespace={

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the PR's note reads like the ForwardMatchField's logic or handling logic is defect. maybe worth double checking and fixing.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, and it is a defect in its own right — filed as #446, deliberately kept out of this PR. Two separate faults meet at this line, and I want to be clear which is which because only one of them is fixed here.

What this PR fixes is a real defect independent of ForwardMatchField. The test field should not exist at all. Its HOPOPT twin does not have it, and :rfc:6621 §6.1.1 puts the I-DPD mode bit in octet 2 alongside TidTy/TidLen — which the existing info BitField(length=1, namespace={'mode': (0,1), 'type': (1,3), 'len': (4,4)}) already reads. There is no separate wire octet for it, and pkt['test'] was read only by smf_dpd_data_selector against the enclosing _SMFDPDOption, never against this schema. So the two schemas disagreed about the same bytes, and HOPOPT was the correct one. That stands whatever happens to ForwardMatchField.

What you're pointing at is the reason the stray field had teeth, and it is the shared machinery. ForwardMatchField exists to look ahead without consuming, but the bytes it matched still occupy a __buffer__ slot, and Schema.__len__ is len(self.__bytes__()) (pcapkit/protocols/schema/schema.py:443). So any schema containing a forward match reports itself longer than the octets it actually read, by exactly the width of the match — and where that length is then checked against a declared area, correct input fails. Measured on the same octets 1100080100010100:

hopopt    __fields__ = [type, len, info, tid, id]        len(schema) = 3
ipv6_opts __fields__ = [type, len, test, info, tid, id]  len(schema) = 4

Why deleting the field cannot be the general fix. The CGA Parameters option is the case that proves it: pcapkit/protocols/schema/internet/mh.py's CGAParameter carries

public_key_test: 'ANSIKeyLengthTest' = ForwardMatchField(BitField(length=2, namespace={'len': (8, 8)}))

and that one is load-bearing — the public key's length genuinely has to be read before the key can be sized, so it cannot be deleted. With the other blocker in that path fixed (#445, a nested schema being unable to reach the enclosing packet's fields), the same option then fails at FieldValueError: Field parameters has invalid length., which is #446 and nothing else. I measured that chain: KeyError: 'length' first, then FieldValueError once the lookup is corrected.

So #446 is the root and it needs answering, but it is a design question rather than a one-line fix, which is why it is its own issue rather than folded in here. len(schema) is used as "how many octets did this schema account for", and a non-consuming field should arguably not contribute — but changing that also changes what bytes(schema) round-trips, and both OptionField and ListField depend on the answer. The alternative is to keep __len__ as the buffer length and give schemas a separate consumed-octet measure, then use that wherever a declared area is checked, which is more honest and touches every length check. #446 lays both out.

I've dispatched work on #446 now — the sequencing reason for holding it back was that this PR was already reasoning about ForwardMatchField length accounting and I did not want two changes to the same machinery developed in parallel. That reason has expired now this PR is done.

This PR stays as-is: it deletes a field that should never have been there, and #446 fixes why its presence broke anything. Happy to fold #446 in here instead if you'd rather see them land together — say so and I'll re-scope rather than open a second PR.

'mode': (0, 1),
}))
#: TaggerID information.
info: 'TaggerIDInfo' = BitField(length=1, namespace={
'mode': (0, 1),
Expand All @@ -446,9 +464,7 @@ class SMFIdentificationBasedDPDOption(SMFDPDOption, code=Enum_SMFDPDMode.I_DPD):
lambda pkt: pkt['info']['type'] != 0,
)
#: Identifier.
id: 'bytes' = BytesField(length=lambda pkt: pkt['len'] - (
1 if pkt['info']['type'] == 0 else (pkt['info']['len'] + 2)
))
id: 'bytes' = BytesField(length=smf_i_dpd_id_len)

def post_process(self, packet: 'dict[str, Any]') -> 'SMFIdentificationBasedDPDOption':
"""Revise ``schema`` data after unpacking process.
Expand Down
48 changes: 48 additions & 0 deletions tests/protocols/internet/test_hip_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -1763,6 +1763,44 @@ def test_hip_parameter_constructors_cover_data_model_and_default_paths(self) ->
version=2,
).hmac, b'relh')

def test_hip_registration_parameters_reject_underflowing_length(self) -> None:
"""#438: a registration parameter's ``Length`` too small for its own
``lifetime`` octet must raise, not silently drop the registration list.

``reg_request``, ``reg_response`` and ``reg_failed`` each size their
list of registration-type octets as ``Length - 1``. Nothing floored
that at zero, so a peer declaring ``Length = 0`` drove the list length
to ``-1``. Unlike a :class:`~pcapkit.corekit.fields.strings.BytesField`,
:class:`~pcapkit.corekit.fields.collections.ListField` never reaches
:func:`struct.calcsize` for a negative length -- its own ``while length
> 0`` loop just returns an empty list instead -- so this parsed to an
empty ``reg_type`` with no exception and no diagnostic, rather than
rejecting the malformed ``Length``.

"""
from pcapkit.protocols.internet.hip import HIP
from pcapkit.utilities.exceptions import FieldValueError

# next(1) len(1)=5 pkt(1) ver(1)=0x01 (the reserved bit that must be 1)
# checksum(2) control(2) shit(16) rhit(16) -- the fixed 40-octet header,
# declaring one 8-octet parameter to follow: (5 - 4) * 8 == 8.
fixed = bytes([0x3b, 0x05, 0x00, 0x01]) + bytes(2) + bytes(2) + bytes(16) + bytes(16)
self.assertEqual(len(fixed), 40)

for name, code in (
('REG_REQUEST', 932),
('REG_RESPONSE', 934),
('REG_FAILED', 936),
):
with self.subTest(parameter=name):
# type(2) len(2)=0 lifetime(1), then 3 octets padding out the
# 8-octet parameter area the outer header declared.
param = code.to_bytes(2, 'big') + (0).to_bytes(2, 'big') + bytes(4)
raw = fixed + param

with self.assertRaisesRegex(FieldValueError, 'invalid parameter length'):
HIP(raw, len(raw), extension=True)

def test_hip_schema_selectors_and_encrypted_parameter_branches(self) -> None:
from pcapkit.const.hip.cipher import Cipher
from pcapkit.const.hip.hi_algorithm import HIAlgorithm
Expand Down Expand Up @@ -1791,6 +1829,16 @@ def test_hip_schema_selectors_and_encrypted_parameter_branches(self) -> None:
self.assertEqual(type(unknown_host_field).__name__, 'BytesField')
self.assertEqual(unknown_host_field.length, 6)

# #438: ``reg_request``/``reg_response``/``reg_failed`` size their
# registration-type list as ``Length - 1``, the ``- 1`` accounting for
# the ``lifetime`` octet already read unconditionally.
self.assertEqual(hip_schema.registration_type_list_len({'len': 1}), 0)
self.assertEqual(hip_schema.registration_type_list_len({'len': 4}), 3)
# a ``Length`` too small to hold that ``lifetime`` octet must raise
# rather than drive the list length negative.
with self.assertRaisesRegex(FieldValueError, 'invalid parameter length'):
hip_schema.registration_type_list_len({'len': 0})

missing_packet: dict[str, object] = {}
with mock.patch('pcapkit.protocols.schema.internet.hip.warn') as warn:
hip_schema.EncryptedParameter.pre_unpack(missing_packet)
Expand Down
117 changes: 106 additions & 11 deletions tests/protocols/internet/test_ipv6_extension_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -1040,7 +1040,6 @@ def assert_bad(reader, option_schema) -> None:
ident = schema.SMFIdentificationBasedDPDOption(
type=Option.SMF_DPD,
len=7,
test={'mode': SMFDPDMode.I_DPD},
info={'mode': 0, 'type': TaggerID.IPv4, 'len': 3},
tid=ip_address('192.0.2.1'),
id=b'id',
Expand Down Expand Up @@ -1264,6 +1263,20 @@ def _assert_option_schema_helpers_and_post_process_branches(self, schema: types.
with self.assertRaises(FieldValueError):
schema.mpl_opt_seed_id_len({'flags': {'type': 4}})

# #438: a null TaggerID consumes nothing, so the identifier is the whole
# of ``Opt Data Len`` less the one octet ``info`` itself already took.
self.assertEqual(schema.smf_i_dpd_id_len({'len': 5, 'info': {'type': 0, 'len': 0}}), 4)
# a non-null TaggerID additionally takes ``TidLen + 1`` octets (the ``+ 2``
# below also counts the octet ``info`` itself took).
self.assertEqual(schema.smf_i_dpd_id_len({'len': 7, 'info': {'type': 2, 'len': 3}}), 2)
# ``Opt Data Len`` too short to hold the TaggerID it declares must raise
# rather than drive the identifier length negative -- c.f. #438, where the
# unguarded subtraction reached :func:`struct.calcsize` as ``'-1s'``.
with self.assertRaisesRegex(FieldValueError, 'invalid SMF I-DPD option length'):
schema.smf_i_dpd_id_len({'len': 0, 'info': {'type': 0, 'len': 0}})
with self.assertRaisesRegex(FieldValueError, 'invalid SMF I-DPD option length'):
schema.smf_i_dpd_id_len({'len': 0, 'info': {'type': 2, 'len': 3}})

for mode in (SMFDPDMode.I_DPD, SMFDPDMode.H_DPD):
field = schema.smf_dpd_data_selector({'test': {'mode': mode, 'len': 4}})
self.assertIsInstance(field, SchemaField)
Expand Down Expand Up @@ -1319,8 +1332,6 @@ def _assert_option_schema_helpers_and_post_process_branches(self, schema: types.
'info': {'mode': 0, 'type': TaggerID.NULL, 'len': 0},
'id': b'id',
}
if schema.__name__.endswith('ipv6_opts'):
ident_kwargs['test'] = {'mode': SMFDPDMode.I_DPD}
ident = schema.SMFIdentificationBasedDPDOption(**ident_kwargs)
self.assertIs(ident.post_process({}), ident)
self.assertEqual(ident.mode, SMFDPDMode.I_DPD)
Expand Down Expand Up @@ -1536,14 +1547,16 @@ def _assert_identification_based_dpd_options_parse_from_the_wire(self, protocol_
identifier rather than the whole option. Both cases below hang the
pristine tree exactly as the hash-based ones do.

Only the option itself is asserted, not the padding after it: HOPOPT and
IPv6-Opts disagree on how much of the option area an
``SMFIdentificationBasedDPDOption`` leaves over, because the IPv6-Opts
schema carries an extra forward-matched octet which
:attr:`Schema.__buffer__ <pcapkit.protocols.schema.schema.Schema.__buffer__>`
records although the stream never consumes it. That is its own
``len(data)``-is-not-consumed defect, distinct from the one #431 is about,
and pinning either count here would bless one of the two.
Only the option itself is asserted here, not the padding after it -- that
parity is what :meth:`_assert_identification_based_dpd_null_tid_option_area_matches`
below pins instead. Until #441, HOPOPT and IPv6-Opts disagreed on how much
of the option area an ``SMFIdentificationBasedDPDOption`` left over,
because the IPv6-Opts schema carried an extra forward-matched ``test``
field its HOPOPT twin did not: the field consumed no bytes but still
occupied a :attr:`Schema.__buffer__ <pcapkit.protocols.schema.schema.Schema.__buffer__>`
slot, so ``len(schema)`` over-reported the option by one octet under
IPv6-Opts. That was its own ``len(data)``-is-not-consumed defect, distinct
from the one #431 is about.

"""
from pcapkit.const.ipv6.option import Option
Expand Down Expand Up @@ -1586,6 +1599,88 @@ def test_ipv6_opts_identification_based_dpd_options_parse_from_the_wire(self) ->

self._assert_identification_based_dpd_options_parse_from_the_wire(IPv6_Opts)

def _assert_identification_based_dpd_null_tid_option_area_matches(self, protocol_cls: type) -> None:
"""#441: a null-TaggerID I-DPD option must consume exactly what HOPOPT does.

This is the issue's own reproduction: a null-TaggerID I-DPD option with a
zero-octet identifier, followed by one octet of ``PadN``. ``ipv6_opts.py``'s
``SMFIdentificationBasedDPDOption`` carried a stray ``test``
:class:`~pcapkit.corekit.fields.misc.ForwardMatchField` that its HOPOPT
twin did not -- see
:meth:`_assert_identification_based_dpd_options_parse_from_the_wire` above
for the mechanism. On the pristine tree this option parses under HOPOPT
but raises ``ProtocolError: IPv6-Opts: invalid format`` under IPv6-Opts,
for the identical octets.

"""
from pcapkit.const.ipv6.option import Option
from pcapkit.const.ipv6.smf_dpd_mode import SMFDPDMode
from pcapkit.const.ipv6.tagger_id import TaggerID

raw = bytes.fromhex('1100080100010100')

with time_limit(5):
proto = protocol_cls(raw, extension=True)

options = list(proto.info.options.items(multi=True))
self.assertEqual([code for code, _ in options], [Option.SMF_DPD, Option.PadN])

smf_dpd = options[0][1]
self.assertEqual(smf_dpd.length, 3)
self.assertEqual(smf_dpd.dpd_type, SMFDPDMode.I_DPD)
self.assertEqual(smf_dpd.tid_type, TaggerID.NULL)
self.assertEqual(smf_dpd.tid_len, 0)
self.assertIsNone(smf_dpd.tid)
self.assertEqual(smf_dpd.id, b'')

padn = options[1][1]
self.assertEqual(padn.length, 3)

# the reader and the writer must agree: repacking the parsed schema has
# to give back the very bytes it was read from
self.assertEqual(bytes(proto.__header__), raw)

def test_hopopt_identification_based_dpd_null_tid_option_area_matches(self) -> None:
from pcapkit.protocols.internet.hopopt import HOPOPT

self._assert_identification_based_dpd_null_tid_option_area_matches(HOPOPT)

def test_ipv6_opts_identification_based_dpd_null_tid_option_area_matches(self) -> None:
from pcapkit.protocols.internet.ipv6_opts import IPv6_Opts

self._assert_identification_based_dpd_null_tid_option_area_matches(IPv6_Opts)

def _assert_identification_based_dpd_option_rejects_underflowing_length(self, protocol_cls: type) -> None:
"""#438: ``Opt Data Len`` too small for a null TaggerID must raise, not crash.

This is the issue's own reproduction: an I-DPD option (null TaggerID)
declaring ``Opt Data Len = 0``, which leaves no room for the one octet
``info`` itself already consumes. ``id``'s length is ``Opt Data Len - 1``
for a null TaggerID, and nothing floored that at zero, so it drove the
identifier length to ``-1``. That reached :func:`struct.calcsize` as the
template ``'-1s'`` and raised a bare ``struct.error: bad char in struct
format`` -- not one of pcapkit's own exception types, and uncatchable
through :mod:`pcapkit.utilities.exceptions`.

"""
from pcapkit.utilities.exceptions import FieldValueError

raw = bytes.fromhex('3b00080000000000')

with self.assertRaisesRegex(FieldValueError, 'invalid SMF I-DPD option length'):
with time_limit(5):
protocol_cls(raw, extension=True)

def test_hopopt_identification_based_dpd_option_rejects_underflowing_length(self) -> None:
from pcapkit.protocols.internet.hopopt import HOPOPT

self._assert_identification_based_dpd_option_rejects_underflowing_length(HOPOPT)

def test_ipv6_opts_identification_based_dpd_option_rejects_underflowing_length(self) -> None:
from pcapkit.protocols.internet.ipv6_opts import IPv6_Opts

self._assert_identification_based_dpd_option_rejects_underflowing_length(IPv6_Opts)

def _assert_a_truncated_option_area_is_diagnosed(self, protocol_cls: type) -> None:
"""An option area with nothing behind it is an error, not a hang.

Expand Down
Loading
Loading