Skip to content

Test: Pin the zoned NAT64 refusal and correct the zero-value Guard doc - #1556

Merged
mrsabath merged 8 commits into
mainfrom
nat64-pin-followup
Oct 29, 2026
Merged

mrsabath merged 8 commits into
mainfrom
nat64-pin-followup

Conversation

@mrsabath

@mrsabath mrsabath commented Oct 29, 2026 •

Copy link
Copy Markdown
Member

Follow-up to #1539, addressing @huang195's two round-2 comments. Both were marked
non-blocking, so #1539 merged on its own approval and these land separately.

Now based on main. This originally stacked on fix/sbx-ssrf-guard (#1539),
because the test pins code #1539 introduces and so could not compile against main
until it merged. #1539 has since merged and this branch is retargeted: it is 0
commits behind main, and the net diff against main is exactly the two files
below.

The NAT64 pin was a real gap in #1539

#1539 moved the embedded-v4 decode onto the zone-stripped address
(nat64Embedded(checkAddr)), and I described that in the review thread, but
nothing tested it. @huang195 spotted that reverting it leaves every test in
egressguard and forwardproxy green.

Confirmed, and confirmed what it costs. With the decode's input reverted to the
zoned address:

Address Reverted Fixed
[64:ff9b::7f00:1%eth0]:80 (127.0.0.1) allowed refused
[64:ff9b::a9fe:a9fe%eth0]:80 (169.254.169.254) allowed refused
[64:ff9b::7f00:1]:80 (unzoned control) refused refused
[64:ff9b::a9fe:a9fe]:80 (unzoned control) refused refused

So a written zone reached loopback and the cloud metadata service — and the
metadata address is never-permitted space, which no permit_cidrs setting is
supposed to be able to open.

Both addresses are now rows in TestCheckAddr_ZonedV6StillRefused, with the
measurement in the comment. The zone-strip and the NAT64 decode are one fix,
not two
: nat64Prefix.Contains is false for a zoned address, so before the
zone was stripped the decode never ran on one. The test says that explicitly, so
a later edit cannot quietly separate them.

The zero-value Guard doc was wrong

It read "the zero value refuses nothing and dials nothing", which contradicted
the fallback comment added in the same commit ("a zero-value Guard … still asks
the host"). @huang195 was right that the doc is the one that's wrong. Verified:

Input to a zero Guard{} Result
127.0.0.1:443 refused
10.0.0.5:443 refused
169.254.169.254:80 refused
93.184.216.34:443 (public) allowed
example.com:443 (name) egressguard: no resolver configured

check runs normally; an empty permit list opens nothing. Only names fail, on
the nil resolver. The doc now says "checks literal addresses but cannot resolve
names" and spells out all three behaviours. The fallback comment drops its
"built before this field existed" clause — a struct cannot predate its own field
at runtime, as the review noted.

Test plan

  • The two new rows fail against the pre-fix decode input (= nil, want a refusal on both) and pass after
  • Full core suite: 73 packages, 0 failures
  • go vet and gofmt clean

No production behaviour changes here — #1539 already fixed the guard. This adds
the test that keeps it fixed, and corrects a doc comment.

Refs #1484

Assisted-By: Claude (Anthropic AI) noreply@anthropic.com

Arms an opt-in SSRF dial guard on every outbound dial the forward proxy
makes: CONNECT tunnels, plain and bridged requests, transparent redirects,
and skip_hosts traffic too — a bypass must not become an SSRF bypass. Each
destination is resolved once, refused with 403 destination_private when the
address is loopback, link-local (including 169.254.169.254), RFC 1918 /
unique-local space, or one of this host's own interface addresses and no
permit covers it, and then dialed at the checked address, so DNS rebinding
cannot re-answer between the check and the connect. Link-local, unspecified
and multicast addresses are never permitted, whatever permit_cidrs says.

Off by default: a sidecar's ordinary upstreams — Keycloak, in-cluster
services, a laptop's local model — live in exactly the refused space, so
defaulting this on would break every existing deployment.

- core/listener/egressguard: the guard itself (CheckAddr / DialContext /
  DialTimeout, typed Refusal), resolver injectable so the tests pin the
  resolve-once property against a counting stub
- forwardproxy: WithEgressGuard option (NewServer signature unchanged);
  under mTLS the guard sits inside the dialer so a refused address never
  gets a TLS ClientHello; handleConnect and the transparent listener check
  before the dial and reuse the checked address for the bridge fall-open
  re-dial; serveOutbound renders a Refusal as the listener's standard 403
  rejection with error.kind destination_private
- pipeline: SessionEvent.DialAddr on the wire (dialAddr, omitempty) — the
  audit trail pairing the name the client asked for with the address
  dialed; new tunnelReason "dest-private" (within agentop's 18-char PLUGIN
  column cap)
- tlsbridge: WithDialContext so the bridge's re-origination client dials
  through the guard — the bridge must not become the path around it
- config: listener.egress_guard {enabled, permit_cidrs}; a listener.*
  field, so it inherits the restart-on-reload rule

The moca conformance shapes are pinned by tests: private_address_403
(CONNECT and GET to loopback, upstream never dialed),
metadata_address_403_even_if_permitted (169.254.169.254 under 0.0.0.0/0),
and the rebinding case's Cortex half — resolve exactly once, dial the
first checked answer, open row carries dialAddr.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Mariusz Sabath <mrsabath@gmail.com>
Self-review of #1539, both accuracy rather than behavior:

- SessionEvent.DialAddr's comment claimed the field is absent "on events
  that dialed nothing", but a guarded plain or bridged request dials
  through the guarded transport and records no dialAddr either — only a
  tunnel's open row carries it. Say that instead: present only on a
  tunnel's open row, absent when the guard is off. session-api.md already
  said exactly this.
- The "egress guard on" boot log fired wherever the guard was configured,
  including a reverse-only deployment where no forward proxy exists to
  enforce it. The guard is still built before the TLS bridge (the bridge's
  re-origination client consumes it), but the announcement now logs in the
  forward-role block, where it is true.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Mariusz Sabath <mrsabath@gmail.com>
#1484)

CodeQL on #1539 flagged "Log entries created from user input" at the
serveOutbound refusal Debug: r.Host and r.Method are client-controlled,
and a control rune forged into either would forge a log entry — the same
CWE-150 shape the User-Agent path already guards against.

- Rename pipeline.sanitizeUA to pipeline.SanitizeControlRunes. The rule
  (C0/DEL/C1/bidi/zero-width and invalid UTF-8 replaced with U+FFFD) and
  its rationale are unchanged; only the name now says what it is: the
  rule applies to any client-controlled string headed for a surface a
  terminal or a file renders, not only a User-Agent. Exported so the
  listeners can use the one copy rather than grow a third loop beside
  the ledger's sanitizeLabel.
- Apply it at every log site this PR added: the serveOutbound refusal
  Debug (Host, Method), handleConnect's refusal Warn (Host) and
  resolution-failure Warn (Host and the error string, which embeds the
  name via net.DNSError), and the transparent listener's refusal Warn
  (host — sniffed off the client's own bytes as SNI or a Host header).
  Values that are not client-controlled — the guard's parsed address,
  the kernel's SO_ORIGINAL_DST literal — stay raw.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Mariusz Sabath <mrsabath@gmail.com>
Hai's review of #1539, all six findings:

- countsAsError only exempted upgrade_declined, so /v1/usage counted the
  guard's 403 destination_private as an error while four places of
  documentation said it does not. The kind is now exempted alongside
  upgrade_declined — a refusal is the policy working, not the proxy
  failing — with a test next to the declined-upgrade one that pins the
  distinction from an origin's own 403.
- The "host's own addresses" check tested the interface's on-link PREFIX,
  refusing every LAN neighbour and, on a cloud VM with a public address
  on a large on-link prefix, ordinary public upstreams. hostOwns now
  compares the interface's address itself; a test pins the distinction
  with the exact prefixes from the review.
- fd00:ec2::254 (AWS metadata, IPv6 form) is ULA and so sat in the
  permit-able set; it is never-permitted now. 100.64.0.0/10 (RFC 6598
  CGNAT, home of Alibaba's metadata service) is refused like private
  space and permit-able like it. A well-known-prefix NAT64 address
  (64:ff9b::/96) is checked by the IPv4 it embeds, so the wrapped
  127.0.0.1 is refused while the v6 form stays the dial target. All
  three are named in the package comment and the docs.
- DialTimeout had no caller; deleted.
- The port is parsed with net.LookupPort before the resolve: a
  service-name port ("example.com:https") dials again instead of 502ing,
  and a bad port costs no DNS lookup.
- The SanitizeControlRunes doc block opened with "sanitizeUA replaces…",
  a function that no longer exists; the rename paragraph now leads the
  block and the seven other comment references follow the new name.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Mariusz Sabath <mrsabath@gmail.com>
Three conflicts, all additive — main and this branch each appended to the same
event-schema prose and the same projection count.

- `core/sessionapi/summary_test.go`: main added `Ttfb` (#1251) and this branch
  added `DialAddr`, each as the 29th kept field. Both are kept, so the count is
  30 and both comments stand.
- `CLAUDE.md`: main rewrote the `bytesUp`/`bytesDown` bullet (a relayed response
  is counted now, with extproc's body-free arm the one exception) and added
  `ttfbMs`; this branch added the guard's `dest-private` sentence to
  `tunnelReason` and the `dialAddr` bullet. Took main's rewrite verbatim and
  re-applied both guard additions on top.
- `docs/session-api.md`: same shape — main's `ttfbMs` bullet plus this branch's
  `destination_private` sentences.

Verified: CGO_ENABLED=0 go build ./... clean; sessionapi, pipeline, egressguard,
forwardproxy and cost/usage tests pass.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Mariusz Sabath <mrsabath@gmail.com>
…rent refusal (#1484)

Two review findings from the CodeRabbit pass, each confirmed against the code
before fixing and each with a test that fails without its fix.

**A zoned IPv6 literal reached the host's own address.** netip.Addr equality
includes the zone, and net.InterfaceAddrs reports the host's addresses unzoned,
so `[2001:db8::5%eth0]` compared unequal to the `2001:db8::5` the host owns and
walked past hostOwns — writing a zone the host itself would ignore was enough.
CheckAddr now drops the zone for the CHECK and keeps it for the DIAL, since a
link-scoped destination needs it to pick an interface.

hostOwns was the whole exposure: loopback, link-local, private and CGNAT are
range predicates, and a zone does not move an address out of a range (pinned by
TestCheckAddr_ZonedV6StillRefused). Equality is the one place a zone bites.
Stripping it also fixes a false refusal in the other direction — a permitted
zoned ULA was being refused, because Prefix.Contains is false for any zoned
address, so no permit could ever match one.

The NAT64 decode now runs on the zone-stripped address too, which is strictly
better: nat64Prefix.Contains was false for a zoned address, so a zoned NAT64
form previously skipped the embedded-v4 check entirely.

Tests go through CheckAddr, not hostOwns: a test that strips the zone itself
before calling hostOwns asserts the fix rather than the path, and passes against
the unfixed code — I wrote that version first and it did. Verified both new
tests fail before the fix (`= "[2001:db8::5%eth0]:443" with no refusal`) and
pass after. `ifaceAddrs` joins `resolve` as a test seam, because the host-owned
rule is the one refusal that depends on the machine the test runs on — on this
laptop the only host-owned non-private address is IPv4, which cannot carry a
zone, so the bug was untestable without it.

**The transparent listener's refusal reported an error to Finishers.**
handleConnect sets pctx.StatusCode = 403 before its tl.close; this path did
not, and OutcomeFromContext reads a zero StatusCode as OutcomeError. So the
three listener paths disagreed about the same refusal, this one reporting a
failure for what CLAUDE.md, docs/session-api.md and framework-architecture.md
all call a delivered 403 the Finisher sees as an allow. Nothing noticed because
no status reaches the client on this path — it speaks no HTTP to the agent — so
the field exists only for the Finishers and the row.

Verified: full core suite, 73 packages, 0 failures. go vet and gofmt clean on
every touched package. agentop's failures (launchd/systemd/service-manager and
two server-picker TUI tests) reproduce identically on clean main — sandbox
limitations, not this change.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Mariusz Sabath <mrsabath@gmail.com>
…1484)

Both round-2 review comments. Neither blocks, but the first is a real coverage
gap in my own previous commit.

**The NAT64 half of the zone fix was unpinned.** 3873b1c moved the
embedded-v4 decode onto the zone-stripped address, and I said so in the PR
thread, but nothing tested it: reverting `nat64Embedded(checkAddr)` to
`nat64Embedded(dialAddr)` left every test in egressguard and forwardproxy
green. Confirmed, and confirmed what it costs — with the decode's input
reverted, `[64:ff9b::7f00:1%eth0]:80` and `[64:ff9b::a9fe:a9fe%eth0]:80` are
both ALLOWED, which is loopback and the cloud metadata service, while their
unzoned forms are refused. The metadata one is never-permitted space, so a
written zone was enough to reach it.

Both addresses are now rows in `TestCheckAddr_ZonedV6StillRefused`, with the
measurement in the comment. The two halves are one fix rather than two —
`nat64Prefix.Contains` is false for a zoned address, so before the zone was
stripped the decode never ran on one — and the test now says that, so a later
edit cannot quietly separate them.

**The zero-value Guard doc said the opposite of the truth**, and contradicted
the fallback comment I added in the same commit. A zero `Guard{}` does refuse
a loopback, private or metadata LITERAL: `check` runs and an empty permit list
opens nothing. Only names fail, on the nil resolver. Verified all four cases.
The doc now says "checks literal addresses but cannot resolve names" and names
the public-literal and name behaviours; the fallback comment drops its "built
before this field existed" clause, which was describing something that cannot
happen at runtime.

Verified: full core suite, 73 packages, 0 failures; go vet and gofmt clean. The
two new rows fail against the pre-fix decode input and pass after.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Mariusz Sabath <mrsabath@gmail.com>
@mrsabath
mrsabath requested a review from a team as a code owner October 29, 2026 15:22
@coderabbitai

coderabbitai Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 31 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 27edadf4-2622-400e-a98c-b08621e3d04e
Commits

Reviewing files that changed from the base of the PR and between ab5f01f and b380c55.

Files selected for processing: 2
  • core/listener/egressguard/guard.go
  • core/listener/egressguard/guard_test.go
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Base automatically changed from fix/sbx-ssrf-guard to main October 29, 2026 15:40
Resolves the conflict with main, which has since merged #1539 (the forward
proxy dial refusal) and #1537 (the plaintext-credential refusal). Both
conflicts were add/add in core/listener/egressguard and both resolved to this
branch's side, which is the change the PR exists to make:

- guard.go: the corrected zero-value Guard doc. The old "refuses nothing and
  dials nothing" text on main is what this branch set out to fix, so main's
  side is the stale one.
- guard_test.go: the two zoned NAT64 rows and the comment recording what they
  measured. Additive; main's side simply predates them.

The branch was cut before #1537, so merging it without this would have deleted
that feature — the plaintext gate, its three test files, the staticinject
plugin changes and its doc, about 600 lines. Those merged cleanly and are kept;
the diff against main is now only the 24 lines this PR actually adds.

Verified: full core suite with CGO_ENABLED=0, 0 failures, including the
staticinject, extproc and reverseproxy packages #1537 brought in; go vet and
gofmt clean on the touched packages. TestCheckAddr_ZonedV6StillRefused passes
with the two NAT64 rows.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Mariusz Sabath <mrsabath@gmail.com>

@Alan-Cha Alan-Cha left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Summary

Verified both changes on the PR head. I reverted only the NAT64 decode's input to the zoned address: the two new rows fail ([64:ff9b::7f00:1%eth0] and [64:ff9b::a9fe:a9fe%eth0] allowed), and with them removed every other egressguard and forwardproxy test stays green, so the rows close a real gap. Reverting the zone strip also trips them, which bears out the "one fix, not two" comment. The new zero-value doc matches a zero Guard{}: loopback, private and metadata literals refused, a public literal allowed, a name failing with no resolver configured.

Author: mrsabath (MEMBER — maintainer)
Areas reviewed: Go (core/listener/egressguard)
Agent/IDE config (.claude/.vscode): none
Commits: 8 commits, all signed-off: yes
CI status: passing

@mrsabath
mrsabath merged commit 7454925 into main Oct 29, 2026
30 checks passed
@github-project-automation github-project-automation Bot moved this from New/ToDo to Done in Rossoctl Issue Prioritization Oct 29, 2026
@mrsabath
mrsabath deleted the nat64-pin-followup branch October 29, 2026 18:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants