Skip to content

feat(proto-gtpv2c): type Node Identifier IE on receive - #583

Merged
VerifiedOrganic merged 3 commits into
mainfrom
feat/582-node-identifier
Jul 26, 2026
Merged

feat(proto-gtpv2c): type Node Identifier IE on receive#583
VerifiedOrganic merged 3 commits into
mainfrom
feat/582-node-identifier

Conversation

@VerifiedOrganic

@VerifiedOrganic VerifiedOrganic commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Problem

opc-proto-gtpv2c modelled no typed IE for Node Identifier (176). Emission was never blocked — the raw-preserving path handles it — but on receive a peer message carrying 176 decoded as preserved-unknown, so the crate could not structurally validate the §8.107 shape and a consumer wanting to read a peer-supplied value had to hand-parse the same peer-controlled bytes the crate already walks.

What landed

IE_TYPE_NODE_IDENTIFIER = 176, a NodeIdentifier typed value with private fields behind a validated constructor and name()/realm() accessors, a redacting Debug, infallible canonical encode, and a TypedIeValue::NodeIdentifier variant wired through all four exhaustive matches. 176 added to KNOWN_RECEIVE_IE_TYPES and exactly one ReceiveIeRule — Create Session Request, top level, instance 0, max_occurrences: ONE.

Receiver behaviour follows the spec, by presence

A malformed Node Identifier is discarded and the rest of the message processed as if it were absent. TS 29.274 clause 7.7.8, verbatim:

The receiver of a GTP signalling message including an optional information element with a Value that is not in the range defined for this information element value shall discard this IE, but shall treat the rest of the message as if this IE was absent and continue processing.

Node Identifier is presence O on Table 7.2.1-1. Clause 7.7.7 conditions the error response on presence in the same way — mandatory or verifiable-conditional gets "Invalid length", optional does not.

The first revision of this PR failed the whole message instead, justified as consistency with the crate's other typed IEs. That was wrong: the IEs cited as precedent (Cause, F-TEID, PAA) are Mandatory or Conditional, so the spec's own presence split is the rule and not an exception for 176. Corrected in 3cafb65f.

Implemented by reusing the same continue the clause 7.7.9 instance filter already uses in decode_typed_ie_sequence_at — not a new mechanism. Discard applies at all three validation levels; neither Structural nor Strict is documented as an opt-in stricter-than-TS-29.274 mode, so neither carves out.

Sender side still rejects. Both clauses open "The receiver of a GTP signalling message", so S2bDecodePurpose::CanonicalBuilder — the builders' self-check — keeps failing a caller-supplied malformed raw IE 176. The octets are already in the message being built; discarding from the typed view would emit them regardless.

TypedIe::decode_from_raw still returns Truncated; its doc already states it cannot represent deliberate omission.

Two places the issue's premise was wrong

"The value ends at the Node Realm" — no. Table 8.1-1 marks 176 Extendable and Figure 8.107-1 reserves (q+1)..(n+4): "These octet(s) is/are present only if explicitly specified". An exact-length check would reject conforming future-release traffic. Decode stops at Node Realm and ignores the suffix; canonical encode emits only the understood Release 18 prefix; raw-preserving keeps the suffix byte-exact.

"Neither length shall be zero" — not for this case. That sentence appears only under the SGSN Identifier and MME Identifier bullets, not under 3GPP AAA Server Identifier. Empty subfields are accepted.

Wire compatibility

11040-point differential — the harness was written against pre-change APIs first, baselined, then re-run. Block B originally excluded Strict; that gap is closed, so all three validation levels are now guarded.

Block A (collateral):   480 pts — 40 committed .bin fixtures x 3 levels x 2 encode modes x 2 decode surfaces
Block B (injection):  10560 pts — 5 IE types x 2 spare nibbles x 16 instances x 11 value classes x 3 levels x 2 modes

Against dae1919a, 539 changed points, all canonical mode, all IE 176:

cut split
by level structural 272 + procedure_aware 171 + strict 96
by cause 410 no octet contributed + 129 re-encoded differently

Block A: 0 changed points. Raw-preserving mode: 0 changed points across the entire PR. Well-formed IE 176 at instance 0 is byte-identical in every mode at every level.

The grid widening was verified additive rather than assumed: 0 pre-existing labels missing, 0 pre-existing labels with a changed outcome, 3520 new rows all strict, and the 2-level subset hashes identically before and after. So no regression can hide behind the digest change.

Review

Four adversarial passes across three commits.

The first found the flagship 7520-point differential asserted none of its outcomes — it checked only a point count derived from its own inputs, plus label uniqueness. It now pins every axis size as a literal and digests the ordered (label, outcome) list per block. That digest has since demonstrably earned its place: a one-bit header.rs change was caught by the digest alone, changing no labels.

The last found the discard was implemented one statement too late in the loop. seen.push ran before the discard continue, so a discarded IE still permanently claimed its (ie_type, instance) slot. Consequences, all reproduced: under the crate's own ProcedureAware receive profile a later well-formed Node Identifier was silently dropped and a fabricated DuplicateIeEvidence emitted; under DecodeContext::conservative() — documented as "suitable for untrusted network input" — the whole message failed with DuplicateIe. The peer-controlled DoS this PR exists to remove was still reachable, at four octets instead of two. Fixed in 0a723390; restoring the bug kills the three new tests and none of the pre-existing ones, confirming nothing caught it before.

Mutation testing throughout. The survivors are equivalent mutants and are identified as such rather than counted as passes — one arm is unreachable by construction, and one parameter can only take a single value at its sole live call site today.

Three defects were found in the tests themselves and fixed rather than reported as clean: a boundary test derived its 255 from the very constant under test; one mutation's anchor missed so it never ran; and both message-level tests appended the malformed IE last, where a continuebreak truncation is invisible.

Every quoted spec string was grep-confirmed verbatim against TS 29.274 V18.8.0.

One finding that changes the issue's framing

Table 7.2.1-1's condition reads "The ePDG/TWAN may include this IE on the S2a/S2b interface ... to the PGW". On S2b an ePDG-role stack is the sender, never the receiver — so this receive hardening benefits the crate's PGW-side decode surface, not an ePDG's exposure to peer-controlled bytes. The work stands (the crate models both roles and validating receive is correct), but the epdg framing on the issue does not hold for this direction.

Still unverified, as for the filer: whether any deployed PGW actually sends 176.

Follow-up filed, not folded in

#585 — four presence-O slots elsewhere in the crate still fail the whole message on a malformed value, against the same clauses. Not fixable by extending this PR's approach: it keys the discard on IE type, which is only sound because 176 is optional at every slot the profile admits. Those four vary by slot, so a correct decision must key on (procedure, direction, scope, ie_type, instance) — answerable only at ProcedureAware, which makes it a crate-wide contract change.

Gates

cargo fmt --all -- --check clean · clippy clean under --all-features and default · cargo test -p opc-proto-gtpv2c --all-features 252 → 286 · cargo build --workspace --all-targets clean.

Closes #582

TS 29.274 V18.8.0 Table 8.1-1 assigns type 176 to Node Identifier,
"Extendable / 8.107", and Figure 8.107-1 encodes it as a one-octet
Length of Node Name and Node Name followed by a one-octet Length of Node
Realm and Node Realm. The crate modelled no such IE, so a peer-supplied
Node Identifier decoded as TypedIeValue::Raw and its declared subfield
lengths were never inspected: a Node Name length of 0xff inside a
three-octet value was indistinguishable from a well-formed one.

NodeIdentifier keeps both subfields private behind a validated
constructor, which bounds each to the 255 octets its length field can
express and makes encoding infallible without a truncating cast. Decode
rejects a subfield length that overruns the IE value, or an absent
length octet, as Truncated, reporting the absolute offset of the
offending subfield Length octet under one rule for both failure paths.
Trailing octets are ignored rather than rejected because Table 8.1-1
marks 176 Extendable and clause 8.1 requires a legacy receiver to ignore
the Figure 8.107-1 (q+1) to (n+4) octets. Either subfield may be empty:
clause 8.107 requires a non-zero length only for its SGSN Identifier and
MME Identifier cases, and the encoding carries no discriminator
distinguishing them from the 3GPP AAA Server Identifier case this
profile receives.

Receive admission follows Table 7.2.1-1, which lists the IE once for
this profile: 3GPP AAA Server Identifier, presence O, Create Session
Request, top level, instance 0. Exactly one receive rule was added; the
remaining Node Identifier rows are clause 7.3 S3/S10/S16 mobility
messages this profile does not model. Admitting 176 into the receive
grammar also narrows the sender profile, because the three request
builders gate caller-supplied additional_ies on the same disposition, so
a hand-rolled raw IE 176 is now accepted only in that slot. The response
builders gate nothing; that looseness is pre-existing and is documented
rather than changed here.

