Skip to content

fix: close all 14 findings of the 2026-09-21 system review - #21

Merged
marcoome merged 12 commits into
mainfrom
fix/system-review-batch
Sep 21, 2026
Merged

marcoome merged 12 commits into
mainfrom
fix/system-review-batch

Conversation

@marcoome

@marcoome marcoome commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Security remediation. Details withheld until operators have had time to upgrade; see the CHANGELOG for impact and upgrade notes.

…te nodes

Caddy's admin API authenticates nothing, so "can route to <wg-ip>:2019" was
equivalent to root on a node. The agent admin proxy existed but was opt-in and
the shipped remote-node profile published 2019 on the WireGuard address.

- deploy/remote-node: publish the admin port on 127.0.0.1 only, default to the
  custom edge image, add the shared GeoIP volume; the bootstrap Caddyfile now
  binds 0.0.0.0 inside the container (a WG address cannot be bound from a
  bridge container at all - that config could never start).
- deploy/node-agent example: admin proxy vars are no longer commented out.
- panel: refuse to register a node whose api_url is a raw remote :2019, in the
  admin form and POST /api/v1/nodes; `server doctor` prints an "admin API auth"
  row per node; approving an auto-joined node warns instead of blocking.
- scripts/node-join.sh: fix the unbindable admin address and print the steps to
  close the direct port.

Rollout order (a wrong order cuts the panel off from the node):
  1. Enable tunnel / Rotate in the panel, copy HPG_ADMIN_PROXY_KEY.
  2. Start the agent with HPG_ADMIN_PROXY_LISTEN/_KEY/HPG_CADDY_ADMIN_URL.
  3. Repoint the node's API URL at http://<wg-ip>:2021 and verify with doctor.
  4. Only then change the caddy mapping to "127.0.0.1:2019:2019".
Existing nodes are untouched; HPG_ALLOW_UNAUTHENTICATED_NODE_ADMIN=1 re-opens
registration for a fleet mid-migration.

(cherry picked from commit 6f0d15ba6d65b5de177c538b1c17abd61fa6b798)
…issuance

README and the placement comment promised Redis-backed shared cert storage
(caddy-tlsredis), but the edge image builds no TLS-storage module, the
Caddyfile line was commented out, and every node has its own caddy_data. The
claim was chosen for removal rather than implementation: shared TLS storage
holds private keys, needs Redis reachable from every remote node over the
mesh, and fails closed on a Redis outage - a much larger availability and
blast-radius change than the failover latency it saves.

MULTI_NODE gains a "Certificates on failover" section stating what actually
happens: the peer issues its own certificate on the first handshake it serves,
so plan ACME rate limits for nodes x hosts. The Caddyfile comment now warns
that adding a `storage` directive breaks config load instead of inviting it.

(cherry picked from commit c2bbd3743c8c632fdefdd5e63eabef81cbb91cea)
The lite and Portainer profiles still shipped 1.3.2, so an operator who picked
either got a panel missing every 1.4/1.5 fix. Bumped, plus two guards in the
existing CI policy block: every ghcr.io/host-yt pin under deploy/ must name one
version, and the README status line must be that version. `make pin-version
V=x.y.z` rewrites all of them at once so the bump cannot be partial.

(cherry picked from commit 6c978d8104ffaf8b4b0da5f69c131c4ac6108813)
MariaDB, Redis and the node-agent had healthchecks; the two services that
matter did not, so a wedged-but-running panel or Caddy stayed "running".

The runtime image is distroless - no shell, curl or wget - so the app probes
itself: `server healthcheck` GETs /readyz over loopback and exits 0/1.
Caddy uses the local admin endpoint rather than :80/:443, which legitimately
503s before the first route is pushed. start_period is 300s for the app (a
first boot runs every migration before /readyz turns green) and 60s for Caddy;
an ACME order in flight must never read as wedged. Compose reports health but
does not restart on it - that needs an external supervisor.

(cherry picked from commit 1acff05ef575345810fb19816e5c04f1540e8e6f)
…le modes

`docker compose up` aborted on an empty REDIS_PASSWORD deep inside
interpolation, and the install guide never mentioned the variable at all.
`server doctor` now lists every required variable that is missing or empty in
one row and prints a generated value for each secret one, so the fix is a
paste rather than a loop of compose errors.

Secret files: install_state.json is chmod 600 after the rename, not only on
create, so a file restored from a backup or synced by a file-sync client stops
being world-readable. Doctor reports the mode of .env and install_state.json
plus the process umask. INSTALL.md sets umask 077 and chmods .env.

(cherry picked from commit 2011619313efc8829ec93cdd62c72b919486c346)
The agent wrote /data/geoip/GeoLite2-Country.mmdb into its own container - the
example mounted only logs and state - and the remote Caddy ran stock
caddy:2.11.4, which has no maxmind_geolocation matcher. The feature could not
work in the profile that documents it.

Agent and Caddy now share /opt/hostyt-node/geoip (read-only on the Caddy side,
only the agent writes), the remote profile defaults to the custom edge image,
and syncGeoIP refuses to write when the directory is not a mount instead of
succeeding into a path Caddy never reads.

The per-node GeoIP capability flag stays the operator's gate: Caddy's admin API
exposes no module list, so there is nothing to probe it with.

(cherry picked from commit adfcb0530b33f9e98c52d6d5b8587f00bdd968c5)
…le SLA

- `make run` advertised loading .env but only ran `go run`, and nothing in the
  Go code parses a .env file. The target now sources it when present, and both
  the package doc and ARCHITECTURE say the process environment is the only
  source.
- SECURITY claimed APP_SECRET rotation was downtime-free. cmd/rotate-secret
  requires the panel stopped and nulls api_keys.key_hmac, so the runbook now
  spells out stop, back up, rotate, restart, re-issue every API key.
- ARCHITECTURE states the actual recovery window for deferred work instead of
  leaving "no durable queue" as the whole answer: boot push at +10s, reconcile
  at 60s, drift at 5 min, so a route change is durable but can take ~5 minutes
  after a crash; mail is the work that can be lost outright. No queue built.

(cherry picked from commit c4db9474603b807300ed3f318b51753c3c11973b)
…ch-Dest trust

HPG-SEC-003. Permissive/document-only SSO mode only ever gated GET/HEAD
page loads and additionally skipped the gate for any request whose
client-supplied Sec-Fetch-Dest header claimed a subresource kind - a
plain GET could dodge auth entirely just by setting that header, and
every POST/PUT/PATCH/DELETE already went through with zero auth check.

- New routes (routes.Service.Create, the single insert path for the
  panel UI, the client-portal API and the admin API) now default
  sso_strict_mode=1, so an operator who later turns on SSO for a route
  gets the only mode that actually authenticates every request unless
  they deliberately opt out. Existing rows are untouched - this is an
  INSERT-time default, not a migration/backfill, so no live route's
  behavior changes today.
- Removed the Sec-Fetch-Dest exclusion from the permissive-mode
  matcher. Never base an auth decision on a header the client sets.
  The static-asset path exclusion (SPA refresh storms) is unaffected.
- Renamed the UI copy to say what permissive mode actually is
  (document-load-only, not a weaker form of real auth) and added a
  visible warning when Strict mode is unchecked.

What an operator notices: a route with SSO configured for the first
time now starts in Strict mode (401 JSON, all methods) instead of the
old redirect-only GET/HEAD gate - if that breaks a browser app that
relied on the permissive redirect flow, uncheck Strict mode and accept
the warning. Existing SSO routes are unaffected either way. Permissive
mode no longer exempts requests that claim a non-navigation
Sec-Fetch-Dest, so an SPA's own XHR/fetch calls to its backend are now
gated like everything else in that mode - scope the gate off an API
path with sso_paths/sso_hosts, or switch to Strict mode, if that breaks
an app that relied on the old bypass.

(cherry picked from commit 5a5b6429cce465b037158d319201abbbdb3dcdbc)
… the docker bridge

HPG-SEC-005. docker-compose trusts the whole bridge subnet as a proxy
peer, and the panel self-route forwarded True-Client-IP/X-Real-IP to
the panel verbatim. TrustedRealIP then preferred those single-value,
client-settable headers over the properly parsed X-Forwarded-For
chain - so an internet client could pick its own apparent source IP,
rotating past per-IP rate limits, poisoning the audit log, or matching
an allowlisted IP for a gated endpoint.

- Panel self-route (routes.Service.panelRoute, caddyapi.BuildRoute):
  delete both headers and stamp X-Real-IP from Caddy's own resolved
  {http.request.client_ip} instead of relaying whatever the client
  sent.
- TrustedRealIP: the verified right-hand X-Forwarded-For entry (a
  well-behaved proxy appends what it saw rather than letting the
  client set it) is now always preferred. True-Client-IP/X-Real-IP are
  only honored for header names the operator explicitly opts into
  (APP_TRUST_REALIP_HEADERS) - the same "name the proxy chain" model
  CloudflareIP already uses for CF-Connecting-IP - so a real edge
  appliance that legitimately overwrites one of these still works once
  named, and nothing is trusted blindly by default.

What an operator notices: nothing, by default - APP_TRUST_REALIP_HEADERS
is empty out of the box, so rate limiting/audit/allowlists now derive
the client IP from XFF instead of a client-settable header, which is
what they should have been doing all along. An operator whose edge
proxy sets True-Client-IP or X-Real-IP (not XFF) before reaching the
panel needs to list that header name in APP_TRUST_REALIP_HEADERS or
those requests fall back to the docker-bridge peer address.

Left alone: docker-compose.yml's 172.18.0.0/16 trust range itself
(deploy/** is another agent's area) - this fix assumes that range stays
broad and hardens what happens once a peer is inside it.

(cherry picked from commit 4fb21745e9dbfe1fdb04219006cf2225c4739ce8)
HPG-SEC-007. /hpg-portal/logout was registered for both GET and POST,
and the public forward-auth portal runs on the protected host with no
panel session, so it bypasses the panel's CSRF middleware entirely. A
third-party page could force a visitor's logout with a plain <img> tag
or link - a logout CSRF.

- GET now renders a confirmation page only (PortalHandlers.LogoutConfirm) -
  no session lookup, no cookie mutation, no Redis call.
- POST (PortalHandlers.Logout) is the only path that destroys the
  session, and it now requires the same double-submit CSRF token
  LoginSubmit/Portal2FASubmit already use (cookie + form field, set
  when the confirmation page renders). A POST with a missing or
  mismatched token falls back to rendering the confirmation instead of
  logging out.

Checked for other links/redirects into /hpg-portal/logout (nav
templates, the SSO/OIDC flow, JS) - there are none; the portal has no
UI that links to it today, so nothing else needed updating.

What an operator notices: an existing bookmark, image tag, or script
that GETs /hpg-portal/logout to force a sign-out now only shows a
"Sign out" confirmation button instead of actually signing the user
out - anything driving logout must POST with the token from that page.

(cherry picked from commit c4d0ea0cf1558406bc4c8aca18c49dbe24c01f50)
… and WAF

HPG-SEC-001: a reseller or scope-restricted admin could point their own route
at the control plane. The main backend allowed any RFC1918/CGNAT address with
no infra or port check, and additional upstreams plus path-rule upstreams had
no SSRF screening at all - all three become a Caddy `dial`, so a tenant could
reach the node's unauthenticated admin API on 2019 through their own domain.
Reuse the stream deny set (streamguard.InfraTargets) as the one screener for
HTTP too: InfraTargets.ScreenHTTPBackend now refuses port 2019, managed node /
panel / control-plane addresses and the WireGuard mesh, resolves hostnames and
checks every answer. All three upstream paths call it on save, and the config
builder re-screens every emitted target (deny set only, no DNS) so a legacy or
newly-infra row is dropped instead of pushed. Customer private origins stay
allowed. A tenant Host header naming infrastructure is refused too.

HPG-SEC-004: custom_headers, rewrite URIs and redirect destinations reach
Caddy's replacer, which expands {env./file./system./$} - the same rejector the
custom-handlers path already had now guards them, on save and at build.

HPG-SEC-006: waf_directives was only length-capped, so one bad SecLang line
could fail /load for every tenant on the node. Scoped admins now get the
structured subset (SecRuleRemoveById with numeric ids); unrestricted admins
keep arbitrary Sec* directives; unparseable lines are dropped at emission.

(cherry picked from commit 0f349c2d4e61ef306ef2b6e58db7f6e119bea85c)
Closes all 14 findings of the 2026-09-21 system review.

Minor rather than patch: the batch adds a healthcheck subcommand, new doctor
checks, a single-source version pin target and APP_TRUST_REALIP_HEADERS. No
existing configuration format changes, and the upgrade is still a docker pull
plus the migrations the panel runs itself - but three behaviours tighten, and
the upgrade notes say which.
@marcoome
marcoome merged commit 8c9cb59 into main Sep 21, 2026
2 checks passed
@marcoome
marcoome deleted the fix/system-review-batch branch September 21, 2026 08:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant