Skip to content

fix(proto-gtpv2c): fail closed on malformed IE 176 in the profile-less decoder - #590

Closed
VerifiedOrganic wants to merge 5 commits into
mainfrom
fix/585b-generic-decoder-malformed-ie
Closed

fix(proto-gtpv2c): fail closed on malformed IE 176 in the profile-less decoder#590
VerifiedOrganic wants to merge 5 commits into
mainfrom
fix/585b-generic-decoder-malformed-ie

Conversation

@VerifiedOrganic

Copy link
Copy Markdown
Contributor

Refs #585 — half (b) only. The presence-keying half is not addressed here.

Problem

decode_typed_ie_sequence hardcoded MalformedOptionalIePolicy::Discard, so every caller of the exported generic decoder inherited the S2b receiver profile's discard rule for IE 176 (Node Identifier), regardless of interface, procedure or direction. A malformed IE 176 was silently elided and the decode returned success. Regression introduced this session by #582/#583.

Spec basis

TS 29.274 V18.8.0 clause 7.7.7's lead paragraph is presence-neutral, re-derived verbatim from the primary artifact for this change:

"Apart from Echo Request message, if a receiving GTP entity detects information element with invalid length in a Request message, it shall send an appropriate error response with Cause IE value set to "Invalid length" together with the type and instance of the offending IE."

Presence rows re-derived against V18.8.0: IE 176 is CO at message level in Table 7.3.1-1 (Forward Relocation Request) and Table 7.3.6-1 (Context Response). The only M rows are the grouped sub-tables 7.3.1-5 / 7.3.6-5. This contradicts the presence table in #585's own comment, which asserts M at message level. The same pattern holds in Rel-19 V19.5.0.

Fix

The profile-less path now fails closed. It deliberately does not anticipate presence-keying: where the decoder cannot know a slot's presence, it fails rather than guessing. The LengthOverflow carve-out is preserved as message-fatal, with a regression test.

BREAKING

Behavioural change on decode_typed_ie_sequence and TypedIe::decode_sequence. No signature moves, so nothing downstream fails to compile — a malformed IE 176 that previously vanished from the returned sequence now surfaces an error. This declaration is the only signal callers get.

Evidence

  • cargo test -p opc-proto-gtpv2c — 289 passed, 0 failed
  • cargo test -p opc-protocol — 19 passed, 0 failed
  • clippy -D warnings and fmt --check clean on both crates
  • Mutation proof, re-run independently by a reviewer: restoring Discard on the generic path makes the new test fail
  • S2b production path verified unmoved: s2b.rs passes its policy explicitly and does not route through the changed entry point

Review record

Three adversarial rounds. Round 1 found CONFORMANCE.md claiming TypedIe::decode_from_raw carries DecodeError::offending_ie when it returns None. Round 2 found the grouped-IE claim refuted by live probe — only the value-decode arm annotates, so a container annotates first for framing errors. Round 3 retracted the unverified claims by deletion rather than rewriting them.

Limitations

  • The grouped-IE annotation rule is error-class dependent; the docs no longer assert a general rule.
  • The Rel-19 V19.5.0 to V19.6.0 delta on these presence rows was not checked.

…s decoder (#585)

decode_typed_ie_sequence hardcoded MalformedOptionalIePolicy::Discard, so the
TS 29.274 clause 7.7.8 discard for IE 176 (Node Identifier) applied to every
caller of the crate's two exported profile-less sequence decoders. Neither
takes a procedure, a direction, or a message type, so neither can establish the
IE's Table 7.2.x-1 presence -- and presence is the condition both clauses
attach the discard to. Clause 7.7.7, quoted in
crates/opc-proto-gtpv2c/CONFORMANCE.md, is explicit that the IE "shall be
discarded and if the IE was received as a Mandatory IE or a verifiable
Conditional IE in a Request message, an appropriate error response with Cause
IE value set to "Invalid length" together with the type and instance of the
offending IE shall be returned to the sender."

Hardcoding one profile's presence judgement into a profile-less exported API
therefore did two things at once: it silently elided a peer-controlled IE from
an SDK consumer's typed view, and it destroyed the identity the clause requires
an error response to carry. The entry point now selects Reject. This is a
deliberate fail-closed step and not a presence-keyed rule -- keying the
disposition on presence needs a procedure and direction these signatures do not
carry, and is left to separate work.

BREAKING: a malformed IE 176 that previously vanished from the sequence
returned by decode_typed_ie_sequence or TypedIe::decode_sequence now surfaces
an error. No signature moved, so nothing downstream fails to compile; this note
is the only signal callers get. Callers that want the clause 7.7.8 discard must
decode through S2bMessage::decode, which selects S2bDecodePurpose::Receive and
has resolved the procedure and direction the rule depends on.

DecodeError gains an optional offending_ie identity, set through
with_offending_ie and read through offending_ie, so the failure names the IE
clause 7.7.7 owes the sender instead of leaving Gtpv2cOffendingIe to be
invented by the caller. It is set-if-absent, so a grouped IE's member keeps
naming the innermost element that actually offended rather than its container.
The field is private and DecodeError::new is the only constructor, so the
addition is source-compatible; it stores a type octet and a four-bit instance
only, both already in the clear on the wire, so the type's "never stores raw
packet bytes" logging guarantee is unchanged.

The S2b production path is untouched: s2b.rs computes malformed_optional from
S2bDecodePurpose and passes it explicitly at all three of its sequence entry
points, none of which routes through decode_typed_ie_sequence. Every S2b test
binary passes with its count unchanged.

IeDecodePolicy::discards_malformed is byte-identical, which is how the
LengthOverflow carve-out is preserved: LengthOverflow reports the offset
arithmetic guarding the decode rather than the received octets, and must still
fail the message even where a discard is licensed. It is now pinned by a unit
test, verified red by admitting LengthOverflow to that matches!. The pin has to
be a unit test because LengthOverflow is raised only by checked_add_offset and
is unreachable from any input a public decoder can be handed.

Six tests in tests/node_identifier.rs asserted the discard through the generic
decoder. They move to S2bMessage::decode, which still owns it, so the clause
7.7.8 evidence stays on the path that still applies it; the one whose subject
is the profile-less path asserts the fail-closed error and the offending
identity instead. Two of them needed a carrier built from the crate's own
builder rather than the committed fixture, which carries a private IE that
UnknownIePolicy::Reject and DecodeContext::conservative() fail on for reasons
unrelated to clause 7.7.8.

CONFORMANCE.md's "the discard is uniform across the decode surface ... at every
validation level" bullet is rewritten: the axis that selects the discard is the
decode entry point, not the validation level. The rustdoc on
TypedIe::decode_sequence and TypedIe::decode_from_raw, the crate README, and
the MalformedOptionalIePolicy variant docs are corrected in the same commit,
the last to describe the disposition rather than a role now that callers on
both sides of the wire select Reject.

The clause 7.7.7 and 7.7.8 text quoted here and in CONFORMANCE.md is the
in-tree quotation. No 3GPP artifacts exist on this machine, so it is NOT
RE-DERIVABLE ON THIS MACHINE.
…585)

Follow-up to 06ef176. Adversarial review found that the CONFORMANCE.md text
that commit added was itself false in two places, that it left the pre-change
contract standing verbatim in a doc comment 24 lines above the test that
inverts it, and that the set-if-absent guard it introduced in
DecodeError::with_offending_ie had no test in any crate.

CONFORMANCE.md claimed all three profile-less decoders return an error carrying
DecodeError::offending_ie and named TypedIe::decode_from_raw among them, but
decode_from_raw never called with_offending_ie and returned None. A caller
holding a single RawIe -- the one surface reached for when you already have the
IE -- therefore had nothing to put in the clause 7.7.7 "Invalid length" Cause
IE, which is exactly the identity the parent fix exists to preserve.

Of the two remedies, annotating decode_from_raw is chosen over narrowing the
prose. The prose describes the product this crate should ship: clause 7.7.7
owes the sender "the type and instance of the offending IE", and a conversion
handed exactly one IE knows that identity for certain. Every existing test that
exercises decode_from_raw asserts on code, offset, or spec_ref -- none compares
a whole DecodeError -- so no existing expectation moves. The annotation is
placed in the public wrapper rather than in decode_from_raw_with_evidence so
the sequence loop keeps a single annotation site. Because the attachment is
set-if-absent, a grouped IE handed to decode_from_raw still names the member
that failed, not the container.

Verified red first: node_identifier_value_decode_reports_the_malformed_length_pair,
extended to sweep instances 0-15 and assert the identity, failed with
"left: None, right: Some((176, 0))" before the map_err and passes after.

