protocols: drop ipv6_opts' stray SMF_DPD test field, fix two length underflows - #449
Conversation
…nderflows - ipv6_opts.py's SMFIdentificationBasedDPDOption carried a ForwardMatchField named `test` that its HOPOPT twin does not. It consumes no bytes but still occupies a __buffer__ slot, so len(schema) over-reported the option by one octet and the option-area threshold check raised ProtocolError on octets that parse cleanly under HOPOPT. Confirmed against RFC 6621 section 6.1.1: the mode bit already lives in `info`, so the field was a stray leftover, not a missing one. Fixes #441. - hopopt.py's and ipv6_opts.py's SMFIdentificationBasedDPDOption.id sized itself as `Opt Data Len - (1 or TidLen + 2)` with nothing floored at zero, so a peer declaring too small an Opt Data Len drove the length negative and reached struct.calcsize() as '-1s', raising a bare struct.error. Replaced the lambda with a named function that raises FieldValueError instead. - hip.py's RegRequestParameter/RegResponseParameter/RegFailedParameter sized their registration-type list as `Length - 1` with the same unfloored shape. ListField doesn't reach struct.calcsize for a negative length, so this silently produced an empty list rather than crashing -- still a wrong parse of malformed input, not a rejection of it. Same guard applied via a shared helper. Fixes #438. Build: mypy clean on the three modified schema modules. Tests: full suite 916 passed/17 skipped before, 921 passed/17 skipped after (5 new tests, all verified to fail before the fix and pass after).
Picks up #440 (option round-trip harness) so its EXPECTED_FAILURES table can drop the SMF_DPD entry this PR fixes.
…entry #440's round-trip harness recorded this case as a PARSE gap; the fix in this branch closes the round trip, so the entry is removed rather than left stale. Kept the surrounding comment as history of both the #432 and #441 fixes, matching how the hopopt half of the same story was already handled.
| 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={ |
There was a problem hiding this comment.
the PR's note reads like the ForwardMatchField's logic or handling logic is defect. maybe worth double checking and fixing.
There was a problem hiding this comment.
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.
|
Reviewing on behalf of Copilot (out of tokens). Reviewed commit CIPolled Branch position
#441 (stray
|
| Measurement | Claimed | Reproduced |
|---|---|---|
mypy on the 3 modified schema modules |
clean | clean ("Success: no issues found in 3 source files") |
tests/protocols/test_option_roundtrip_unit.py |
7 passed / 299 subtests / 0 failures | 7 passed, 299 subtests passed |
test_ipv6_extension_unit.py + test_hip_unit.py |
55 tests / 125 subtests | 55 passed, 125 subtests passed |
Full suite, baseline a4c8d62b1 |
926 passed / 17 skipped / 1268 subtests | 926 passed, 17 skipped, 1268 subtests passed (own clone, own run, 586.91s/564.43s) |
Full suite, PR tip d60916b85 |
931 passed / 17 skipped / 1271 subtests | 931 passed, 17 skipped, 1271 subtests passed (own clone, own run, 635.19s/594.66s) |
Every claimed number matches exactly, including the +5 passed / +3 subtests delta.
Verdict
GOOD TO MERGE at d60916b85. #441 and #438 are both correctly diagnosed (verified independently against RFC 6621 and the actual field/selector code, not just re-asserted), correctly and minimally fixed, covered by new wire-level tests that reproduce the original repros, and every quantitative claim in the PR description reproduces exactly on independent measurement. CI is fully green with no red legs on this commit. The one real gap — CALIPSOOption/MPLOption padding and RegInfoParameter.reg_info sharing the same unguarded-underflow shape, one of them (CALIPSO/MPL) still crashing with a bare struct.error today — is honestly disclosed in the PR body rather than hidden, is not introduced or worsened by this change, and is flagged above and inline so the promised follow-up issue actually gets filed.
…chema) (#456) * schema: a forward match consumes nothing, so bill it nothing in len(schema) Schema.unpack() kept the octets a ForwardMatchField read in __buffer__ even though it rewinds the stream past them, so __bytes__()/__len__() -- which concatenate every __buffer__ slot -- double-counted them: once in the forward-matched slot, once more where the real field re-reads the same octets. OptionField and ListField size a declared area by subtracting len(item) as they go, so the over-count broke correct input, e.g. CGAParameter's public_key_test (mh.py:509), which cannot simply drop the field the way #441's stray one can. See #446. - pcapkit/protocols/schema/schema.py: unpack() now zeroes a ForwardMatchField's __buffer__ slot after the rewind, mirroring what pack() already does for the same field type -- pack() has zeroed it since #422 and a schema built from field values already tests that way (test_schema_unit.py:69-70). unpack() was the one path left inconsistent; bytes(schema) now agrees with pack()'s existing convention on both paths, and now reproduces the octets actually consumed rather than double counting the previewed region. - tests/protocols/schema/test_schema_unit.py: two new cases -- a minimal schema with one ForwardMatchField asserting len(schema) equals the octets consumed, and a ListField whose declared area is exactly right but was rejected before the fix with the same FieldValueError CGAParameter hits. - tests/protocols/test_option_roundtrip_unit.py: deletes the 'ipv6-opts-option/SMF_DPD' EXPECTED_FAILURES entry, which this fix turns 'OK' on its own (independent of #449's removal of the stray field that exposed it). Full suite: 864 passed, 13 skipped, at PYTHONSAFEPATH=1, interpreter 3.14.7. Baseline at e2d8ed6 (origin/main): 862 passed, 13 skipped, before the 2 new tests existed. * tests: name the ListField that actually raises, not CGAParameter's OptionField CGAParametersOption.parameters is the ListField of CGAParameter items whose budget the forward-match over-count drains; CGAParameter.extensions is an OptionField and never raises an invalid-length FieldValueError itself. Fix the docstring to name the field that matches the issue's own traceback.
Summary
Two related schema defects in
pcapkit/protocols/schema/internet/, both surfaced while investigating option/parameter length arithmetic in that directory.#441 — stray
testfield makeslen(schema)over-report by oneDefect.
pcapkit/protocols/schema/internet/ipv6_opts.py'sSMFIdentificationBasedDPDOptiondeclared:right before
info. Its HOPOPT twin (pcapkit/protocols/schema/internet/hopopt.py) has no such field — the two classes are otherwise line-for-line equivalent.Mechanism.
ForwardMatchFieldconsumes no bytes from the stream but still occupies aSchema.__buffer__slot (pcapkit/protocols/schema/schema.py), so it counts towardlen(schema). The nested schema therefore over-reported its length by one octet, andOptionField(pcapkit/corekit/fields/collections.py) accumulated that over-report against the declared option area until the threshold check inpcapkit/protocols/internet/ipv6_opts.py(_read_ipv6_opts) raisedProtocolError: IPv6-Opts: invalid formaton octets that parse cleanly under HOPOPT.Why it was a stray field, not a missing one — verified against RFC 6621 §6.1.1. The I-DPD option header layout is: octet 0 Option Type, octet 1 Opt Data Len, octet 2 bit 0 the H-bit/mode (0 = I-DPD), bits 1–3 TidTy, bits 4–7 TidLen, then the TaggerID and Identifier. There is no separate "test" octet in the wire format — the mode bit is part of the same octet as TidTy/TidLen, which
SMFIdentificationBasedDPDOption.info(BitField(length=1, namespace={'mode': (0,1), 'type': (1,3), 'len': (4,4)})) already reads. Confirmed by usage:pkt['test']is read only bysmf_dpd_data_selector, which operates on the enclosing_SMFDPDOption's packet dict (both modules already declaretestthere, atlength=3), not onSMFIdentificationBasedDPDOption's own fields —smf_i_dpd_id_lenandpost_processon this class never referencepkt['test']. So the field was dead weight left over from before #432, not a field HOPOPT is missing.Fix. Removed the stray field from
ipv6_opts.py. HOPOPT was already correct; ipv6_opts.py now matches it exactly.Independent corroboration from #440. After this branch was up, #440 merged to
mainand addedtests/protocols/test_option_roundtrip_unit.py, a harness that round-trips 258 option codes and records the ones that cannot close the cycle. It had anEXPECTED_FAILURESentry for exactly this defect ('ipv6-opts-option/SMF_DPD', recorded as aPARSEgap atipv6_opts.py:434, discovered independently of this PR). Mergingorigin/maininto this branch and running that file turns the entry's assertion red —AssertionError: 'OK' != 'PARSE'— because the round trip now closes. That is the harness telling us, from a direction neither this PR nor #440 anticipated together, that the #441 diagnosis and fix are correct. Removed the now-staleEXPECTED_FAILURESentry in this PR (see Tests below); left the surrounding historical comment in place, updated to past tense.#438 — a wire-derived length underflows into a negative struct format
Defect.
SMFIdentificationBasedDPDOption.id(bothhopopt.pyandipv6_opts.py) sized itself as:with nothing floored at zero.
Mechanism. A peer declaring an
Opt Data Lentoo small for the TaggerID it claims (e.g.Opt Data Len = 0for a null TaggerID, which needs at least 1) drives this negative._TextField.__call__(pcapkit/corekit/fields/strings.py) then setsself._template = f'{length}s', i.e.'-1s', and the baseFieldBase.lengthproperty (pcapkit/corekit/fields/field.py) computesstruct.calcsize('-1s'), raising a barestruct.error: bad char in struct format— not one of pcapkit's own exception types, and uncatchable throughpcapkit.utilities.exceptions.Fix. Replaced the lambda with a named function,
smf_i_dpd_id_len, in each module (matching the existingmpl_opt_seed_id_lenconvention already in both files), which raisesFieldValueErrorwhen the computed length would be negative instead of letting it reachstruct.FieldValueErroris the exception this file already raises for every other "computed field parameter is invalid" case (smf_i_dpd_tid_selector,smf_dpd_data_selector,mpl_opt_seed_id_len), so no new exception type was needed.HIP siblings — same guard, needed.
pcapkit/protocols/schema/internet/hip.py'sRegRequestParameter,RegResponseParameterandRegFailedParameterall sized their registration-type list the same unfloored way:ListField(length=lambda pkt: pkt['len'] - 1, ...). These do need the guard, though the failure mode differs from theBytesFieldcase:ListField.length(pcapkit/corekit/fields/collections.py) returns its rawintdirectly rather than going throughstruct.calcsize, and itsunpack()loop iswhile length > 0, so a negative length never crashes — it silently returns an empty list. Verified end-to-end with a craftedLength = 0REG_REQUESTparameter: pre-fix, this parses toreg_type=()with no exception and no diagnostic, exactly the "silently produce a wrong parse" failure the fix is meant to avoid, not astruct.error. Fixed with a shared helper,registration_type_list_len, reused by all three parameter schemas.Checked, post-merge, that this change does not disturb any of #440's other HIP-related
EXPECTED_FAILURESentries —hip-parameter/R1_Counter,ENCRYPTED,HIP_TRANSFORM,HOST_ID, and the sixteen-parameterRECONSTRUCT/tuple-vs-list group that includesREG_REQUEST/REG_RESPONSE/REG_FAILEDthemselves. TheRECONSTRUCTgroup's gap is a different defect entirely (_read_param_*returning a tuple where_make_param_*needs a list) on the well-formed construct/parse/reconstruct path; this fix only changes behavior for a malformedLengththat well-formed round-trip data never produces. Confirmed by running the full round-trip file after mergingorigin/main: 7 passed, 299 subtests, 0 failures — the one flip is exactly the SMF_DPD entry above, nothing else moved.Two other siblings the issue named for the same check —
RegInfoParameter.reg_info(pkt['len'] - 2) inhip.py, and theCALIPSOOption/MPLOptionpadding fields inhopopt.py/ipv6_opts.py(pkt['len'] - 8 - pkt['cmpt_len']*4,pkt['len'] - 2 - ...) — share the identical unfloored shape but are not touched by this PR; they were outside the lines the issue asked to check, and are being filed separately.Tests
tests/protocols/internet/test_ipv6_extension_unit.py: two new wire-level tests per protocol (HOPOPT/IPv6-Opts) — one reproducing ipv6_opts' SMFIdentificationBasedDPDOption has a stray test field HOPOPT lacks, so identical octets fail there #441's exact octets and asserting both protocols now produce the identical option layout, one reproducing A wire-derived length underflows into a struct format string, raising bare struct.error on untrusted input #438's exact octets and assertingFieldValueErrorinstead of a crash. Extended the existing schema-helpers test with direct calls tosmf_i_dpd_id_lencovering both branches (null and non-null TaggerID) and the underflow guard. Updated a docstring and two test fixtures that had encoded the ipv6_opts' SMFIdentificationBasedDPDOption has a stray test field HOPOPT lacks, so identical octets fail there #441 field-count mismatch as expected behavior (anident_kwargs['test'] = ...conditional that only applied to ipv6_opts, and an unconditionaltest=...kwarg shared by both) — both are now symmetric between the two protocols.tests/protocols/internet/test_hip_unit.py: one new wire-level test constructing a full HIP packet with aLength = 0REG_REQUEST/REG_RESPONSE/REG_FAILEDparameter, assertingFieldValueError. Extended the existing schema-selectors test with direct calls toregistration_type_list_len.tests/protocols/test_option_roundtrip_unit.py(added by tests: round-trip 258 option codes from the registries, recording the 86 that cannot close the cycle #440, merged in): removed the now-fixed'ipv6-opts-option/SMF_DPD'EXPECTED_FAILURESentry — the table's own design is that a fixed defect deletes its entry rather than leaving it behind, and the assertion message says exactly that. Kept the surrounding comment block, rewritten to past tense, as history alongside the existing note about corekit: stop the option and list loops spinning forever on a truncated area (#431) #432's half of the same story; did not comment the entry out.git stash push -- pcapkit/protocols/schema/internet/*.py(tests left in place, never a baregit stash).autofunctionentries for the two new helper functions in the corresponding.rstfiles, matching the existing entries formpl_opt_seed_id_lenetc.Test plan
mypyclean on the three modified schema modulestests/protocols/internet/test_ipv6_extension_unit.pyandtests/protocols/internet/test_hip_unit.pypass (55 tests, 125 subtests)tests/protocols/test_option_roundtrip_unit.pypasses in full post-merge: 7 passed, 299 subtests, 0 failuresorigin/main(git merge --no-ff, no rebase, no force-push) — clean, zero conflictsmainata4c8d62b1(tests: round-trip 258 option codes from the registries, recording the 86 that cannot close the cycle #440's own merge commit; Python 3.14.7,examples/generators/make_samples.pyrun first): 926 passed, 17 skipped, 1268 subtestsa4c8d62b1+ this fix): 931 passed, 17 skipped, 1271 subtests — net +5 passed test methods, +3 subtests, 0 failures, no new skipspcapkit.__file__verified against this worktree for both measurements(Earlier figures of 916/921 passed against
f50436a8awere the pre-#440 baseline and are superseded by the above, taken after mergingorigin/main.)Closes #441
Closes #438