8000 fix(minvmd): drop a deny-all box's dns queries outside the zone host-side by norrietaylor · Pull Request #2014 · gominimal/minimal · GitHub
[go: up one dir, main page]

Skip to content

fix(minvmd): drop a deny-all box's dns queries outside the zone host-side - #2014

Merged
norrietaylor merged 3 commits into
mainfrom
fix/deny-all-dns-dropped-host-side
Oct 7, 2026
Merged

norrietaylor merged 3 commits into
mainfrom
fix/deny-all-dns-dropped-host-side

Conversation

@norrietaylor
@norrietaylor norrietaylor commented Oct 6, 2026 •
Copy link
Copy Markdown
Member

Summary

On a VM-backed host, minvmd's host-side egress gate now silently drops a DNS datagram from an own-address deny-all row unless every question in it is an A lookup of a box-zone name. This is the deciding copy, outside the VM escape boundary. The in-VM relay gate (draft #2013) is the precision copy that honest clients hit first and that answers REFUSED. This PR does not depend on #2013's code. It reuses the same predicate semantics.

Requirements

  • Design ruling 2: the host-side silent drop of unmatched names for own-address deny-all rows. No path writes toward the guest. Each drop gets a rate-limited warn line naming the box and the name. The deny-all predicate on BoxRecord comes from host-registered egress. Zone names pass.
  • NET-141, deny-all case: no name outside the box zone leaves a deny-all box, for any record type.
  • Host-side decision for own-address rows: the deciding copy runs in minvmd, before the frame reaches the switch.

The architecture review added two items to this scope, both covered here:

  • Unparseable datagrams are dropped. A UDP port-53 datagram that does not parse as a standard DNS query is dropped. Slice 1's in-VM gate forwards these, so without this change only gvproxy stood in the way.
  • Zone names pass for A only. A zone name passes only when every question in the query is type A. gvproxy answers zone names locally only for A and forwards every other type to the host's upstream resolvers.

Design

  • BoxRecord::is_deny_all() (box_registry.rs): a deny_all field, computed once in BoxRegistry::register from registration.egress. It is true when every allow_* list is present and empty, the same predicate as minimald's classifier::admits_nothing. The field is registration-frozen and never reads the row's guest-filled runtime half. A row with no egress declaration is never deny-all. That includes the node namespace row, which carries every host-address box's frames, so host-address boxes are out of scope by construction.

  • dns_pins::deny_all_refusal(table, pkt, datagram): a pure decision covering UDP frames to port 53 from a deny-all row. It returns Some(refusal), naming the row, the first failing name and its type, when any of these holds:

    • the datagram doesn't parse;
    • it isn't a standard query (QR=0, opcode QUERY);
    • it has no question;
    • any question is not (type A and in the zone).

    Every question is checked. A zone name here follows gvproxy's own test, not the zone's. gvproxy v0.8.9 (pkg/services/dns/dns.go) treats a name as local only on an exact, case-sensitive .min.internal. suffix and never lowercases, so the gate follows the same rule:

    • Exact lowercase suffix. The name's last two wire labels must be exactly the lowercase bytes min and internal, compared as raw label bytes. x.MIN.INTERNAL, x.Min.Internal and x.min.INTERNAL are dropped, because gvproxy forwards them upstream. Labels below the apex may carry any case, so X.min.internal passes.
    • Apex excluded. A zone name sits strictly below the apex. gvproxy forwards the apex min.internal itself upstream, so an A query for it is dropped.
    • Comparing raw labels also drops a single label holding an escaped dot (evil\.min under internal), which a plain string-suffix match would accept.
  • Hook (egress_gate.rs relay_frames_to_switch): after the frame verdict admits the frame and before the write, the gate parses the UDP port-53 datagram once. If deny_all_refusal says drop, the gate calls limiter.warn_deny_all_query and does continue, so the frame is never written and nothing is sent back. observe_query now reuses the same parsed datagram after the write.

  • Warn line: DropLimiter::warn_deny_all_query uses the existing refusal key (box, rule, name) and the bounded table with its flood fallback. The rule is egress-deny-all-dns-query. Fields: source, namespace, name, query_type.

  • TCP 53: the frame verdict already drops TCP to the resolver for a deny-all row, because the NET-079 carve-out is UDP-only. The relay test asserts this.

Tests

  • net::dns_pins::tests::deny_all_row_passes_only_zone_a_lookups:
    • names outside the zone are dropped for A, AAAA, TXT and MX, and the drop names the name;
    • lookalike names and the escaped-dot label are dropped;
    • A lookups pass for web.min.internal, host.min.internal, and X.min.internal / HOST.min.internal (case below the apex);
    • A lookups are dropped for x.MIN.INTERNAL, x.Min.Internal, x.min.INTERNAL, HOST.Min.Internal, the apex min.internal and MIN.INTERNAL (names built with Name::from_ascii so the case reaches the wire);
    • two zone A questions pass together;
    • TXT, MX, SRV and AAAA are dropped for both x.min.internal and host.min.internal;
    • a zone A followed by an outside name is dropped;
    • a zone A followed by a zone TXT is dropped;
    • unparseable, reply-shaped and zero-question datagrams are dropped.
  • net::dns_pins::tests::non_deny_all_rows_are_not_decided: allow-list rows, no-policy rows, closed-names-open-subnets rows, the node namespace and unregistered sources are never decided here.
  • net::egress_gate::tests::deny_all_row_query_outside_zone_dropped_host_side, at relay level:
    • a deny-all row's outside query, sent twice, never reaches the switch;
    • exactly one warn line names source, namespace and name;
    • nothing is written back to the guest;
    • zone A lookups pass byte for byte, host.min.internal included;
    • a zone TXT, a zone-A-first multi-question query, an unparseable datagram and TCP 53 are all dropped;
    • an allow-list row's queries are still forwarded.
  • box_registry::tests::deny_all_predicate_reads_the_host_registration_only: the predicate is set only by the deny-all shape. A guest runtime port report neither lifts it nor sets it.
  • Mutation check: with the continue removed, the relay test fails.

Residuals

  • No e2e leg. Proving that a raw query which bypasses the in-VM gate gets dropped needs root in the guest to send a hand-crafted datagram around the relay, plus a VM lane. That isn't cheap, so this PR relies on the unit and relay-level tests. The epic's e2e proof (REFUSED, TCP 53 dropped, host.min.internal resolves) runs through the in-VM gate.
  • Two copies of admits_nothing. minimald's copy is in classifier.rs and minvmd's is in box_registry.rs. A follow-up moves the predicate onto sessions::EgressPolicy before the deny-all DNS work closes.
  • Slice 1 matches zone names by string suffix, so an escaped-dot label like evil\.min.internal passes it. The host copy here matches by label and drops it. Worth aligning in fix(minimald): refuse a deny-all box's names outside the zone at its relay #2013.
  • Only the question section is read. In a passing zone A query, the additional section (EDNS options) passes unread, the same as in the in-VM gate. That is safe only because gvproxy answers an exact-lowercase zone A query locally and never forwards it, so those sections never leave the host. If gvproxy's zone match or its forwarding rule changes, this residual must be revisited.

Validation

macOS arm64, minvmd scope:

  • just fix: clean.
  • just test: Summary [6.172s] 1007 tests run: 1007 passed, 19 skipped.
  • just clippy: no warnings.
  • just clippy-strict: clippy-strict: clean.

What CI proves

The macOS lane and the Linux lanes build and test minvmd, which runs the new unit and relay-level tests. Nothing here touches minimald or a VM path, and no e2e leg was added.

Summary by CodeRabbit

  • Network Access
    • Deny-all boxes can send standard UDP DNS queries for A records within the min.internal subdomains.
    • DNS queries outside that scope, including non-A lookups and malformed or non-standard queries, are blocked. Other box policies and TCP DNS behavior remain unchanged.

…side

On a VM-backed host the host-side egress gate is the deciding copy of
NET-141's deny-all case for own-address rows. A deny-all row's resolver
carve-out now carries only a standard query whose every question is an
A lookup of a box-zone name, the one thing the switch's resolver answers
without asking upstream. Every other datagram to DNS's port is dropped
before it is written on to the switch: a name outside the zone, a zone
name asked for any other type, a multi-question query hiding either
behind a zone A, and anything that does not parse as a standard query.

The drop is silent toward the guest and says so once per box per name
per interval through the gate's limiter. The predicate lives on
BoxRecord and is derived at registration from the host-registered egress
policy, never from a guest report. Host-address boxes ride the node row,
which is never deny-all, and allow-list rows are untouched.
@coderabbitai
coderabbitai Bot commented Oct 6, 2026 •
Copy link
Copy Markdown
Contributor

Review in Change Stack →

Warning

Review limit reached

  • Run on-demand review

This review includes 3 billable files and costs up to $0.75.

  • Ask an admin to make reviews automatic

Open in CodeRabbit

Reviews can continue after your included limit without a manual trigger. An admin must approve usage-based billing.

Or wait 59 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used all 2 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Essentials
  • Run ID: afd27c50-9780-4c50-8f93-c72da7e1fdd2
📥 Commits

Reviewing files that changed from the base of the PR and between 684af57 and 89633ce.

📒 Files selected for processing (3)
  • crates/minvmd/src/box_registry.rs
  • crates/minvmd/src/net/dns_pins.rs
  • crates/minvmd/src/net/egress_gate.rs

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Essentials
  • Run ID: cd8e15ff-acaf-4927-9d2b-fcdad0f748d3
📥 Commits

Reviewing files that changed from the base of the PR and between e7533f5 and 684af57.

📒 Files selected for processing (3)
  • crates/minvmd/src/box_registry.rs
  • crates/minvmd/src/net/dns_pins.rs
  • crates/minvmd/src/net/egress_gate.rs

Included review availability: This review used your included allowance. Your plan provides up to 5 included reviews per hour; 1 remain after this review.


📝 Walkthrough

Walkthrough

Changes

Deny-all DNS handling

Layer / File(s) Summary
Derive deny-all status
crates/minvmd/src/box_registry.rs
Registration sets BoxRecord’s deny_all flag when all three allow dimensions are present and empty. The flag is exposed through is_deny_all() and included in equality.
Evaluate deny-all DNS queries
crates/minvmd/src/net/dns_pins.rs
DNS checks refuse malformed or non-standard queries, empty question sets, and questions that are not A lookups strictly below the lowercase zone apex. Tests cover query cases and rows outside the deny-all policy.
Enforce and log DNS refusals
crates/minvmd/src/net/egress_gate.rs
The relay checks admitted UDP DNS datagrams before forwarding, rate-limits refusal warnings, and reuses the parsed datagram for query-pin observation. Relay tests cover refused and permitted traffic.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Bug fix

Sequence Diagram(s)

sequenceDiagram
  participant EgressRelay
  participant deny_all_refusal
  participant BoxRecord
  participant DropLimiter
  EgressRelay->>deny_all_refusal: Check UDP DNS datagram
  deny_all_refusal->>BoxRecord: Check registered deny-all status
  deny_all_refusal-->>EgressRelay: Return refusal or no refusal
  alt Query refused
    EgressRelay->>DropLimiter: Rate-limit warning
  else Query accepted
    EgressRelay->>EgressRelay: Continue forwarding and pin observation
  end
Loading

Suggested reviewers: 0chroma

Merge Risk: ⚪ Minimal · up to 684af

No actionable issue is established for the DNS gate; it is mergeable after normal checks.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: minvmd drops deny-all boxes’ DNS queries outside the zone on the host side. It uses a Conventional Commit format and has no trailing period.
Description check ✅ Passed The description provides a detailed summary, design, test coverage, validation results, and residuals. It omits the template’s Checklist section, but the rest of the description is complete.
Docstring Coverage ✅ Passed Docstring coverage is 95.00% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 20 functions across 2 files. (1 skipped: 1 …
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

A rabbit checks each name at night,
And lets zone A queries take flight.
Odd questions stop before they roam,
While careful logs record them home.
The parsed query follows through,
And carrots wait for tests that pass.

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

The switch's resolver answers a name locally only on an exact,
case-sensitive `.min.internal.` suffix and forwards everything else
upstream, the apex `min.internal` itself and any other casing of the
apex labels included. The deny-all gate matched the zone case-insensitively
and admitted the apex, so such a query passed the gate and was forwarded.

A deny-all zone name now sits strictly below the apex and ends in the raw
wire labels `min` and `internal`, byte for byte in lowercase. The labels
below the apex may carry any case.
@norrietaylor
norrietaylor marked this pull request as ready for review October 6, 2026 07:09
@norrietaylor
norrietaylor requested a review from a team as a code owner October 6, 2026 07:09
@minimal-foundry
minimal-foundry Bot commented Oct 6, 2026 •
Copy link
Copy Markdown
Contributor

Approved: no findings at 89633ced704b.

Nothing to do.

Clobber: none: git log on both net/dns_pins.rs and net/egress_gate.rs shows only this PR's own two commits ahead of the prior T74 host-side-DNS work; nothing recent is reverted.

@minimal-foundry minimal-foundry Bot left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

minimal-review — approved

Verdict APPROVE — No findings.

@minimal-foundry minimal-foundry Bot added the review:approved minimal-review approved the current head label Oct 6, 2026
@norrietaylor
Copy link
Copy Markdown
Member Author

ARCH PASS

@norrietaylor
norrietaylor merged commit e44ae89 into main Oct 7, 2026
35 checks passed
@norrietaylor
norrietaylor deleted the fix/deny-all-dns-dropped-host-side branch October 7, 2026 01:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

review:approved minimal-review approved the current head

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

0