The set-if-absent guard now has coverage in both crates, written mutation-first.
Replacing the is_none() check with an unconditional assignment turns
decode_error_offending_ie_keeps_the_first_identity_attached (opc-protocol) and
a_grouped_ie_failure_names_the_offending_member_not_the_container
(opc-proto-gtpv2c) red, both reporting Some((93, 1)) where Some((73, 2)) is
required; reverting it turns them green. Before this commit opc-protocol had no
test that mentioned offending_ie at all, so the guard could have been deleted
there unnoticed. 06ef176 touched crates/opc-protocol/src/error.rs but its gate
ran only cargo test -p opc-proto-gtpv2c; both crates are gated here.

Corrected documentation, each verified against the code rather than reasoned
from the previous text:

* CONFORMANCE.md's error-response bullet said the offending IE's type and
  instance "are supplied by the decoder itself through DecodeError::offending_ie
  rather than being reconstructed by the caller". error_response.rs contains
  zero references to DecodeError; the boundary takes a caller-built
  Gtpv2cOffendingIe. The bullet now says so and describes reading the identity
  off DecodeError::offending_ie as the caller's step.
* tests/node_identifier.rs said "The sequence decoders apply TS 29.274 clause
  7.7.8 to the same failure and discard the IE instead; that is pinned
  separately below" -- the pre-change contract, which 06ef176 inverted without
  touching this paragraph. It now says the profile-less sequence decoders reach
  the same disposition, and points at the two tests that actually pin each half.
* typed.rs and README.md said the clause 7.7.8 discard "is applied by
  S2bMessage::decode alone" / "reached only through S2bMessage::decode".
  S2bMessage::decode_with_diagnostics (s2b.rs:3517) and S2bMessage::from_message
  (s2b.rs:3523) also select S2bDecodePurpose::Receive, and decode reaches it
  only through from_message, so from_message is the wider surface. All three are
  now named wherever the profile is described.
* The LengthOverflow unit test's justification claimed LengthOverflow "is raised
  only by checked_add_offset" and is "unreachable from any input a public
  decoder can be handed". Both are false: it is constructed directly at
  header.rs:449, message.rs:90/212, ie.rs:45/234/252/315 and several sites in
  typed.rs, and TypedIe::decode_from_raw(raw_176, ctx, 0, usize::MAX) returns it
  (observed by probe). The guard the test pins is still correct, so only the
  comment changed: it now argues reachability for this guard's own inputs and
  says explicitly that the claim does not extend to the crate.
* IeDecodePolicy::scoped still explained the parameter as "the caller passes the
  side it is on" after Reject became a both-sides disposition; it now says the
  caller passes its profile.
* DISCARD_MALFORMED_OPTIONAL_IE_TYPES' header read as the disposition itself; it
  now says eligibility, membership being one of the three conjuncts
  discards_malformed requires.
* CHANGELOG "The S2b production path is untouched" was false as written: all
  three S2b entry points share decode_typed_ie_sequence_at, so S2b errors now
  carry an offending_ie payload they did not before. Split into the disposition
  (unmoved) and the payload (changed, with the observable consequence stated).
* The [Unreleased] #583 entry kept two sentences asserting the old crate-wide
  discard while the supersession note named only one. The note now names all
  three claims it supersedes.

06ef176's own commit message repeats the LengthOverflow and "S2b production
path is untouched" claims corrected above; it is left unamended and this message
is the correction of record.

The clause 7.7.7 and 7.7.8 text quoted here and in CONFORMANCE.md is the
in-tree quotation. No 3GPP artifacts exist on this machine, so it is NOT
RE-DERIVABLE ON THIS MACHINE.
Live probes against HEAD refute several claims this branch's docs make about
`DecodeError::offending_ie`:

  - `decode_typed_ie_sequence` on Bearer Context (93, instance 1) wrapping a
    truncated member header returns `offending_ie: Some((93, 1))` -- the
    container, not the member. Only the value-decode arm annotates; the
    framing, structural and depth arms return unannotated, so the enclosing
    loop stamps the container identity.
  - A top-level framing overrun returns `offending_ie: None`, as does
    `DuplicateIe` under `DuplicateIePolicy::Reject`.
  - `S2bDecodePurpose::Receive` is selected at two sites (s2b.rs:3517, 3523),
    not by the three entry points the rustdoc enumerated, and that list is not
    exhaustive over the public surfaces that reach them.
  - `DecodeError` derives `Debug`, so `{:?}` output did change; only `Display`
    is unchanged.

