diff --git a/AGENTS.md b/AGENTS.md index 56940d8..6429cf4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -131,7 +131,7 @@ kubectl patch secret mcp-sentinel-secrets -n mcp-sentinel --type merge -p '{"str - `mcp.mcpruntime.org` (default ingress host for `MCPServer` when you use host-based routing) - `platform.mcpruntime.org` (dashboard / admin UI — the primary user-facing entrypoint) - **Expected public URLs (after DNS and TLS):** - - Dashboard UI: `https://platform.mcpruntime.org/` (also serves `/api`, `/grafana`, `/prometheus` under the same host so users do not need raw IPs / dev path-based routing) + - Dashboard UI: `https://platform.mcpruntime.org/` (also serves `/api` under the same host). `/grafana` and `/prometheus` are intentionally **not** exposed on the public platform host — those tools have no built-in auth in this stack. Reach them with `kubectl port-forward -n mcp-sentinel svc/grafana 3000:3000` / `svc/prometheus 9090:9090`, or front them with your own auth-aware ingress. - Registry: `https://registry.mcpruntime.org` (or HTTP before TLS, depending on overlay) - Each MCP server (default `IngressPath` is `/{metadata.name}/mcp`): e.g. `https://mcp.mcpruntime.org/demo-one/mcp` for a server named `demo-one` in the default shape - **Let’s Encrypt and DNS:** the setup TLS flow requests `registry/registry-cert` for `registry.` and `mcp.` when those names are in env-derived config. `platform.` is separate: the `mcp-sentinel-platform-ui` Ingress in `mcp-sentinel` asks cert-manager to write `mcp-sentinel-platform-tls`. **All three** public DNS A/AAAA (or CNAME) records must exist and point to the **same** public ingress IP (or stable LB). A typo in DNS (e.g. `regsitry` instead of **registry**, or `platfrom` instead of **platform**) will break the matching hostname. Port **80** must hit Traefik for **HTTP-01** before certs are issued. @@ -147,10 +147,11 @@ kubectl patch secret mcp-sentinel-secrets -n mcp-sentinel --type merge -p '{"str - **Analytics 401:** use gateway/ingest URL and key, not the app’s random env. Example: `ANALYTICS_INGEST_URL=http://mcp-sentinel-ingest.mcp-sentinel.svc.cluster.local:8081/events` and `ANALYTICS_API_KEY` from `mcp-sentinel-secrets` (`API_KEYS` key). - **Secret not found in workload namespace:** copy `mcp-sentinel-secrets` or use a shared secret reference. - **Dashboard / API 401:** align `API_KEYS` and `UI_API_KEY` and roll the API deployment. +- **Dashboard 308 redirect loop in dev:** the UI service redirects HTTP→HTTPS for non-local hosts when it sees `X-Forwarded-Proto: http`. Override with the `UI_REQUIRE_HTTPS` env on the `mcp-sentinel-ui` deployment: `auto` (default — redirect public hosts only), `true` (always redirect on http), `false` (never redirect, use this when the UI is fronted by a non-TLS terminator on a real hostname). - **Ingress / routes:** `kubectl get ingress -A` and confirm paths match the gateway and demo servers you expect. - **Private / HTTP in-cluster registry / k3s:** Pull and push can fail with `https` vs `http` or `registry.local` DNS on nodes. See **k3s and HTTP registry (config files)** below, set **`MCP_REGISTRY_*`** before `pipeline generate` when you want `ClusterIP:port` in manifests, and raise **`MCP_DEPLOYMENT_TIMEOUT`** if setup rollouts time out on slow first pulls. - **Prod DNS / ACME:** with `MCP_PLATFORM_DOMAIN=example.com`, setup derives `registry.example.com`, `mcp.example.com`, and `platform.example.com`. All three public DNS records must point at the ingress IP and port 80 must reach Traefik for HTTP-01. If cert-manager reports NXDOMAIN, verify from outside and inside the cluster: `getent hosts registry.example.com`, `getent hosts mcp.example.com`, `getent hosts platform.example.com`, and `kubectl run dns-check --rm -i --restart=Never --image=busybox:1.36 -- nslookup platform.example.com`. -- **Platform UI 404 / wrong host:** when `MCP_PLATFORM_DOMAIN` (or `MCP_PLATFORM_INGRESS_HOST`) is set, setup applies a host-based ingress `mcp-sentinel-platform-ui` in `mcp-sentinel`. Verify with `kubectl get ingress mcp-sentinel-platform-ui -n mcp-sentinel -o yaml`; the rule should be host=`platform.` routing `/` to `mcp-sentinel-ui:8082` (and `/api`, `/grafana`, `/prometheus` to those services). If the dashboard returns Traefik default 404, check that DNS resolves `platform.` to the cluster ingress, then `kubectl logs -n traefik deploy/traefik --tail=120` for routing errors. The dev path-based gateway (`mcp-sentinel-gateway`) keeps working when `MCP_PLATFORM_DOMAIN` is unset. +- **Platform UI 404 / wrong host:** when `MCP_PLATFORM_DOMAIN` (or `MCP_PLATFORM_INGRESS_HOST`) is set, setup applies a host-based ingress `mcp-sentinel-platform-ui` in `mcp-sentinel` (and, when TLS is enabled, a sibling `mcp-sentinel-platform-ui-http` for HTTP→HTTPS redirect). Verify with `kubectl get ingress mcp-sentinel-platform-ui -n mcp-sentinel -o yaml`; the rule should be host=`platform.` routing `/` to `mcp-sentinel-ui:8082` (and `/api` to the same service). `/grafana` and `/prometheus` are deliberately not on the public host. If the dashboard returns Traefik default 404, check that DNS resolves `platform.` to the cluster ingress, then `kubectl logs -n traefik deploy/traefik --tail=120` for routing errors. The dev path-based gateway (`mcp-sentinel-gateway`) keeps working when `MCP_PLATFORM_DOMAIN` is unset. - **Prod registry 404 / image pulls say “not found”:** if `registry-cert` is Ready but pods fail to pull `registry./:`, check the public registry route: `curl -k -i https://registry./v2/`. Expected is HTTP 200 with `docker-distribution-api-version: registry/2.0`; Traefik `404 page not found` means the ingress/router is not active. Check `kubectl logs -n traefik deploy/traefik --tail=120` and `kubectl get ingress registry -n registry -o yaml`. In prod, the registry ingress must not reference the dev-only `pii-redactor@file` middleware. - **Prod MCP server URLs:** prefer path-based public routing for clients: `https://mcp.//mcp`. Use `spec.publicPathPrefix: ` and set the server’s `MCP_PATH` to `//mcp`; avoid examples that require a custom `Host` header such as `go.example.local`. diff --git a/internal/cli/platform_ingress.go b/internal/cli/platform_ingress.go index ec107da..6f84bf1 100644 --- a/internal/cli/platform_ingress.go +++ b/internal/cli/platform_ingress.go @@ -7,6 +7,7 @@ import ( ) const platformIngressName = "mcp-sentinel-platform-ui" +const platformHTTPRedirectIngressName = "mcp-sentinel-platform-ui-http" const platformTLSSecretName = "mcp-sentinel-platform-tls" // applyPlatformIngressIfConfigured applies a host-based ingress for the @@ -27,12 +28,19 @@ func applyPlatformIngressIfConfigured(kubectl KubectlRunner) error { } // renderPlatformIngressManifest emits an Ingress that maps platform. -// to the dashboard UI, /api on the same UI service (which reverse-proxies to -// mcp-sentinel-api via API_UPSTREAM), and the in-cluster Grafana / Prometheus -// paths. When issuerName is set, a TLS section and cert-manager annotation are -// added so cert-manager's ingress-shim provisions a Certificate for -// platform. into the mcp-sentinel-platform-tls Secret in the same -// namespace as the Ingress. +// to the dashboard UI and /api on the same UI service (which reverse-proxies +// to mcp-sentinel-api via API_UPSTREAM). Grafana and Prometheus are +// intentionally NOT exposed on the public platform host: those tools have no +// built-in auth in this stack, so operators must reach them via port-forward +// or a private ingress instead. +// +// When issuerName is set, a TLS section and cert-manager annotation are added +// so cert-manager's ingress-shim provisions a Certificate for platform. +// into the mcp-sentinel-platform-tls Secret in the same namespace as the +// Ingress. A second Ingress on the `web` entrypoint is also emitted so HTTP +// requests to the same host hit the UI service, which redirects to HTTPS. +// (We can't rely on Traefik's entrypoint-level redirect because the prod +// overlay disables it to keep HTTP-01 ACME challenges working on first issue.) func renderPlatformIngressManifest(host, issuerName string) string { host = strings.TrimSpace(host) issuerName = strings.TrimSpace(issuerName) @@ -81,20 +89,6 @@ func renderPlatformIngressManifest(host, issuerName string) string { b.WriteString(" name: mcp-sentinel-ui\n") b.WriteString(" port:\n") b.WriteString(" number: 8082\n") - b.WriteString(" - path: /grafana\n") - b.WriteString(" pathType: Prefix\n") - b.WriteString(" backend:\n") - b.WriteString(" service:\n") - b.WriteString(" name: grafana\n") - b.WriteString(" port:\n") - b.WriteString(" number: 3000\n") - b.WriteString(" - path: /prometheus\n") - b.WriteString(" pathType: Prefix\n") - b.WriteString(" backend:\n") - b.WriteString(" service:\n") - b.WriteString(" name: prometheus\n") - b.WriteString(" port:\n") - b.WriteString(" number: 9090\n") b.WriteString(" - path: /\n") b.WriteString(" pathType: Prefix\n") b.WriteString(" backend:\n") @@ -102,5 +96,39 @@ func renderPlatformIngressManifest(host, issuerName string) string { b.WriteString(" name: mcp-sentinel-ui\n") b.WriteString(" port:\n") b.WriteString(" number: 8082\n") + + if issuerName != "" { + // HTTP-only ingress on the same host so plain `http://platform./` + // hits the UI service (which 308s to HTTPS) instead of falling through to + // the host-less dev gateway ingress in k8s/10-gateway.yaml. + b.WriteString("---\n") + b.WriteString("apiVersion: networking.k8s.io/v1\n") + b.WriteString("kind: Ingress\n") + b.WriteString("metadata:\n") + b.WriteString(" name: ") + b.WriteString(platformHTTPRedirectIngressName) + b.WriteString("\n") + b.WriteString(" namespace: ") + b.WriteString(defaultAnalyticsNamespace) + b.WriteString("\n") + b.WriteString(" annotations:\n") + b.WriteString(" traefik.ingress.kubernetes.io/router.entrypoints: web\n") + b.WriteString("spec:\n") + b.WriteString(" ingressClassName: traefik\n") + b.WriteString(" rules:\n") + b.WriteString(" - host: ") + b.WriteString(strconv.Quote(host)) + b.WriteString("\n") + b.WriteString(" http:\n") + b.WriteString(" paths:\n") + b.WriteString(" - path: /\n") + b.WriteString(" pathType: Prefix\n") + b.WriteString(" backend:\n") + b.WriteString(" service:\n") + b.WriteString(" name: mcp-sentinel-ui\n") + b.WriteString(" port:\n") + b.WriteString(" number: 8082\n") + } + return b.String() } diff --git a/internal/cli/platform_ingress_test.go b/internal/cli/platform_ingress_test.go index 578cc74..4a239f4 100644 --- a/internal/cli/platform_ingress_test.go +++ b/internal/cli/platform_ingress_test.go @@ -13,19 +13,27 @@ func TestRenderPlatformIngressManifestNoTLS(t *testing.T) { "traefik.ingress.kubernetes.io/router.entrypoints: web", `- host: "platform.example.com"`, "- path: /api\n", - "- path: /grafana\n", - "- path: /prometheus\n", "- path: /\n", "name: mcp-sentinel-ui", "number: 8082", - "name: grafana", - "name: prometheus", } for _, want := range mustContain { if !strings.Contains(got, want) { t.Fatalf("missing %q in manifest:\n%s", want, got) } } + mustNotContain := []string{ + "- path: /grafana", + "- path: /prometheus", + "name: grafana", + "name: prometheus", + "name: " + platformHTTPRedirectIngressName, + } + for _, unwanted := range mustNotContain { + if strings.Contains(got, unwanted) { + t.Fatalf("manifest must not contain %q (Grafana/Prometheus must not be exposed publicly, redirect ingress only emitted with TLS):\n%s", unwanted, got) + } + } if strings.Contains(got, "tls:") { t.Fatalf("did not expect a TLS block when issuer is empty:\n%s", got) } @@ -34,21 +42,17 @@ func TestRenderPlatformIngressManifestNoTLS(t *testing.T) { } } -func TestRenderPlatformIngressManifestApiBeforeGrafana(t *testing.T) { +func TestRenderPlatformIngressManifestApiBeforeRoot(t *testing.T) { got := renderPlatformIngressManifest("platform.example.com", "") apiIdx := strings.Index(got, "- path: /api") - grafanaIdx := strings.Index(got, "- path: /grafana") rootIdx := strings.Index(got, "- path: /\n") - if apiIdx < 0 || grafanaIdx < 0 || rootIdx < 0 { - t.Fatalf("missing one of /api, /grafana, / paths:\n%s", got) - } - // Traefik matches longer/more-specific prefixes before /, so /api must - // appear in the manifest and be a sibling of /grafana, /prometheus. - if apiIdx > grafanaIdx { - t.Fatalf("/api must be listed before /grafana in the rule for readability:\n%s", got) + if apiIdx < 0 || rootIdx < 0 { + t.Fatalf("missing /api or / paths:\n%s", got) } - if grafanaIdx > rootIdx { - t.Fatalf("/grafana must be listed before / catch-all:\n%s", got) + // Traefik matches longer/more-specific prefixes before /, so /api must be + // declared in the rule before the catch-all /. + if apiIdx > rootIdx { + t.Fatalf("/api must be listed before / catch-all:\n%s", got) } } @@ -61,14 +65,42 @@ func TestRenderPlatformIngressManifestWithTLS(t *testing.T) { `- "platform.mcpruntime.org"`, "secretName: " + platformTLSSecretName, `- host: "platform.mcpruntime.org"`, + "name: " + platformHTTPRedirectIngressName, } for _, want := range mustContain { if !strings.Contains(got, want) { t.Fatalf("missing %q in manifest:\n%s", want, got) } } - if strings.Contains(got, "\n traefik.ingress.kubernetes.io/router.entrypoints: web\n") { - t.Fatalf("did not expect plain web entrypoint when TLS issuer is set:\n%s", got) + if strings.Contains(got, "\n traefik.ingress.kubernetes.io/router.entrypoints: web\n ingressClassName") { + t.Fatalf("primary ingress should be on websecure when TLS issuer is set:\n%s", got) + } +} + +func TestRenderPlatformIngressManifestHTTPRedirectShape(t *testing.T) { + got := renderPlatformIngressManifest("platform.mcpruntime.org", "letsencrypt-prod") + idx := strings.Index(got, "name: "+platformHTTPRedirectIngressName) + if idx < 0 { + t.Fatalf("expected HTTP redirect ingress when TLS configured:\n%s", got) + } + tail := got[idx:] + mustContain := []string{ + "traefik.ingress.kubernetes.io/router.entrypoints: web", + `- host: "platform.mcpruntime.org"`, + "- path: /\n", + "name: mcp-sentinel-ui", + } + for _, want := range mustContain { + if !strings.Contains(tail, want) { + t.Fatalf("HTTP redirect ingress missing %q:\n%s", want, tail) + } + } + // The HTTP redirect ingress must NOT request its own cert / TLS block. + if strings.Contains(tail, "tls:") { + t.Fatalf("HTTP redirect ingress must not have a tls block:\n%s", tail) + } + if strings.Contains(tail, "cert-manager.io/cluster-issuer") { + t.Fatalf("HTTP redirect ingress must not request a certificate:\n%s", tail) } } diff --git a/services/ui/main.go b/services/ui/main.go index cab3c9b..e73873b 100644 --- a/services/ui/main.go +++ b/services/ui/main.go @@ -127,7 +127,9 @@ func main() { } log.Printf("mcp-sentinel-ui listening on :%s", port) - handler := otelhttp.NewHandler(logRequests(mux), "http.server") + httpsMode := envOr("UI_REQUIRE_HTTPS", "auto") + secured := securityHeadersMiddleware(httpsRedirectMiddleware(mux, httpsMode)) + handler := otelhttp.NewHandler(logRequests(secured), "http.server") httpServer := &http.Server{ Addr: ":" + port, Handler: handler, @@ -837,6 +839,108 @@ func writeJSON(w http.ResponseWriter, status int, payload any) { } } +// httpsRedirectMiddleware redirects HTTP requests to HTTPS based on the +// X-Forwarded-Proto header set by an upstream TLS-terminating proxy. +// +// mode controls behavior: +// - "false"/"off"/"0": never redirect (useful in dev or when fronted differently) +// - "true"/"on"/"1": always redirect on X-Forwarded-Proto: http +// - anything else (default "auto"): redirect only when Host looks public +// (not localhost / not a bare IP). This is safe for the bundled Kind dev +// stack where Host is `localhost:18080`. +func httpsRedirectMiddleware(next http.Handler, mode string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if shouldRedirectToHTTPS(r, mode) { + target := "https://" + r.Host + r.URL.RequestURI() + http.Redirect(w, r, target, http.StatusPermanentRedirect) + return + } + next.ServeHTTP(w, r) + }) +} + +func shouldRedirectToHTTPS(r *http.Request, mode string) bool { + switch strings.ToLower(strings.TrimSpace(mode)) { + case "false", "off", "0", "no": + return false + case "true", "on", "1", "yes": + // fall through, force-mode: redirect on http forwarded scheme + default: + if isLocalHost(r.Host) { + return false + } + } + if r.TLS != nil { + return false + } + proto := strings.ToLower(strings.TrimSpace(r.Header.Get("x-forwarded-proto"))) + if proto == "https" { + return false + } + if proto == "http" { + return true + } + // No proxy header. Only redirect in forced mode for non-local hosts. + return strings.EqualFold(strings.TrimSpace(mode), "true") && !isLocalHost(r.Host) +} + +func isLocalHost(host string) bool { + if host == "" { + return true + } + h, _, err := net.SplitHostPort(host) + if err != nil { + h = host + } + h = strings.ToLower(h) + if h == "localhost" || h == "127.0.0.1" || h == "::1" { + return true + } + if ip := net.ParseIP(h); ip != nil { + return true + } + return false +} + +// securityHeadersMiddleware adds baseline security headers on every response. +// HSTS is added only when the request was served over HTTPS so it never asks a +// browser to upgrade dev hostnames that have no certificate. +func securityHeadersMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := w.Header() + h.Set("X-Content-Type-Options", "nosniff") + h.Set("Referrer-Policy", "strict-origin-when-cross-origin") + h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()") + // Google Sign-In needs accounts.google.com for scripts/iframes/connect. + h.Set("Content-Security-Policy", + "default-src 'self'; "+ + "script-src 'self' 'unsafe-inline' https://accounts.google.com https://apis.google.com; "+ + "style-src 'self' 'unsafe-inline'; "+ + "img-src 'self' data: https:; "+ + "font-src 'self' data:; "+ + "connect-src 'self' https://accounts.google.com; "+ + "frame-src https://accounts.google.com; "+ + "frame-ancestors 'none'; "+ + "base-uri 'self'; "+ + "form-action 'self'") + if isHTTPSRequest(r) { + h.Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains") + } + path := r.URL.Path + if strings.HasPrefix(path, "/api") || strings.HasPrefix(path, "/auth/") { + h.Set("Cache-Control", "no-store") + } + next.ServeHTTP(w, r) + }) +} + +func isHTTPSRequest(r *http.Request) bool { + if r.TLS != nil { + return true + } + return strings.EqualFold(strings.TrimSpace(r.Header.Get("x-forwarded-proto")), "https") +} + // logRequests is middleware that logs HTTP requests. // It logs the HTTP method, URL path, response status, and duration. func logRequests(next http.Handler) http.Handler { diff --git a/services/ui/main_test.go b/services/ui/main_test.go index 458f523..62633c4 100644 --- a/services/ui/main_test.go +++ b/services/ui/main_test.go @@ -388,3 +388,131 @@ type roundTripFunc func(*http.Request) (*http.Response, error) func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func TestSecurityHeadersMiddlewareAlwaysSetsBaselineHeaders(t *testing.T) { + handler := securityHeadersMiddleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + + wantContains := map[string]string{ + "X-Content-Type-Options": "nosniff", + "Referrer-Policy": "strict-origin-when-cross-origin", + "Permissions-Policy": "camera=()", + "Content-Security-Policy": "frame-ancestors 'none'", + } + for header, fragment := range wantContains { + got := rec.Header().Get(header) + if !strings.Contains(got, fragment) { + t.Fatalf("%s = %q, want substring %q", header, got, fragment) + } + } + if rec.Header().Get("Strict-Transport-Security") != "" { + t.Fatalf("HSTS should not be set on plain HTTP, got %q", rec.Header().Get("Strict-Transport-Security")) + } +} + +func TestSecurityHeadersMiddlewareSetsHSTSWhenForwardedHTTPS(t *testing.T) { + handler := securityHeadersMiddleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("X-Forwarded-Proto", "https") + handler.ServeHTTP(rec, req) + + if got := rec.Header().Get("Strict-Transport-Security"); !strings.Contains(got, "max-age=") { + t.Fatalf("Strict-Transport-Security = %q, want max-age", got) + } +} + +func TestSecurityHeadersMiddlewareSetsCacheControlOnAPI(t *testing.T) { + handler := securityHeadersMiddleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/runtime/servers", nil)) + if got := rec.Header().Get("Cache-Control"); got != "no-store" { + t.Fatalf("Cache-Control on /api = %q, want no-store", got) + } + + rec = httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/styles.css", nil)) + if got := rec.Header().Get("Cache-Control"); got != "" { + t.Fatalf("Cache-Control on static asset = %q, want empty", got) + } +} + +func TestHTTPSRedirectMiddlewareAutoModeRedirectsPublicHTTP(t *testing.T) { + handler := httpsRedirectMiddleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + t.Fatal("next handler must not be called when redirecting") + }), "auto") + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/dashboard?x=1", nil) + req.Host = "platform.example.com" + req.Header.Set("X-Forwarded-Proto", "http") + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusPermanentRedirect { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusPermanentRedirect) + } + if got := rec.Header().Get("Location"); got != "https://platform.example.com/dashboard?x=1" { + t.Fatalf("Location = %q", got) + } +} + +func TestHTTPSRedirectMiddlewareAutoModeSkipsLocalhost(t *testing.T) { + called := false + handler := httpsRedirectMiddleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + called = true + }), "auto") + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Host = "localhost:18080" + req.Header.Set("X-Forwarded-Proto", "http") + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK || !called { + t.Fatalf("expected pass-through for localhost, got status=%d called=%v", rec.Code, called) + } +} + +func TestHTTPSRedirectMiddlewareDisabledMode(t *testing.T) { + called := false + handler := httpsRedirectMiddleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + called = true + }), "false") + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Host = "platform.example.com" + req.Header.Set("X-Forwarded-Proto", "http") + handler.ServeHTTP(rec, req) + + if !called { + t.Fatal("expected pass-through when UI_REQUIRE_HTTPS=false") + } +} + +func TestHTTPSRedirectMiddlewarePassesThroughHTTPS(t *testing.T) { + called := false + handler := httpsRedirectMiddleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + called = true + }), "auto") + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Host = "platform.example.com" + req.Header.Set("X-Forwarded-Proto", "https") + handler.ServeHTTP(rec, req) + + if !called { + t.Fatal("expected HTTPS request to pass through") + } +}