From 142aa2da17792612c24c2d31bad5d1e9b7d9c9c2 Mon Sep 17 00:00:00 2001 From: marcoome Date: Mon, 21 Sep 2026 13:01:25 +0200 Subject: [PATCH] fix(hosts): the node-side resolve switch is super_admin only The switch waives panel-side resolution, and with it the screen that checks where a backend actually points. It was settable by any account that could edit a route, so a scope-restricted admin could turn off screening for their own route. Same bar as skipping upstream TLS verification now. The policy lives in one function rather than two literal role comparisons, so the create and edit forms cannot drift apart. --- CHANGELOG.md | 12 ++++++ README.md | 2 +- deploy/docker-compose.lite.yml | 6 +-- deploy/docker-compose.yml | 8 ++-- deploy/node-agent/docker-compose.example.yml | 2 +- deploy/portainer-external-db.yml | 6 +-- deploy/remote-node/docker-compose.yml | 2 +- internal/httpserver/handlers/admin.go | 2 +- .../httpserver/handlers/admin_captcha_test.go | 6 +-- internal/httpserver/handlers/admin_hosts.go | 18 +++++++- .../handlers/admin_hosts_resolve_test.go | 14 +++++++ .../handlers/admin_reseller_plans.go | 10 ++++- .../httpserver/handlers/admin_resellers.go | 2 +- internal/httpserver/handlers/api_docs.go | 2 +- internal/httpserver/handlers/api_v1.go | 2 +- internal/httpserver/handlers/auth.go | 1 - internal/httpserver/handlers/client.go | 41 +++++++++---------- internal/httpserver/handlers/portal.go | 6 +-- internal/view/admin/hosts_edit.html.tmpl | 2 +- internal/view/admin/hosts_new.html.tmpl | 2 +- 20 files changed, 96 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bf057ea..7d066146 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,18 @@ is deliberately left as published. (`caddyapi.ScreenDialTarget`): Caddy also accepts socket and file-descriptor upstream forms, which no address-based screen covers. +## [1.7.1] - 2026-09-21 + +### Security + +- **The per-route "backend is resolved on the node" switch was available to + scope-restricted accounts.** That switch waives panel-side resolution, and + with it the screen that checks where a backend actually points - so an + account limited to its own routes could turn its own screening off. It now + requires super_admin, the same bar as skipping upstream TLS verification. + Existing routes are unaffected: tunnel-bound routes were marked + automatically and keep working, and nothing already saved is re-evaluated. + ## [1.7.0] - 2026-09-21 Follow-up to the 1.6.0 remediation: an independent review of that work found diff --git a/README.md b/README.md index 259bb75e..c4c88e94 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ yourself, with WireGuard tunnels to origin and per-node failover. The control plane configures every node over WireGuard, drives Let's Encrypt issuance, runs a WAF + GeoIP, and surfaces traffic stats. -**Status:** v1.7.0. Stack: Go 1.26.3, chi, MariaDB/MySQL or SQLite, Redis, Caddy 2.11. +**Status:** v1.7.1. Stack: Go 1.26.3, chi, MariaDB/MySQL or SQLite, Redis, Caddy 2.11. Single binary ~21 MB image, ~28 MB idle RAM. ## Use cases diff --git a/deploy/docker-compose.lite.yml b/deploy/docker-compose.lite.yml index 351de372..4ce40d6e 100644 --- a/deploy/docker-compose.lite.yml +++ b/deploy/docker-compose.lite.yml @@ -22,7 +22,7 @@ services: app: - image: ${IMAGE_APP:-ghcr.io/host-yt/caddy-proxy-manager:1.7.0} + image: ${IMAGE_APP:-ghcr.io/host-yt/caddy-proxy-manager:1.7.1} pull_policy: ${IMAGE_PULL_POLICY:-if_not_present} restart: unless-stopped healthcheck: @@ -170,7 +170,7 @@ services: # access-log dashboard + analytics rollups) and runs the customer tunnel. # No WAF audit log in lite (WAF is off); the access log is enough. hpg-node-agent: - image: ${IMAGE_NODE_AGENT:-ghcr.io/host-yt/caddy-proxy-manager-node-agent:1.7.0} + image: ${IMAGE_NODE_AGENT:-ghcr.io/host-yt/caddy-proxy-manager-node-agent:1.7.1} pull_policy: ${IMAGE_PULL_POLICY:-if_not_present} restart: unless-stopped network_mode: host @@ -202,7 +202,7 @@ services: - node wireguard: - image: ${IMAGE_WG:-ghcr.io/host-yt/caddy-proxy-manager-wg:1.7.0} + image: ${IMAGE_WG:-ghcr.io/host-yt/caddy-proxy-manager-wg:1.7.1} pull_policy: ${IMAGE_PULL_POLICY:-if_not_present} restart: unless-stopped cap_add: diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 4a7ac57b..322696dc 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -32,7 +32,7 @@ services: - internal app: - image: ${IMAGE_APP:-ghcr.io/host-yt/caddy-proxy-manager:1.7.0} + image: ${IMAGE_APP:-ghcr.io/host-yt/caddy-proxy-manager:1.7.1} pull_policy: ${IMAGE_PULL_POLICY:-if_not_present} restart: unless-stopped # App runs as distroless nonroot; block any setuid privilege escalation. @@ -166,7 +166,7 @@ services: # can flip CACHE_HANDLER_AVAILABLE=1 and per-route cache_enabled does # real origin caching. CI pushes deploy/caddy/Dockerfile to ghcr; local # dev falls back to `build:` when the image isn't pullable. - image: ${IMAGE_CADDY:-ghcr.io/host-yt/caddy-proxy-manager-edge:1.7.0} + image: ${IMAGE_CADDY:-ghcr.io/host-yt/caddy-proxy-manager-edge:1.7.1} build: context: ./caddy dockerfile: Dockerfile @@ -227,7 +227,7 @@ services: # file is written but nobody ships it - dashboard stays empty. # Needs a one-shot node token + WG key from the panel tunnel-enable flash. hpg-node-agent: - image: ${IMAGE_NODE_AGENT:-ghcr.io/host-yt/caddy-proxy-manager-node-agent:1.7.0} + image: ${IMAGE_NODE_AGENT:-ghcr.io/host-yt/caddy-proxy-manager-node-agent:1.7.1} pull_policy: ${IMAGE_PULL_POLICY:-if_not_present} restart: unless-stopped network_mode: host @@ -274,7 +274,7 @@ services: # the kernel module path may not exist; this service is intended for # the Linux VPS where the manager actually runs in production. wireguard: - image: ${IMAGE_WG:-ghcr.io/host-yt/caddy-proxy-manager-wg:1.7.0} + image: ${IMAGE_WG:-ghcr.io/host-yt/caddy-proxy-manager-wg:1.7.1} pull_policy: ${IMAGE_PULL_POLICY:-if_not_present} restart: unless-stopped cap_add: diff --git a/deploy/node-agent/docker-compose.example.yml b/deploy/node-agent/docker-compose.example.yml index 072240fe..1fd5bae1 100644 --- a/deploy/node-agent/docker-compose.example.yml +++ b/deploy/node-agent/docker-compose.example.yml @@ -12,7 +12,7 @@ version: "3.9" services: hpg-node-agent: - image: ghcr.io/host-yt/caddy-proxy-manager-node-agent:1.7.0 + image: ghcr.io/host-yt/caddy-proxy-manager-node-agent:1.7.1 restart: unless-stopped network_mode: host # needed for wg-tun0 + nftables + UDP listen cap_add: diff --git a/deploy/portainer-external-db.yml b/deploy/portainer-external-db.yml index c1eca42c..c4a6e992 100644 --- a/deploy/portainer-external-db.yml +++ b/deploy/portainer-external-db.yml @@ -27,7 +27,7 @@ services: - internal app: - image: ghcr.io/host-yt/caddy-proxy-manager:1.7.0 + image: ghcr.io/host-yt/caddy-proxy-manager:1.7.1 pull_policy: if_not_present restart: unless-stopped healthcheck: @@ -103,7 +103,7 @@ services: retries: 5 caddy: - image: ghcr.io/host-yt/caddy-proxy-manager-edge:1.7.0 + image: ghcr.io/host-yt/caddy-proxy-manager-edge:1.7.1 pull_policy: if_not_present restart: unless-stopped # Falls back to the socket file when the admin endpoint has no TCP port: @@ -141,7 +141,7 @@ services: - internal hpg-node-agent: - image: ghcr.io/host-yt/caddy-proxy-manager-node-agent:1.7.0 + image: ghcr.io/host-yt/caddy-proxy-manager-node-agent:1.7.1 pull_policy: if_not_present restart: unless-stopped network_mode: host diff --git a/deploy/remote-node/docker-compose.yml b/deploy/remote-node/docker-compose.yml index f4b91e31..6ca15006 100644 --- a/deploy/remote-node/docker-compose.yml +++ b/deploy/remote-node/docker-compose.yml @@ -28,7 +28,7 @@ services: # no cache-handler, coraza, caddy-l4, rate_limit or maxmind-geolocation, # so cache_enabled / WAF / streams / GeoIP routes are rejected or silently # absent there. Override with IMAGE_CADDY=caddy:2.11.4 for a plain node. - image: ${IMAGE_CADDY:-ghcr.io/host-yt/caddy-proxy-manager-edge:1.7.0} + image: ${IMAGE_CADDY:-ghcr.io/host-yt/caddy-proxy-manager-edge:1.7.1} restart: unless-stopped # SEC-002: :2019 is Caddy's admin API and it authenticates NOTHING, so it # is published on host loopback only. The host-networked hpg-node-agent diff --git a/internal/httpserver/handlers/admin.go b/internal/httpserver/handlers/admin.go index ebd113ff..7239c143 100644 --- a/internal/httpserver/handlers/admin.go +++ b/internal/httpserver/handlers/admin.go @@ -2232,7 +2232,7 @@ func (h *AdminHandlers) ClientsList(w http.ResponseWriter, r *http.Request) { JOIN routes r ON r.id = lr.route_id JOIN services s ON s.id = r.service_id WHERE s.client_id IN (` + strings.Join(ph, ",") + `) - AND lr.bucket_start >= `+store.DateSub(30, "DAY")+` + AND lr.bucket_start >= ` + store.DateSub(30, "DAY") + ` GROUP BY s.client_id` bwRows, bwErr := db.QueryContext(ctx, bwSQL, ids...) if bwErr == nil { diff --git a/internal/httpserver/handlers/admin_captcha_test.go b/internal/httpserver/handlers/admin_captcha_test.go index b6994780..16e51658 100644 --- a/internal/httpserver/handlers/admin_captcha_test.go +++ b/internal/httpserver/handlers/admin_captcha_test.go @@ -7,10 +7,10 @@ import "testing" // a secret already exists. func TestCaptchaSecretRequired(t *testing.T) { cases := []struct { - name string - newProvider, curProvider string + name string + newProvider, curProvider string hasSecret, secretProvided bool - want bool + want bool }{ {"fresh secret always ok", "hcaptcha", "turnstile", true, true, false}, {"switch provider, no new secret -> required", "hcaptcha", "turnstile", true, false, true}, diff --git a/internal/httpserver/handlers/admin_hosts.go b/internal/httpserver/handlers/admin_hosts.go index e16bbae9..d318f072 100644 --- a/internal/httpserver/handlers/admin_hosts.go +++ b/internal/httpserver/handlers/admin_hosts.go @@ -715,9 +715,15 @@ func (h *AdminHandlers) HostsCreate(w http.ResponseWriter, r *http.Request) { WildcardEnabled: r.FormValue("wildcard_enabled") == "1", WildcardZone: strings.ToLower(strings.TrimSpace(r.FormValue("wildcard_zone"))), ViaWGPeerID: strings.TrimSpace(r.FormValue("via_wg_peer_id")), - ResolveNodeSide: r.FormValue("backend_resolve_node_side") == "1", + ResolveNodeSide: r.FormValue("backend_resolve_node_side") == "1" && canWaivePanelResolution(sess.Role), RequireClientCert: r.FormValue("require_client_cert") == "1", } + // Waiving panel-side resolution means the target is never screened against + // a resolved address, so it stays with the unrestricted role. + if r.FormValue("backend_resolve_node_side") == "1" && !canWaivePanelResolution(sess.Role) { + h.renderHostsNewErr(w, r, form, "resolving the backend on the node requires super_admin") + return + } form.MTLSCAID, _ = strconv.ParseInt(r.FormValue("mtls_ca_id"), 10, 64) if !form.RequireClientCert { form.MTLSCAID = 0 // no enforcement, no anchor - mirrors the edit path @@ -3669,6 +3675,11 @@ func (h *AdminHandlers) HostsUpdate(w http.ResponseWriter, r *http.Request) { } viaPeerID, _ := strconv.ParseInt(r.FormValue("via_wg_peer_id"), 10, 64) resolveNodeSide := r.FormValue("backend_resolve_node_side") == "1" + if resolveNodeSide && (sess == nil || !canWaivePanelResolution(sess.Role)) { + editPath := "/admin/hosts/" + strconv.FormatInt(id, 10) + "/edit" + redirectWithFlash(w, r, editPath, "", "resolving the backend on the node requires super_admin") + return + } if external { editPath := "/admin/hosts/" + strconv.FormatInt(id, 10) + "/edit" // MUTUAL EXCLUSION: an external route must NOT be bound to a WG peer - @@ -5013,6 +5024,11 @@ func screenBackendWith(ctx context.Context, infra *streamguard.InfraTargets, hos // unresolvedHint turns a panel-side resolution failure into the one action // that actually unblocks the operator, instead of a dead end. +// canWaivePanelResolution gates the "backend is resolved on the node" switch. +// Waiving panel-side resolution means no resolved address is ever screened, so +// it stays with the unrestricted role rather than any scoped admin. +func canWaivePanelResolution(role string) bool { return role == "super_admin" } + func unresolvedHint(err error, fallback string) string { if errors.Is(err, streamguard.ErrUnresolved) { return "backend hostname does not resolve from the panel - fix DNS, or tick " + diff --git a/internal/httpserver/handlers/admin_hosts_resolve_test.go b/internal/httpserver/handlers/admin_hosts_resolve_test.go index 0dd8c0d2..e2820e4d 100644 --- a/internal/httpserver/handlers/admin_hosts_resolve_test.go +++ b/internal/httpserver/handlers/admin_hosts_resolve_test.go @@ -54,3 +54,17 @@ func TestUnresolvedHint(t *testing.T) { t.Errorf("other errors keep their message, got %q", got) } } + +// The node-side resolve switch waives the resolved-address screen, so it must +// stay with the unrestricted role. A scoped admin granting it to themselves +// would put an unscreened name back in the dial path. +func TestCanWaivePanelResolution(t *testing.T) { + if !canWaivePanelResolution("super_admin") { + t.Error("super_admin must be able to set it") + } + for _, role := range []string{"admin", "reseller", "client", "viewer", "", "SUPER_ADMIN"} { + if canWaivePanelResolution(role) { + t.Errorf("role %q must not be able to waive panel-side resolution", role) + } + } +} diff --git a/internal/httpserver/handlers/admin_reseller_plans.go b/internal/httpserver/handlers/admin_reseller_plans.go index f5f0bb1c..b614004a 100644 --- a/internal/httpserver/handlers/admin_reseller_plans.go +++ b/internal/httpserver/handlers/admin_reseller_plans.go @@ -8,8 +8,8 @@ import ( "strconv" "strings" - "github.com/host-yt/caddy-proxy-manager/internal/reseller" "github.com/go-chi/chi/v5" + "github.com/host-yt/caddy-proxy-manager/internal/reseller" ) // ResellerPlanSave handles POST /admin/reseller-plans (id=0 creates). @@ -23,7 +23,13 @@ func (h *AdminHandlers) ResellerPlanSave(w http.ResponseWriter, r *http.Request) return } _ = r.ParseForm() - atoi := func(k string) int { n, _ := strconv.Atoi(r.FormValue(k)); if n < 0 { n = 0 }; return n } + atoi := func(k string) int { + n, _ := strconv.Atoi(r.FormValue(k)) + if n < 0 { + n = 0 + } + return n + } id, _ := strconv.ParseInt(r.FormValue("id"), 10, 64) p := reseller.Plan{ ID: id, diff --git a/internal/httpserver/handlers/admin_resellers.go b/internal/httpserver/handlers/admin_resellers.go index b04dacdc..3b7713ac 100644 --- a/internal/httpserver/handlers/admin_resellers.go +++ b/internal/httpserver/handlers/admin_resellers.go @@ -10,12 +10,12 @@ import ( "strings" "time" + "github.com/go-chi/chi/v5" "github.com/host-yt/caddy-proxy-manager/internal/audit" "github.com/host-yt/caddy-proxy-manager/internal/auth" "github.com/host-yt/caddy-proxy-manager/internal/httpserver/middleware" "github.com/host-yt/caddy-proxy-manager/internal/quota" "github.com/host-yt/caddy-proxy-manager/internal/reseller" - "github.com/go-chi/chi/v5" ) // slugRe restricts reseller slugs to url-safe lowercase tokens (used in future diff --git a/internal/httpserver/handlers/api_docs.go b/internal/httpserver/handlers/api_docs.go index eb8220bf..5ce45b05 100644 --- a/internal/httpserver/handlers/api_docs.go +++ b/internal/httpserver/handlers/api_docs.go @@ -9,8 +9,8 @@ import ( "strings" "time" - "github.com/host-yt/caddy-proxy-manager/internal/installstate" mw "github.com/host-yt/caddy-proxy-manager/internal/httpserver/middleware" + "github.com/host-yt/caddy-proxy-manager/internal/installstate" ) //go:embed openapi.json diff --git a/internal/httpserver/handlers/api_v1.go b/internal/httpserver/handlers/api_v1.go index d7304d60..de62dc60 100644 --- a/internal/httpserver/handlers/api_v1.go +++ b/internal/httpserver/handlers/api_v1.go @@ -16,8 +16,8 @@ import ( "github.com/go-chi/chi/v5" "github.com/host-yt/caddy-proxy-manager/internal/adminscope" - "github.com/host-yt/caddy-proxy-manager/internal/auth" "github.com/host-yt/caddy-proxy-manager/internal/audit" + "github.com/host-yt/caddy-proxy-manager/internal/auth" "github.com/host-yt/caddy-proxy-manager/internal/domain/routes" "github.com/host-yt/caddy-proxy-manager/internal/httpserver/middleware" "github.com/host-yt/caddy-proxy-manager/internal/quota" diff --git a/internal/httpserver/handlers/auth.go b/internal/httpserver/handlers/auth.go index 10f08ef7..ff24f206 100644 --- a/internal/httpserver/handlers/auth.go +++ b/internal/httpserver/handlers/auth.go @@ -2446,4 +2446,3 @@ func (h *AuthHandlers) renderEmailOTP(w http.ResponseWriter, status int, d email w.WriteHeader(status) _, _ = w.Write(buf.Bytes()) } - diff --git a/internal/httpserver/handlers/client.go b/internal/httpserver/handlers/client.go index f0e38b4e..4ebe02c6 100644 --- a/internal/httpserver/handlers/client.go +++ b/internal/httpserver/handlers/client.go @@ -163,12 +163,12 @@ type clientDashboardData struct { ActiveRoutes int PendingRoutes int FailedRoutes int - TotalBandwidth7d string // formatted bytes for last 7 days - Requests24h int64 // total requests in last 24h across all client routes - Errors24h int64 // 4xx+5xx in last 24h + TotalBandwidth7d string // formatted bytes for last 7 days + Requests24h int64 // total requests in last 24h across all client routes + Errors24h int64 // 4xx+5xx in last 24h Bandwidth30dDays []accesslog.BandwidthDayBucket // 30-day daily totals - Bandwidth30dTotal int64 // sum across Bandwidth30dDays - MaxDay30dBytes int64 // max bucket for bar scaling + Bandwidth30dTotal int64 // sum across Bandwidth30dDays + MaxDay30dBytes int64 // max bucket for bar scaling } func (h *ClientHandlers) Dashboard(w http.ResponseWriter, r *http.Request) { @@ -1634,21 +1634,21 @@ func (h *ClientHandlers) loadClientAPIKeys(ctx context.Context) []clientAPIKeyRo type clientRouteLogsData struct { baseAppData - RouteID int64 - Domain string - Error string - Entries []accesslog.Entry - AnalyticsTotal int64 - StatusBuckets []accesslog.StatusBucket - ProtoBreakdown []accesslog.ProtoHit - BytesSummary accesslog.BytesSummary - TopPaths []accesslog.PathHit - TopCountries []accesslog.CountryHit - TopRemoteIPs []accesslog.RemoteIPHit - TopASNOrgs []accesslog.ASNOrgHit - BandwidthDays []accesslog.BandwidthDayBucket // 7-day daily totals - BandwidthTotal7d int64 // sum across BandwidthDays - MaxDayBytes int64 // max bucket for bar scaling + RouteID int64 + Domain string + Error string + Entries []accesslog.Entry + AnalyticsTotal int64 + StatusBuckets []accesslog.StatusBucket + ProtoBreakdown []accesslog.ProtoHit + BytesSummary accesslog.BytesSummary + TopPaths []accesslog.PathHit + TopCountries []accesslog.CountryHit + TopRemoteIPs []accesslog.RemoteIPHit + TopASNOrgs []accesslog.ASNOrgHit + BandwidthDays []accesslog.BandwidthDayBucket // 7-day daily totals + BandwidthTotal7d int64 // sum across BandwidthDays + MaxDayBytes int64 // max bucket for bar scaling Bandwidth30dDays []accesslog.BandwidthDayBucket // 30-day daily totals Bandwidth30dTotal int64 // sum across Bandwidth30dDays MaxDay30dBytes int64 // max bucket for 30-day bar scaling @@ -1827,4 +1827,3 @@ func (h *ClientHandlers) RouteLogsCSV(w http.ResponseWriter, r *http.Request) { } cw.Flush() } - diff --git a/internal/httpserver/handlers/portal.go b/internal/httpserver/handlers/portal.go index ebec7ac7..46f55caa 100644 --- a/internal/httpserver/handlers/portal.go +++ b/internal/httpserver/handlers/portal.go @@ -39,9 +39,9 @@ type PortalHandlers struct { Logger *slog.Logger Portal *portal.Service Metrics metricsLoginEmitter - Secure bool // cookie Secure flag, mirrors the panel auth cookie - SameSite http.SameSite // mirrors the panel auth cookie SameSite - TTL time.Duration // portal session lifetime + Secure bool // cookie Secure flag, mirrors the panel auth cookie + SameSite http.SameSite // mirrors the panel auth cookie SameSite + TTL time.Duration // portal session lifetime State *installstate.Manager // for decrypting totp_secret_enc OAuth2X *oauth2x.Service // social login for the portal; nil disables buttons AppURL string // panel base URL, used to build OAuth callback URLs diff --git a/internal/view/admin/hosts_edit.html.tmpl b/internal/view/admin/hosts_edit.html.tmpl index cb5097c6..b2934d6f 100644 --- a/internal/view/admin/hosts_edit.html.tmpl +++ b/internal/view/admin/hosts_edit.html.tmpl @@ -226,7 +226,7 @@
Backend is resolved on the node -

Tick only for names nothing but the node can look up (container names, tunnel peers). The panel then cannot verify where this backend points, so the node dials whatever the name gives it. A name the panel cannot resolve is refused unless this is ticked. The panel otherwise resolves the name itself and the node dials the address the panel screened.

+

Tick only for names nothing but the node can look up (container names, tunnel peers). The panel then cannot verify where this backend points, so the node dials whatever the name gives it. A name the panel cannot resolve is refused unless this is ticked. The panel otherwise resolves the name itself and the node dials the address the panel screened. Requires super_admin.