The unqualified grouped-IE sentence, the "each of the three" universal, the
entry-point enumeration and the "sees no difference" consequence are removed
rather than restated. The rustdoc on `TypedIe::decode_from_raw` already carries
the qualified form ("a member that failed its own value decode") and is left
as is.

Also scopes the CHANGELOG's remaining unscoped discard bullet to the S2b
receive profile, drops the count from the supersession note so the enumeration
no longer claims to be complete, and deletes the claim that
`TypedIe::decode_from_raw` is the surface the canonical builder's self-check
runs on -- it has no production caller; the self-check routes through
`build_s2b_profile_message` under `S2bDecodePurpose::CanonicalBuilder`.

No behaviour change.
The CHANGELOG said the error from `TypedIe::decode_from_raw` "names the IE it
was handed". A probe refutes it: handed `RawIe { ie_type: 93, instance: 1 }`
wrapping a malformed IE 176 at instance 5, the returned error carries
`offending_ie = Some((176, 5))` -- the nested member, not the container.
Handed the same malformed IE 176 directly, it carries `Some((176, 5))` too.
`a_grouped_ie_failure_names_the_offending_member_not_the_container` in
tests/s2b_typed.rs already pins the equivalent for a Bearer Context wrapping
an EBI.

This was a truncation artifact: a previous edit removed the trailing qualifier
and left the stem asserting the unqualified form. Narrowed in place rather
than deleted, because the entry has to record that 440fcb6 added the
annotation at all. The stem "already returned the value-level error and still
does" was re-checked against e5abab7, where `decode_from_raw` already passed
`MalformedOptionalIePolicy::Reject` and applied no `with_offending_ie`.

The set-if-absent behaviour is already stated under `DecodeError::offending_ie`
in the Added section, so the new clause points at it rather than restating it.

No code or test changed.
`offending_ie` grew `DecodeError` from 104 to 112 bytes: the field is
three bytes but the struct was exactly packed, so it cost a whole
8-byte alignment unit. `DecodeError` is returned by every protocol
crate in the workspace, and at 112 bytes it pushed
`opc-proto-pfcp`'s `ProfileValidationError` past
`clippy::result_large_err`, failing CI in 38 places in a crate this
change never touched.

Narrows `offset` to `u32` so `offending_ie` occupies the bytes it
frees. The protocols this type serves bound offsets by 16- and 32-bit
length fields, so the range is unreachable; `new` saturates rather
than wrapping so a pathological offset cannot report a small wrong
one. `offset()` still returns `usize`, and every field stays private,
so no public API changes.

Rejected alternatives, both of which would have ADDED breakage:
boxing `ProfileValidationError::TypedDecode::source` (clippy's own
suggestion) changes a public enum in a published crate, and boxing
`DecodeError::spec_ref` makes `with_spec_ref` non-const.

Pins the size in a test, so the next field addition fails in the crate
that owns the type rather than as a lint in a crate that uses it.
@VerifiedOrganic

Copy link
Copy Markdown
Contributor Author

Closing as superseded by #603, which fixes the same two defects without this branch's regressions:

  • No usizeu32 narrowing of DecodeError::offset. Measured on the tree: DecodeError is 104 bytes, a PFCP profile error embeds it at 120, and clippy's result_large_err fires at >= 128 — so the size pressure this branch was paying for was real, but fix(proto-gtpv2c): name the offending IE and fail closed in the profile-less decoder #603 removes it by keeping element identity in opc-proto-gtpv2c instead of widening the shared type. crates/opc-protocol/** is untouched there.
  • No GTPv2-shaped (u8, u8) on the shared published error. PFCP and NGAP IE types are u16; Diameter is an AVP code plus an optional vendor id.
  • No unconditional size_of assertion. fix(proto-gtpv2c): name the offending IE and fail closed in the profile-less decoder #603 widens the 32-bit lane to cover opc-protocol and opc-proto-gtpv2c and wires it into the aggregate needs:, which it previously was not in.
  • Complete-header-plus-overrun now carries the Type and Instance it already holds, which this branch still dropped.

Closing rather than leaving open so the wrong branch cannot be merged.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant