From dda864b6d3c48265afebba3b9453c543cf7f028b Mon Sep 17 00:00:00 2001 From: Steve Shreeve Date: Sun, 11 Oct 2026 05:29:53 -0600 Subject: [PATCH] mdns: refuse a dedicated front door under the service edge, whose mode governs ports 80 and 443 alone --- CHANGELOG.md | 3 +++ docs/20260722-034619-capability-mdns.md | 8 ++++++++ mdns.go | 7 +++++++ mdns_exposure_test.go | 22 ++++++++++++++++++++++ 4 files changed, 40 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bbaace4..6cb6ca0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,9 @@ A hardening round. What an upgrading operator notices first: upstream paths must be absolute and clean. - **The TCP control listeners refuse browsers** (any `Origin`, and on `local` any non-loopback `Host`). +- **Under the service edge a dedicated mdns `listen` is refused:** it + would serve the status page on a port the exposure mode does not + govern. The front door shares the HTTP port, as the seed configures it. - **Caddy 2.11.7**, current quic-go, webtransport-go, and `x/` modules, built with Go 1.27.2; `govulncheck` gates every pull request. diff --git a/docs/20260722-034619-capability-mdns.md b/docs/20260722-034619-capability-mdns.md index 241593f..bb1520a 100644 --- a/docs/20260722-034619-capability-mdns.md +++ b/docs/20260722-034619-capability-mdns.md @@ -324,6 +324,14 @@ unauthorized responses take precedence over this route table: | Caching | `Cache-Control: no-store` on every route | | Encoding | **All dynamic values render as DOM text nodes, never markup.** Today every rendered field is charset-inert by construction (registry name/host validation admits no HTML metacharacters; `canonical` is operator cold config; a conflict rename appends `-`), and this row keeps that true for every future field | +**Dedicated mode is refused under the service edge.** The exposure mode +governs ports 80 and 443: the firewall rule and the socket watch cover +nothing else, so a dedicated `listen` would serve the status page outside +the mode, on every address it names, a global IPv6 one included. When the +service edge has published a mode, a `listen` is a Start error naming the +mode, and the front door shares the HTTP port. Dedicated mode remains for a +hand-run Caddyfile (development and CI), which has no exposure contract. + **Bind failures are hard Start errors (dedicated mode).** If the front-door address is held by another process, Start refuses the config with a precise error naming the address and the conflict. If diff --git a/mdns.go b/mdns.go index 970b3c4..2f40d9b 100644 --- a/mdns.go +++ b/mdns.go @@ -786,6 +786,13 @@ func (a *App) startMdns() error { zap.String("name", ms.Name), ) } else { + // Under the service edge the exposure mode governs ports 80 and 443 + // alone (its firewall rule and its socket watch): a dedicated + // front-door port would serve the status page outside both, on + // every address the listen names, a global IPv6 one included. + if scope := ExposureScope(); scope != "" { + return fmt.Errorf("janus mdns: `listen %s` gives the front door a port of its own, which the %s exposure mode does not govern (it covers ports 80 and 443); under the service edge the front door shares the HTTP port: remove `listen`", ms.Listen, scope) + } if err := a.checkMdnsListenCollision(); err != nil { return err } diff --git a/mdns_exposure_test.go b/mdns_exposure_test.go index 904d076..8f43ad9 100644 --- a/mdns_exposure_test.go +++ b/mdns_exposure_test.go @@ -3,6 +3,7 @@ package janus import ( "net" "net/netip" + "strings" "testing" "github.com/brutella/dnssd" @@ -113,3 +114,24 @@ func TestMdnsStandaloneKeepsAddressPolicy(t *testing.T) { t.Fatalf("standalone address policy changed: %+v", srv) } } + +// Under the service edge (a published exposure mode), a dedicated front +// door is refused in every mode: its port lies outside the ports the mode +// governs, so it would serve the status page past the firewall rule and +// the socket watch, on a global IPv6 address too. +func TestMdnsDedicatedFrontDoorRefusedUnderTheServiceEdge(t *testing.T) { + t.Cleanup(func() { SetExposure("", "", netip.Addr{}) }) + for _, scope := range []string{"localhost", "lan", "wan"} { + lan := netip.Addr{} + iface := "" + if scope == "lan" { + lan, iface = netip.MustParseAddr("10.0.0.211"), "en0" + } + SetExposure(scope, iface, lan) + app := newTestMdnsApp(t, &MdnsSettings{Listen: ":7680"}) + err := app.startMdns() + if err == nil || !strings.Contains(err.Error(), "remove `listen`") || !strings.Contains(err.Error(), scope) { + t.Errorf("%s: want the dedicated front door refused, got %v", scope, err) + } + } +}