Rejecting a malformed optional IE instead of discarding it diverges from
clauses 7.7.7 and 7.7.8, which both direct discard-and-continue. The
divergence is deliberate and keeps the crate-wide typed-IE contract, but
its radius is wider than the Table 7.2.1-1 slot: outside ProcedureAware
there is no instance filter, so a malformed IE 176 fails the whole
S2bMessage decode at any instance, in any modelled message type, and
nested inside a Bearer Context. CHANGELOG and CONFORMANCE now state that
radius, quote both clauses, and record that this is not what the crate's
own pco.rs selection rule would pick.

The encoder differential over 7520 points is now asserted rather than
merely enumerated: the harness digests the ordered (label, outcome) list
per block against committed literals and pins its axis sizes as literals
instead of recomputing them from the enumerator's own inputs. Re-running
it against the parent commit shows 608 changed points, all on IE 176
injection rows, zero on any committed fixture, and zero raw-preserving
byte changes.
@VerifiedOrganic VerifiedOrganic changed the title feat(proto-gtpv2c): type Node Identifier IE on receive feat(proto-gtpv2c): type Node Identifier IE on receive [DO NOT MERGE - conformance fix in progress] Jul 26, 2026
@VerifiedOrganic

Copy link
Copy Markdown
Contributor Author

Holding this PR. The clause 7.7.8 divergence documented in the description is being corrected rather than disclosed.

TS 29.274 clause 7.7.8 says shall: a receiver of an optional IE with a bad value "shall discard this IE, but shall treat the rest of the message as if this IE was absent and continue processing." Node Identifier is presence O on Table 7.2.1-1. Failing the whole message decode is a conformance violation, and "crate consistency" is not a reason to override a normative requirement.

The uniformity argument also does not hold on inspection: the other typed IEs cited as precedent (Cause, F-TEID, PAA) are Mandatory or Conditional, and the spec buckets receiver behaviour by presence - mandatory/conditional fail, optional discard-and-continue. That is the rule, not an exception for 176.

The crate's own written selection rule, quoted from pco.rs in this commit, already places IE 176 in the skip bucket.

TS 29.274 V18.8.0 clause 7.7.8 states, of an optional IE: "The receiver
of a GTP signalling message including an optional information element
with a Value that is not in the range defined for this information
element value shall discard this IE, but shall treat the rest of the
message as if this IE was absent and continue processing", and "All
semantically incorrect optional information elements in a GTP signalling
message shall be treated as not present in the message." Clause 7.7.7
reaches the same disposition for a length inconsistency in an Extendable
IE, owing the "Invalid length" response only "if the IE was received as
a Mandatory IE or a verifiable Conditional IE in a Request message".
Table 7.2.1-1 gives Node Identifier presence O, so the receiver rule is
discard-and-continue. The parent commit failed the whole message decode
instead, which is a normative violation and a peer-controlled DoS
surface: two octets in an optional IE cost an otherwise-valid Create
Session Request.

The justification given was crate consistency with Cause, F-TEID, PAA,
and TWAN Identifier. Those are Mandatory or Conditional on the messages
where this profile receives them, and TS 29.274 buckets receiver
behaviour by presence: mandatory or verifiable conditional means reject
and name a Cause, optional means discard and continue. Handling the two
differently is the specification's own rule, so the mandatory and
conditional IEs keep failing closed and only the optional bucket
changes. The parent commit had already quoted the crate's own selection
rule from pco.rs and noted that by that rule IE 176 belongs in the skip
bucket; it now is in it.

Discard reuses the clause 7.7.9 instance-filter precedent exactly: the
IE is absent from the typed view and nothing is recorded, because
S2bReceiveDiagnostics carries duplicate-IE evidence only and clause
7.7.8 requires no log for the optional case. Raw-preserving encoding
still reproduces the malformed octets byte-exact, which is observable at
the S2b layer at all now that the decode succeeds. The disposition holds
at Structural, Strict, and ProcedureAware, at every instance 0-15, in
every modelled message type, and nested inside a Bearer Context; Strict
is not an opt-in stricter-than-spec mode, and for an optional IE clause
7.7.8 is the range rule Strict enforces.

Both clauses bind "the receiver of a GTP signalling message", so the
canonical builder's sender-side self-check keeps rejecting: a
caller-supplied raw IE 176 with a malformed value is a build failure,
since those octets are already in the message and dropping the IE from
the typed view would emit them anyway. TypedIe::decode_from_raw, which
by contract cannot represent a deliberate omission, likewise keeps
returning the error.

The tests that pinned the old behaviour are rewritten to assert the new
one rather than deleted, and now assert the second half of the
requirement too: the injected IE is spliced ahead of every fixture IE
and the resulting typed projection must equal the pristine one. Four
tests are added for the raw-preserving surface, the UnknownIePolicy
axis, the builder boundary, and a malformed mandatory F-TEID that must
still fail. Eight mutations were run against the suite and all eight
were killed, including one that widens the discard to a mandatory IE and
one that lets it reach the builder.

Over the committed 7520-point encoder differential, 330 points change
from decode_err:Truncated to a successful decode, all on IE 176 rows:
320 at Structural and 10 at ProcedureAware, where instances 1-15 were
already clause 7.7.9 discards and a non-zero spare nibble fails earlier.
Half of those are raw-preserving points that return byte-for-byte to the
pre-IE-176 outcome, so across the whole change set raw-preserving
encoding is now byte-identical to the parent of the parent commit at
every one of its points, and the 608-point canonical delta drops to 443:
165 clause 7.7.9 discards, 165 clause 7.7.8 discards, 96 spare-nibble
zeroings, 17 Extendable-suffix strippings. Block A, the committed
fixture corpus, stays at zero changed points.

CHANGELOG, CONFORMANCE, and README no longer describe a deliberate
divergence, because there no longer is one.
@VerifiedOrganic VerifiedOrganic changed the title feat(proto-gtpv2c): type Node Identifier IE on receive [DO NOT MERGE - conformance fix in progress] feat(proto-gtpv2c): type Node Identifier IE on receive Jul 26, 2026
The clause 7.7.8 discard added in 3cafb65 ran one statement too late.
`seen.push((key, offset))` happened before the value decode, so a
discarded Node Identifier kept its `(ie_type, instance)` slot and
re-routed every later IE at that key into the clause 7.7.10 duplicate
machinery. "Treat the rest of the message as if this IE was absent" is a
claim about the whole remaining decode, not only about the returned
sequence.

For the wire `[IE 176 inst 0 malformed][IE 176 inst 0 well-formed]` that
meant, before this commit:

  DuplicateIePolicy::Reject -> Err(DuplicateIe)   [spec: Ok, one IE]
  DuplicateIePolicy::First  -> Ok([])             [spec: Ok, one IE]
  DuplicateIePolicy::Last   -> Ok([(176, 0)])     [correct]

`Reject` is what `DecodeContext::conservative()` selects for untrusted
network input, so the peer-controlled denial of service the parent commit
exists to remove was still reachable -- with two malformed IEs and no
valid one it cost four octets instead of two. Under the S2b receive
profile, which forces `First`, the failure was open rather than closed:
the genuine 3GPP AAA Server Identifier was silently dropped and
`S2bReceiveDiagnostics` reported a `DuplicateIeEvidence` the wire did not
contain, so the only diagnostic named the wrong cause.

Record the key only after the value decode succeeds. A discard now leaves
the key free; a retained occurrence still makes a later one at that key a
genuine clause 7.7.10 repeat, which the caller's policy governs.

Also:

- Thread `MalformedOptionalIePolicy` through `IeDecodePolicy::scoped`
  instead of hardcoding `Discard` on an invariant only a comment held.
- Narrow the discard to the error codes the clause 8.107 value decoder
  raises, so the `checked_add_offset` overflow guard is no longer
  swallowed with them.
- Extend the encoder differential's Block B to all three validation
  levels. It stopped at two, leaving the 96 `Strict` canonical IE 176
  points outside the digest: the whole-change canonical delta is 539, of
  which only 443 were guarded. `Strict` is the level `conservative()`
  selects. Raw-preserving encoding is byte-identical to `dae1919a` at all
  3079 checked points across all three levels.
- Correct the CHANGELOG, CONFORMANCE and README sentences that claimed
  the discard behaved "exactly as for a clause 7.7.9 instance discard"
  and introduced "no new diagnostic surface", restore "and critical IE
  rules" to the quoted `ValidationLevel::Strict` definition, and rescope
  the sender-side reject claim from the request builders to every S2b
  builder, which is where `build_s2b_profile_message` actually enforces
  it.

Tests: 280 -> 286.
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.

feat(proto-gtpv2c): add a typed Node Identifier IE (176) so the receive direction is validated

1 participant