fix: resolve the public status page's tenant from a trusted X-Forwarded-Host

The SSR fetch set `Host` to the visitor's hostname. `Host` is a forbidden
header name and undici discards it silently, so the Go server saw
`server:8080`, `hostSlug` returned "", `InstanceFromHost` returned false and
every public status page 404'd on every deployment. The feature did not work.

- `web/` now forwards the visitor's host as `X-Forwarded-Host`, and their
  address on `X-Forwarded-For` — without the latter gin sees a request from the
  Next pod with no XFF and every visitor of every page shares one 120/min
  bucket, tripped by exactly the traffic an outage produces.
- `publicStatusInstance` honours `X-Forwarded-Host` only when `c.RemoteIP()` is
  in `TRUSTED_PROXIES`. It is a tenant selector, so an untrusted peer must not
  be able to name one; `RemoteIP()` rather than `ClientIP()` because the latter
  is reconstructed from the very headers being judged. `TrustedProxies()` moves
  from main.go into the api package so the variable keeps one parser.
- A host naming no slug on a non-cloud deployment resolves the sole instance,
  the way bootstrap does. A self-hosted install at vantage.acme.com or an IP
  has no slug and could never serve a status page; more than one instance is a
  404 rather than a guess, and an unknown-but-well-formed slug stays a 404.
- `InstanceFromHost` gains an explicit-host variant rather than a second copy
  of the slug rules, and now caches negative lookups: an unknown host cost a
  Mongo query per anonymous request, which is also a timing oracle separating
  "no such instance" from "instance exists, page does not".
- The handler's `@Router` annotation is dropped. openapi.json declares one
  server of `/api`, so it published `/api/public/status/{pageId}` — a path that
  does not exist. The real address is described in prose instead.
This commit is contained in:
2026-08-25 09:04:47 +00:00
parent 72c9492223
commit da1dc90ac5
6 changed files with 276 additions and 97 deletions
+2 -21
View File
@@ -175,7 +175,7 @@ func runSchemaSetup() {
}
if err := services.EnsureStatusPageIndexes(); err != nil {
log.Printf("status page indexes: %v", err)
log.Printf("warning: failed to ensure status page indexes: %v", err)
}
if err := services.EnsureAuditIndexes(); err != nil {
@@ -268,7 +268,7 @@ func serve() {
// only produced audit strings; the public status limiter makes it load
// bearing. Empty means trust nobody, which is correct for a direct
// exposure and wrong behind a proxy — hence the explicit setting.
if err := r.SetTrustedProxies(trustedProxies()); err != nil {
if err := r.SetTrustedProxies(api.TrustedProxies()); err != nil {
log.Fatalf("trusted proxies: %v", err)
}
r.Use(gin.Recovery())
@@ -326,25 +326,6 @@ func corsMiddleware() gin.HandlerFunc {
}
}
// trustedProxies reads TRUSTED_PROXIES, a comma-separated list of CIDRs or
// addresses. Unset means trust none: ClientIP() is then the peer address,
// which is right for a direct exposure and means every request behind an
// un-configured proxy shares one address for rate limiting. That is a visible
// failure (one client limited) rather than an invisible one (no limit at all).
func trustedProxies() []string {
v := strings.TrimSpace(os.Getenv("TRUSTED_PROXIES"))
if v == "" {
return nil
}
out := []string{}
for _, p := range strings.Split(v, ",") {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
-51
View File
@@ -4948,57 +4948,6 @@
]
}
},
"/public/status/{pageId}": {
"get": {
"parameters": [
{
"description": "Status page id",
"in": "path",
"name": "pageId",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/services.StatusSnapshot"
}
}
},
"description": "OK"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/api.ErrorResponse"
}
}
},
"description": "Not Found"
},
"429": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/api.ErrorResponse"
}
}
},
"description": "Too Many Requests"
}
},
"summary": "Public status page",
"tags": [
"status"
]
}
},
"/runs/{runId}": {
"get": {
"parameters": [
+46 -2
View File
@@ -7,7 +7,9 @@ import (
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"github.com/gin-gonic/gin"
)
@@ -60,6 +62,15 @@ func RateLimitPublicStatus() gin.HandlerFunc {
//
// Unknown host, unknown page and unpublished page all answer the same 404.
//
// It carries no @Router annotation deliberately. openapi.json declares a
// single server of "/api", so a @Router of /public/status/{pageId} would be
// published as /api/public/status/{pageId} — a path that does not exist, and
// which would sit behind auth.Middleware if it did. The real address is:
//
// GET {scheme}://{instance-host}/public/status/{pageId}
//
// on the gin root, unauthenticated, rate limited per client address.
//
// @Summary Public status page
// @Tags status
// @Produce json
@@ -67,9 +78,8 @@ func RateLimitPublicStatus() gin.HandlerFunc {
// @Success 200 {object} services.StatusSnapshot
// @Failure 404 {object} ErrorResponse
// @Failure 429 {object} ErrorResponse
// @Router /public/status/{pageId} [get]
func getPublicStatusPage(c *gin.Context) {
inst, ok := auth.InstanceFromHost(c)
inst, ok := publicStatusInstance(c)
if !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
@@ -88,3 +98,37 @@ func getPublicStatusPage(c *gin.Context) {
c.Header("Cache-Control", "public, max-age=30")
c.JSON(http.StatusOK, snap)
}
// publicStatusInstance resolves which instance a public request is for.
//
// The browser never reaches this handler directly: the request arrives from
// the Next server, which forwards the visitor's host in X-Forwarded-Host
// because the Host header cannot be set on a fetch (undici drops it silently,
// as a forbidden header name). That makes X-Forwarded-Host a tenant selector,
// so it is honoured only when the machine that opened the connection is one of
// the configured trusted proxies.
//
// When the resulting host names no slug at all — vantage.acme.com,
// status.acme.com, a bare IP — and the deployment is not cloud, the single
// instance of that install is used. A self-hosted install has exactly one, and
// without this every self-hosted status page 404s forever. More than one is a
// refusal rather than a guess.
func publicStatusInstance(c *gin.Context) (*models.Instance, bool) {
host := c.Request.Host
if trustedPeer(c) {
if h := firstForwarded(c.GetHeader("X-Forwarded-Host")); h != "" {
host = h
}
}
if inst, ok := auth.InstanceForHost(host); ok {
return inst, true
}
if auth.HostSlug(host) != "" {
// The host named an instance and that instance does not exist.
return nil, false
}
if services.DeploymentMode() == license.DeploymentCloud {
return nil, false
}
return auth.SoleInstance()
}
+84
View File
@@ -0,0 +1,84 @@
package api
import (
"net"
"os"
"strings"
"sync"
"github.com/gin-gonic/gin"
)
// TrustedProxies reads TRUSTED_PROXIES, a comma-separated list of CIDRs or
// addresses. Unset means trust none: ClientIP() is then the peer address,
// which is right for a direct exposure and means every request behind an
// un-configured proxy shares one address for rate limiting. That is a visible
// failure (one client limited) rather than an invisible one (no limit at all).
//
// This lives here rather than in main.go because the string has two consumers:
// gin's own SetTrustedProxies, which main.go calls with it, and trustedPeer
// below, which the public status page uses to decide whether to believe an
// X-Forwarded-Host. One variable, one parser.
func TrustedProxies() []string {
v := strings.TrimSpace(os.Getenv("TRUSTED_PROXIES"))
if v == "" {
return nil
}
out := []string{}
for _, p := range strings.Split(v, ",") {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
var (
trustedNetsOnce sync.Once
trustedNets []*net.IPNet
)
func parsedTrustedNets() []*net.IPNet {
trustedNetsOnce.Do(func() {
for _, entry := range TrustedProxies() {
if _, n, err := net.ParseCIDR(entry); err == nil {
trustedNets = append(trustedNets, n)
continue
}
// A bare address is a /32 or /128.
if ip := net.ParseIP(entry); ip != nil {
bits := 32
if ip.To4() == nil {
bits = 128
}
trustedNets = append(trustedNets, &net.IPNet{IP: ip, Mask: net.CIDRMask(bits, bits)})
}
}
})
return trustedNets
}
// trustedPeer reports whether the immediate peer is one of the configured
// proxies.
//
// It deliberately uses RemoteIP() rather than ClientIP(): ClientIP() is the
// reconstructed *client* address, which is derived from the very headers this
// function exists to decide whether to believe. X-Forwarded-Host selects a
// tenant on the public status route, so it is only honoured when the machine
// that actually opened the connection is trusted to have set it.
func trustedPeer(c *gin.Context) bool {
nets := parsedTrustedNets()
if len(nets) == 0 {
return false
}
ip := net.ParseIP(c.RemoteIP())
if ip == nil {
return false
}
for _, n := range nets {
if n.Contains(ip) {
return true
}
}
return false
}
+67 -10
View File
@@ -23,6 +23,10 @@ var (
const instanceCacheTTL = 60 * time.Second
// soleInstanceCacheKey cannot collide with a slug: a slug is [a-z0-9-] and can
// never contain a NUL.
const soleInstanceCacheKey = "\x00sole"
func appRootLabel() string {
if v := os.Getenv("APP_ROOT_LABEL"); v != "" {
return strings.ToLower(v)
@@ -50,25 +54,78 @@ func hostSlug(host string) string {
return parts[0]
}
// HostSlug exposes the slug rules to callers outside this package that need to
// distinguish "this host names no instance at all" from "this host names an
// instance that does not exist". It is a thin wrapper rather than a second
// implementation on purpose.
func HostSlug(host string) string { return hostSlug(host) }
// InstanceFromHost resolves the instance named by the request's own Host
// header. Callers that must resolve a host from somewhere else — the public
// status page reads a trusted X-Forwarded-Host — use InstanceForHost so the
// slug rules and the 60s cache stay single-implementation.
func InstanceFromHost(c *gin.Context) (*models.Instance, bool) {
slug := hostSlug(c.Request.Host)
return InstanceForHost(c.Request.Host)
}
// InstanceForHost is InstanceFromHost with the host supplied explicitly.
func InstanceForHost(host string) (*models.Instance, bool) {
slug := hostSlug(host)
if slug == "" {
return nil, false
}
instanceCacheMu.Lock()
if e, ok := instanceCache[slug]; ok && time.Since(e.at) < instanceCacheTTL {
instanceCacheMu.Unlock()
return e.instance, e.instance != nil
if inst, hit := cachedInstanceFor(slug); hit {
return inst, inst != nil
}
instanceCacheMu.Unlock()
inst, err := services.GetInstanceBySlug(slug)
if err != nil || inst == nil {
// Negative entries are cached too. Without them an unknown but
// well-formed host costs a Mongo query per anonymous request, which
// the public status page exposes to the open internet — and the
// round trip is itself a timing oracle separating "no such instance"
// from "instance exists, page does not".
storeInstance(slug, nil)
return nil, false
}
instanceCacheMu.Lock()
instanceCache[slug] = cachedInstance{instance: inst, at: time.Now()}
instanceCacheMu.Unlock()
storeInstance(slug, inst)
return inst, true
}
// SoleInstance resolves the one instance of a deployment that has exactly one.
// It is how a self-hosted install serves a host that names no slug at all —
// vantage.acme.com, status.acme.com, or a bare address. It reuses the same
// count-then-read that bootstrap uses, and refuses rather than guessing when
// more than one instance exists.
func SoleInstance() (*models.Instance, bool) {
if inst, hit := cachedInstanceFor(soleInstanceCacheKey); hit {
return inst, inst != nil
}
n, err := services.CountInstances()
if err != nil || n != 1 {
storeInstance(soleInstanceCacheKey, nil)
return nil, false
}
inst, err := services.FirstInstance()
if err != nil || inst == nil {
storeInstance(soleInstanceCacheKey, nil)
return nil, false
}
storeInstance(soleInstanceCacheKey, inst)
return inst, true
}
func cachedInstanceFor(key string) (*models.Instance, bool) {
instanceCacheMu.Lock()
defer instanceCacheMu.Unlock()
if e, ok := instanceCache[key]; ok && time.Since(e.at) < instanceCacheTTL {
return e.instance, true
}
return nil, false
}
func storeInstance(key string, inst *models.Instance) {
instanceCacheMu.Lock()
instanceCache[key] = cachedInstance{instance: inst, at: time.Now()}
instanceCacheMu.Unlock()
}