icmp - #1760
Draft
daniel-noland wants to merge 24 commits into
Draft
Conversation
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: trueComment |
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
daniel-noland
force-pushed
the
pr/daniel-noland/bench-harness
branch
from
August 26, 2026 17:30
65dccd7 to
640edd0
Compare
daniel-noland
force-pushed
the
pr/daniel-noland/icmp-and-acl
branch
3 times, most recently
from
August 26, 2026 20:41
d200140 to
e431baa
Compare
daniel-noland
force-pushed
the
pr/daniel-noland/bench-harness
branch
from
August 26, 2026 21:02
1ca904b to
7ae0ae2
Compare
daniel-noland
force-pushed
the
pr/daniel-noland/icmp-and-acl
branch
from
August 26, 2026 21:02
e431baa to
30ad436
Compare
daniel-noland
force-pushed
the
pr/daniel-noland/bench-harness
branch
from
August 26, 2026 21:13
7ae0ae2 to
afe8933
Compare
daniel-noland
force-pushed
the
pr/daniel-noland/icmp-and-acl
branch
from
August 26, 2026 21:13
30ad436 to
972ede1
Compare
daniel-noland
force-pushed
the
pr/daniel-noland/bench-harness
branch
from
August 26, 2026 21:25
afe8933 to
ab17860
Compare
daniel-noland
force-pushed
the
pr/daniel-noland/icmp-and-acl
branch
from
August 26, 2026 21:25
972ede1 to
86a422e
Compare
daniel-noland
force-pushed
the
pr/daniel-noland/bench-harness
branch
from
August 27, 2026 01:29
ab17860 to
b0a5e97
Compare
daniel-noland
force-pushed
the
pr/daniel-noland/icmp-and-acl
branch
from
August 27, 2026 01:29
86a422e to
40180ec
Compare
daniel-noland
force-pushed
the
pr/daniel-noland/bench-harness
branch
from
August 27, 2026 01:41
b0a5e97 to
c2c48ab
Compare
daniel-noland
force-pushed
the
pr/daniel-noland/icmp-and-acl
branch
from
August 27, 2026 01:41
40180ec to
ebe87d1
Compare
daniel-noland
force-pushed
the
pr/daniel-noland/bench-harness
branch
from
August 27, 2026 04:35
c2c48ab to
61fb4c7
Compare
daniel-noland
force-pushed
the
pr/daniel-noland/icmp-and-acl
branch
from
August 27, 2026 04:36
5d8566c to
ee0870c
Compare
daniel-noland
force-pushed
the
pr/daniel-noland/bench-harness
branch
from
August 27, 2026 05:12
61fb4c7 to
b18bba9
Compare
daniel-noland
force-pushed
the
pr/daniel-noland/icmp-and-acl
branch
from
August 27, 2026 05:12
ee0870c to
d39b0d7
Compare
Draft
daniel-noland
force-pushed
the
pr/daniel-noland/bench-harness
branch
from
August 27, 2026 18:00
b18bba9 to
9a66fc6
Compare
daniel-noland
force-pushed
the
pr/daniel-noland/icmp-and-acl
branch
from
August 27, 2026 18:00
543ccfc to
792d63f
Compare
daniel-noland
force-pushed
the
pr/daniel-noland/bench-harness
branch
from
August 27, 2026 18:29
9a66fc6 to
4517822
Compare
daniel-noland
force-pushed
the
pr/daniel-noland/icmp-and-acl
branch
2 times, most recently
from
August 28, 2026 02:13
6c9c867 to
d4744be
Compare
daniel-noland
force-pushed
the
pr/daniel-noland/bench-harness
branch
from
August 28, 2026 02:13
4517822 to
bb9404d
Compare
daniel-noland
force-pushed
the
pr/daniel-noland/icmp-and-acl
branch
from
August 28, 2026 02:19
d4744be to
f33dfe6
Compare
daniel-noland
force-pushed
the
pr/daniel-noland/bench-harness
branch
2 times, most recently
from
August 28, 2026 03:05
54309b8 to
b721f06
Compare
daniel-noland
force-pushed
the
pr/daniel-noland/bench-harness
branch
from
August 28, 2026 07:43
5f95720 to
5a2aaf7
Compare
daniel-noland
force-pushed
the
pr/daniel-noland/icmp-and-acl
branch
from
August 28, 2026 07:43
7e9ebd7 to
8f3aea3
Compare
daniel-noland
force-pushed
the
pr/daniel-noland/bench-harness
branch
from
August 28, 2026 09:14
5a2aaf7 to
2777622
Compare
daniel-noland
force-pushed
the
pr/daniel-noland/icmp-and-acl
branch
from
August 28, 2026 09:14
8f3aea3 to
49c0eda
Compare
…ts on RFC 5508 REQ-6, found by tracking the specification the ICMP handler was already arguing from in prose. An ICMP error is trivially spoofable and masquerade releases the public address and port along with the flow, so honouring every Destination Unreachable handed an off-path attacker both a way to break a ping it cannot observe and a way to churn the port pool. The interlock's remaining survivor on this citation is the IPv6 arm, which no test reaches: masquerade carries a v6 pool and the nat suite has no IPv6 case at all. Signed-off-by: Daniel Noland <daniel@githedgehog.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The `TypeGenerator` contract is that a generator eventually reaches every legal value; this one documented that it did not reach `Ipv4::options`, so no property in the workspace has ever seen a variable-length IPv4 header. `parse_back` is why it stayed that way: it sized its buffer to `MIN_LEN` and asserted the deparse wrote exactly that, which is the generator's limitation restated as a requirement. The whole workspace passes once both are fixed. Signed-off-by: Daniel Noland <daniel@githedgehog.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`is_icmp_unrecoverable` decides whether an ICMP error costs the flow it reports on, and no test said anything about it: the whole function could be replaced by `(true, None)` or `(false, None)` and the suite passed. The distinction it draws is the one that matters for availability -- Fragmentation Needed is Path MTU Discovery, and the sender is about to retry the flow that tearing it down would strand. The fixture builder took the Destination Unreachable code as a constant, so the recoverable case could not be built at all. Ten survivors in this file become three, all of them IPv6. Signed-off-by: Daniel Noland <daniel@githedgehog.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pools, the allocator and the stage are generic over the address family and `build_pool66` mirrors `build_pool44` exactly, which is the argument that kept the IPv6 instantiation of all three untested: the nat crate had no IPv6 case at all. It translates and untranslates correctly. Signed-off-by: Daniel Noland <daniel@githedgehog.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Turning a usage bitmap into port ranges is the allocator's answer to "what is allocated", and 28 of the crate's surviving mutants were in it: the arithmetic that turns a bit offset into a port number, the join between a block's two halves, and the fold across blocks were all unstated. Nothing said a range had to be maximal, or that folding blocks in a different order gave the same answer -- which is the order the caller actually uses, since it iterates a `HashMap`. The two bounds in `merge_ranges` guard additions next to the edges of `u16`, and a property confined to low ports reads them as no-ops; drawing blocks from the whole port space is what makes them die. Signed-off-by: Daniel Noland <daniel@githedgehog.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The RFC 5508 REQ-6 citation was decorative on IPv6: the arm of the predicate that recognises an embedded ICMPv6 Query could be deleted outright and the suite passed, because no ICMPv6 fixture existed. The interlock reported this as `unreached` rather than as a survivor, which is what said the fix was to change what the test feeds. Path MTU Discovery needed its own IPv6 test rather than a parameter: v4 spells the recoverable case as a Destination Unreachable code and v6 as a separate type, so one test cannot reach both arms. REQ-6 now holds under `just spec-interlock`. What survives in this file is the Packet Too Big guard, which decides nothing the fallback would not; the reason is recorded next to it. Signed-off-by: Daniel Noland <daniel@githedgehog.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… itself Three things the forwarding path decides that nothing asserted. Nothing said ECMP spreads. The entry selector could be replaced by "always entry zero" and the suite passed -- a router forwarding every flow down one of its paths, which looks healthy until a link saturates. Stability is asserted alongside it, because a flow whose packets take different paths reorders itself and splits in two for anything downstream that keys on the path. `is_iplocal` and `is_vxlan_with_vni` are read on the forwarding path and had no test at all. Both are stated against a second reading of the same fact rather than against a restatement of their bodies. `> 1` in the selector is equivalent to `>= 1` and the reason is recorded next to it; `== 1` and `< 1` are the ones that matter, and now die. Signed-off-by: Daniel Noland <daniel@githedgehog.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
RFC 5508 REQ-3 and its (a) and (c) clauses. The code already did the right thing and the comment already named the RFC; what was missing was a test that could tell a validator checking too much from one checking too little. The existing test breaks every checksum at once, so it cannot. Both sub-clauses need the ICMP checksum recomputed after the field under test is broken, because it covers them. That is not a trick to make the test pass: without it the outer check fires first and neither clause is ever reached, and with it the packet is the one a real reporter produces. Cited in `net` rather than on the existing `nat` test, because the interlock declines a citation whose implementation and test are in different crates. Signed-off-by: Daniel Noland <daniel@githedgehog.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
RFC 8200 puts extension headers between the IP header and the transport, so for IPv6 the IP header's next-header field names the first extension header rather than what the packet carries. Both filters matched their rules against that field. In `acl-filter` this is a bypass: a Deny rule naming TCP stops applying to any TCP packet with one Hop-by-Hop header in front of it, and the sender chooses whether there is one. In `flow-filter` the same mistake fails the other way, dropping traffic a protocol-restricted expose was configured to carry -- which had been written down as a characterization test rather than recognised as the same defect. `upper_layer_proto` returns `None` when the chain runs past MAX_NET_EXTENSIONS rather than guessing, because a filter has to decide what to do about a chain nobody finished reading. Both callers drop. Signed-off-by: Daniel Noland <daniel@githedgehog.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The case only exists on the wire -- the header builder caps at MAX_NET_EXTENSIONS -- so the fixture is bytes. That is also the only place an attacker writes. Signed-off-by: Daniel Noland <daniel@githedgehog.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
RFC 4884 section 4.6 gives one list per address family and they differ: ICMPv4 Parameter Problem may carry an extension structure and ICMPv6 Parameter Problem may not, because its bytes 4..8 are the Pointer and there is nowhere to put a length attribute. The IPv6 list was the IPv4 list. The consequence is a pointer read as a length: any ICMPv6 Parameter Problem whose pointer exceeds 255 announces an "original datagram" of up to 2040 octets, and `check_full_payload` decides whether the embedded packet is complete against that number. Same shape as the RFC 4884 defect this branch opened with -- a near-duplicate where the specification is not symmetric -- so both lists are now stated, and the citation needs both tests. Signed-off-by: Daniel Noland <daniel@githedgehog.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both stages reached into the headers for the two layers they care about, which means every layer they do not name is silently along for the ride. `pat` was built to make that impossible -- it fails the match when an unnamed layer is present, and VLAN tags are strict there because they decide forwarding -- and both stages bypassed it. A VLAN tag is not an evasion: it sits ahead of the IP header, so the protocol and ports a rule matches on were always right. The problem is the other side. Nothing after these stages reads `headers.vlan` -- `Egress` rewrites the MACs and leaves it, VXLAN re-encapsulation puts the outer headers in front of it -- so a tag chosen by whoever built the inner frame of a tunnelled packet would be forwarded onto whatever segment it names, decided by nobody. There is no configuration that expresses an opinion about one. The cost is one `ArrayVec::len` comparison: `opt_eth` and `opt_transport` are `Option` maps over fields both stages already read, `ext_gap_ok` for `Net` is a constant `true`, and `step` is `#[inline]`. Everything else is the type-level accumulator. `upper_layer_proto` stays, and the two are complementary rather than redundant: `pat` skips extension headers at the network position on purpose, so a chain that ran past MAX_NET_EXTENSIONS still matches and only `upper_layer_proto` reports that the transport was never reached. The flow-filter oracle read the protocol out of the same field the classifier did, so correcting one required correcting the other -- and that emptied the suite's "non-transport protocol" bucket, which had been filled entirely by extension-header stacks being misread. `V4ExoticProto` supplies a real one. Signed-off-by: Daniel Noland <daniel@githedgehog.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`Headers::size` counts the VXLAN header, because it is one of them and `headers.deparse` writes it. Both fixtures added it again by hand and sized the buffer for both copies, so eight stray octets sat in front of every inner frame. Decapsulation read them as the start of an Ethernet header and stopped there, which the existing tests could not see: they decapsulate and re-encap to compare *outer* DSCP and ECN, and never look at what came out. Found by writing the first test that does look, which is also what the overlay's refusal of VLAN-tagged frames rests on -- if decapsulation stripped tags rather than handing them on, the check in `IpForwarder` would be dead code. The refusal goes at the decapsulation boundary rather than at the first stage that happens to look, so a tag never becomes an overlay packet: `IcmpErrorHandler` and `FlowLookup` both run before the filters that would otherwise refuse it. Signed-off-by: Daniel Noland <daniel@githedgehog.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The rule is that a stage matches the shape of the chain it acts on rather than reaching in for the fields it wants, so that a layer nobody considered cannot be answered over. Two defects the same week made the case, failing in opposite directions: a field read as something it was not, and a field not read at all. The overlay's refusal of VLAN-tagged frames is a consequence of the rule and not an architectural position. VLAN inside VXLAN is legitimate traffic; nothing here is equipped to carry it, which is a different claim and a smaller one. Written down with what changing the answer would take, because the refusal was phrased in a way that read as permanent -- and the same phrasing would have been wrong about MPLS, or about whatever comes next. Naming the layer in the pattern is how a stage says it has been taught. That the untaught stages keep refusing without anybody listing them is the point. Signed-off-by: Daniel Noland <daniel@githedgehog.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Storing the incremental update made these writes real for the first time, and with them the write-side rules the old no-op never had to obey. A UDP datagram over IPv4 may carry a zero checksum to say the sender computed none. There is no sum there to fold a delta into, so translating a port or an address of such a quote turned the marker into a checksum for a sum nobody took. Zero is spoken for on the other side too: over IPv6 the field is mandatory, so a fold that lands on zero goes out as the other spelling instead. Which rule applies depends on the IP version of the packet the header was quoted from, which `EmbeddedTransport` cannot see, hence the new argument. Signed-off-by: Daniel Noland <daniel@githedgehog.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…cksum `update_checksum_for_address` was added with no caller. Wiring it is the point: TCP, UDP and ICMPv6 are checksummed over a pseudo-header built from the quoted packet's addresses, so rewriting one and stopping there leaves the quote describing an address that is no longer in it. The fold is its own step rather than part of port translation because a mapping that moves only an address never reaches the transport header otherwise -- the port path returns first when there is no port to move. Nothing on the wire catches this. RFC 5508 REQ-3(c) tells a NAT not to validate the quoted transport checksum of an error it receives, so a wrong one survives to the end host that finally reads the quote. Hence an oracle built from scratch: the tests compare against a fixture that computes these checksums rather than folding deltas into them. Signed-off-by: Daniel Noland <daniel@githedgehog.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The same dropped return value as `EmbeddedTransport::update_checksum`, at a site that path does not run through: an ICMP quote's identifier is translated on the header directly, so it kept its own copy of the bug after the shared one was fixed. Signed-off-by: Daniel Noland <daniel@githedgehog.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Standing aside for a live incumbent asks the table to hold nothing, so a full table has no reason to refuse it -- and refusing turned the one answer a racing caller can act on into an error it can only drop a packet over, precisely where races are most likely. `admit` reads the table's length, and `DashMap::len` read-locks every shard, including the one an entry guard holds. Hence the length is read up front and consulted inside the vacant arm alone, rather than the admission check simply moving down. Signed-off-by: Daniel Noland <daniel@githedgehog.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`insert_if_absent` was added with no caller, so the race it describes was still open: two packets of one new flow, handled at the same moment on two cores, each allocated a public tuple and each installed a pair. The second displaced the first's forward half, and the first's reverse half -- keyed on an allocation nothing else can collide with -- stayed live, translating for a tuple that goes back to the pool with the half that was displaced and is then handed to somebody else. The loser keeps nothing. Its reverse half is never inserted, and dropping the pair it built releases the allocation through `AllocatedPort`, so there is no hand-written release path to keep in step. It masquerades with the winner's flow, which is the answer the packet after it would have got anyway. Re-checking and teardown stay with whoever installed the flow. A flow won by another packet was built from an allocator handle of its own, and judging it by this packet's could invalidate a sound flow over an allocator it never used. Signed-off-by: Daniel Noland <daniel@githedgehog.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
No concurrency is needed to reach this one. `process_packet` takes the slow path on the stamp alone -- `get_packet_port_fw_state` reads what `FlowLookup` attached and never falls back to the table, unlike masquerade -- and a burst is stamped before any of it is forwarded. Two packets of one new flow in a single burst therefore both build a pair, and the second tears the first's down. Sequentially that ends consistent, which is why it went unseen: the second pair carries the same translation, so a connection that has been rebuilt underneath its own first packets forwards exactly like one that was left alone. Two cores end it worse. Displacing a flow invalidates its partner, so a loser's reverse insert landing after the winner's strikes down the winner's forward half and then stays live under it -- a pair that answers replies it can no longer forward. Signed-off-by: Daniel Noland <daniel@githedgehog.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`insert_if_absent` decides by asking whether the incumbent `is_active()`, and `FlowInfo::new` starts a flow at `Detached`. Publishing the status after the flow is reachable leaves a window in which it is in the map and still reads as a corpse: a caller landing in that window displaces a live flow and reports `Installed` too. Two winners for one key is what this method exists to prevent. Both then install a reverse flow, and the loser's reverse outlives the allocation it maps -- which is the failure the masquerade and port-forwarding arbitration in the two commits below this one were written to close. `insert_common` does not need it. Its window is harmless because the only reader of the status is `drain_stale`, which also requires the deadline to have passed, and a fresh flow expires in the future. This method is the reader that window became visible to. Set before taking the entry guard rather than under it, which is the obvious answer and a trap: `update_status` writes a `concurrency` atomic, so it is a scheduling point under shuttle, while `dashmap` is a plain dependency whose shard locks shuttle cannot see. A preemption inside a lock the model checker does not know about parks every green thread on the one OS thread -- the suite deadlocks at 0% CPU rather than reporting anything. Nothing observes `val` before it is inserted, so publishing early costs nothing; it only has to be undone on the two paths that do not insert, and those are outside the guard. That is also why there is no regression test. A shuttle property over two threads inserting one key hangs for the same reason; instrumenting `dashmap` is the prerequisite, and the doc comment now says so. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Daniel Noland <daniel@githedgehog.com>
`fix(acl-filter,flow-filter): Match on the protocol the packet carries` converted
the two filters and left `nat`. For IPv6 the IP header's next-header field names
the first extension header rather than the transport, so:
* `is_port_forwardable` is false for a TCP packet with one Hop-by-Hop header in
front of it, and the packet is refused as unsupported traffic;
* `PortFwKey` names the extension header, so the forward and reverse rule
lookups find nothing.
Between them that is a published service being unreachable over IPv6 from any
sender that inserts one extension header -- and the sender chooses. It is the
same failure the `flow-filter` half of that commit describes: dropping traffic a
protocol-restricted expose was configured to carry.
`next_flow_status` had the same read behind a different argument: the old comment
defended it on the grounds that transport headers may be absent with
fragmentation. `upper_layer_proto` walks the chain, fragment header included, so
it answers that rather than trading it away; the fallback keeps the old answer
for a chain that ran past MAX_NET_EXTENSIONS.
`is_icmp` keeps its IP-version check alongside the protocol, so ICMPv4 in IPv6
stays unsupported rather than becoming translatable.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Daniel Noland <daniel@githedgehog.com>
…l too `Match on the protocol the packet carries` converted `get_rule_from_pkt_fw_path` and `_rev_path` and left `can_be_port_forwarded`, which is the path a flow takes before it has any state -- so the failure that commit describes survived in the place it does the most damage. `.net().transport()` is permissive about extension headers, so an IPv6 TCP packet behind a Hop-by-Hop header reaches the qualifying arm and is keyed on Hop-by-Hop. No rule matches, and a published service is unreachable over IPv6 from any sender that inserts one extension header -- which the sender chooses. The regression test asserts on the flow pair rather than the translation, because the pair only exists if a rule was found. Signed-off-by: Daniel Noland <daniel@githedgehog.com>
Signed-off-by: Daniel Noland <daniel@githedgehog.com>
daniel-noland
force-pushed
the
pr/daniel-noland/bench-harness
branch
from
August 28, 2026 17:16
2777622 to
3b54931
Compare
daniel-noland
force-pushed
the
pr/daniel-noland/icmp-and-acl
branch
from
August 28, 2026 17:16
49c0eda to
9e5d2cf
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.