From 1f61989c2d4ceb5961c9ea8e077e2c40bfbef29c Mon Sep 17 00:00:00 2001 From: jbergner Date: Tue, 11 Aug 2026 19:41:59 +0200 Subject: [PATCH] RC-9 --- .env.example | 7 ++++- V4.2.4_VPN_ADMIN_COOKIE_FIX.md | 20 ++++++++++++ cmd/customer-service/main.go | 2 +- internal/customer/server.go | 53 ++++++++++++++++++++++++++------ internal/customer/server_test.go | 24 +++++++++++++++ 5 files changed, 94 insertions(+), 12 deletions(-) create mode 100644 V4.2.4_VPN_ADMIN_COOKIE_FIX.md diff --git a/.env.example b/.env.example index 8dcfa15..4172155 100644 --- a/.env.example +++ b/.env.example @@ -15,7 +15,8 @@ JWT_SECRET=replace-with-at-least-32-random-characters ADMIN_USER=admin ADMIN_PASSWORD=replace-with-a-strong-unique-password # Admin session cookies are Secure by default. Set false only for local HTTP dev. -ADMIN_COOKIE_SECURE=true +# Public/proxied HTTPS: true. VPN-only direct HTTP admin: auto. +ADMIN_COOKIE_SECURE=auto # ALLOW_INSECURE_DEV_DEFAULTS=1 # WebSocket hardening. Same-origin browser WebSockets work automatically. Add @@ -115,7 +116,11 @@ CS_HTTP_ADDR=:8090 CS_ADMIN_HTTP_ADDR=:8091 CS_INTERNAL_ADDR=:8092 CS_PUBLIC_BASE_URL=https://customers.example.com +# Public customer portal stays HTTPS-only. CS_COOKIE_SECURE=true +# Private Customer-Service admin (:8091): auto uses Secure over HTTPS and +# non-Secure when accessed directly over an encrypted VPN via HTTP. +CS_ADMIN_COOKIE_SECURE=auto CS_SESSION_TTL=24h CS_ADMIN_USER=admin CS_ADMIN_PASSWORD=replace-with-another-strong-unique-password diff --git a/V4.2.4_VPN_ADMIN_COOKIE_FIX.md b/V4.2.4_VPN_ADMIN_COOKIE_FIX.md new file mode 100644 index 0000000..4ab7414 --- /dev/null +++ b/V4.2.4_VPN_ADMIN_COOKIE_FIX.md @@ -0,0 +1,20 @@ +# V4.2.4 – VPN Admin Cookie Fix + +The public customer portal and the private Customer-Service admin UI now have independent cookie security modes. + +Recommended deployment: + +```env +# Game admin on VPN/direct HTTP; automatically Secure when placed behind HTTPS. +ADMIN_COOKIE_SECURE=auto + +# Public portal remains HTTPS-only. +CS_COOKIE_SECURE=true + +# Customer-Service admin on VPN/direct HTTP; automatically Secure behind HTTPS. +CS_ADMIN_COOKIE_SECURE=auto +``` + +`auto` detects TLS or `X-Forwarded-Proto: https`. A direct HTTP connection through an encrypted VPN therefore receives a non-Secure HttpOnly admin cookie, while an HTTPS reverse proxy receives a Secure cookie. + +The public customer session remains independent and HTTPS-only. HSTS is emitted only on HTTPS requests. diff --git a/cmd/customer-service/main.go b/cmd/customer-service/main.go index 4babc46..b09bf40 100644 --- a/cmd/customer-service/main.go +++ b/cmd/customer-service/main.go @@ -157,7 +157,7 @@ func main() { PublicAddr: env("CS_HTTP_ADDR", ":8090"), AdminAddr: env("CS_ADMIN_HTTP_ADDR", ":8091"), InternalAddr: env("CS_INTERNAL_ADDR", ":8092"), PublicBaseURL: strings.TrimRight(env("CS_PUBLIC_BASE_URL", ""), "/"), GamePublicURL: env("CS_GAME_PUBLIC_URL", "http://127.0.0.1:8080"), GameAdminURL: env("CS_GAME_ADMIN_URL", "http://127.0.0.1:8081"), SharedSecret: env("CUSTOMER_SERVICE_SHARED_SECRET", ""), DockerHost: env("DOCKER_HOST", "unix:///var/run/docker.sock"), WorkerImage: env("CS_WORKER_IMAGE", "neuralhunt-worker:local"), WorkerEntrypoint: env("CS_WORKER_ENTRYPOINT", ""), WorkerNetwork: env("CS_WORKER_NETWORK", "neuralhunt_backend"), WorkerRegisterURL: env("CS_WORKER_REGISTER_URL", "http://customer-service:8092/internal/workers/register"), WorkerAutoPull: boolEnv("CS_WORKER_AUTO_PULL", true), WorkerRegistryUsername: env("CS_WORKER_REGISTRY_USERNAME", ""), WorkerRegistryPassword: env("CS_WORKER_REGISTRY_PASSWORD", ""), WorkerRegistryServer: env("CS_WORKER_REGISTRY_SERVER", ""), WorkerRateMicrosPerMinute: int64(rate*1_000_000 + 0.5), MaxWorkersPerCustomer: intEnv("CS_MAX_WORKERS_PER_CUSTOMER", 20), MaxWorkersGlobal: intEnv("CS_MAX_WORKERS_GLOBAL", 1000), MaxRunningPerCustomer: intEnv("CS_MAX_RUNNING_WORKERS_PER_CUSTOMER", 10), MaxRunningGlobal: intEnv("CS_MAX_RUNNING_WORKERS_GLOBAL", 100), - SessionTTL: durationEnv("CS_SESSION_TTL", 24*time.Hour), CookieSecure: boolEnv("CS_COOKIE_SECURE", true), AdminUser: env("CS_ADMIN_USER", "admin"), AdminPassword: env("CS_ADMIN_PASSWORD", ""), AllowManualCredits: boolEnv("CS_ALLOW_MANUAL_CREDITS", false), + SessionTTL: durationEnv("CS_SESSION_TTL", 24*time.Hour), CookieSecure: boolEnv("CS_COOKIE_SECURE", true), AdminCookieSecureMode: env("CS_ADMIN_COOKIE_SECURE", "auto"), AdminUser: env("CS_ADMIN_USER", "admin"), AdminPassword: env("CS_ADMIN_PASSWORD", ""), AllowManualCredits: boolEnv("CS_ALLOW_MANUAL_CREDITS", false), PayPalEnabled: boolEnv("PAYPAL_ENABLED", false), PayPalEnvironment: env("PAYPAL_ENVIRONMENT", "sandbox"), PayPalWebhookID: env("PAYPAL_WEBHOOK_ID", ""), PayPalLiveApprovalAck: env("PAYPAL_LIVE_APPROVAL_ACK", ""), Packages: pkgs, } if err := validate(cfg); err != nil { diff --git a/internal/customer/server.go b/internal/customer/server.go index d942957..28995db 100644 --- a/internal/customer/server.go +++ b/internal/customer/server.go @@ -44,6 +44,7 @@ type Config struct { MaxRunningGlobal int SessionTTL time.Duration CookieSecure bool + AdminCookieSecureMode string AdminUser, AdminPassword string AllowManualCredits bool PayPalEnabled bool @@ -85,6 +86,14 @@ func NewService(store *Store, docker *DockerClient, paypal *PayPalClient, cfg Co return &Service{store: store, docker: docker, paypal: paypal, cfg: cfg, hc: &http.Client{Timeout: 10 * time.Second}} } +func requestIsHTTPS(r *http.Request) bool { + if r.TLS != nil { + return true + } + proto := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Proto"), ",")[0]) + return strings.EqualFold(proto, "https") +} + func (s *Service) security(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Content-Type-Options", "nosniff") @@ -92,7 +101,9 @@ func (s *Service) security(next http.Handler) http.Handler { w.Header().Set("Referrer-Policy", "no-referrer") w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()") w.Header().Set("Content-Security-Policy", "default-src 'self'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; form-action 'self' https://www.paypal.com https://www.sandbox.paypal.com") - if s.cfg.CookieSecure { + // HSTS is meaningful only for HTTPS responses. In particular, do not emit it + // on the VPN-only plain-HTTP admin listener. + if requestIsHTTPS(r) { w.Header().Set("Strict-Transport-Security", "max-age=31536000") } next.ServeHTTP(w, r) @@ -122,26 +133,48 @@ func (s *Service) cookieSameSite(name string) http.SameSite { return http.SameSiteStrictMode } -func (s *Service) setCookie(w http.ResponseWriter, name, value string, ttl time.Duration) { +func (s *Service) cookieSecure(r *http.Request, name string) bool { + if name != csAdminCookie { + // The public customer portal should normally stay HTTPS-only. + return s.cfg.CookieSecure + } + + // The Customer-Service admin listener is commonly reachable only through an + // encrypted VPN but over plain HTTP. Keep its cookie policy independent from + // the public customer portal. "auto" means Secure behind HTTPS and non-Secure + // for a direct HTTP/VPN connection. + switch strings.ToLower(strings.TrimSpace(s.cfg.AdminCookieSecureMode)) { + case "0", "false", "no", "off": + return false + case "1", "true", "yes", "on": + return true + case "", "auto": + return requestIsHTTPS(r) + default: + return true + } +} + +func (s *Service) setCookie(w http.ResponseWriter, r *http.Request, name, value string, ttl time.Duration) { now := time.Now().UTC() http.SetCookie(w, &http.Cookie{ Name: name, Value: value, Path: "/", HttpOnly: true, - Secure: s.cfg.CookieSecure, + Secure: s.cookieSecure(r, name), SameSite: s.cookieSameSite(name), MaxAge: int(ttl.Seconds()), Expires: now.Add(ttl), }) } -func (s *Service) clearCookie(w http.ResponseWriter, name string) { +func (s *Service) clearCookie(w http.ResponseWriter, r *http.Request, name string) { http.SetCookie(w, &http.Cookie{ Name: name, Value: "", Path: "/", HttpOnly: true, - Secure: s.cfg.CookieSecure, + Secure: s.cookieSecure(r, name), SameSite: s.cookieSameSite(name), MaxAge: -1, Expires: time.Unix(1, 0).UTC(), @@ -250,7 +283,7 @@ func (s *Service) register(w http.ResponseWriter, r *http.Request) { jsonOut(w, 500, map[string]string{"error": "session failed"}) return } - s.setCookie(w, customerCookie, sid, s.cfg.SessionTTL) + s.setCookie(w, r, customerCookie, sid, s.cfg.SessionTTL) jsonOut(w, 201, map[string]string{"id": cid, "username": in.Username}) } func (s *Service) login(w http.ResponseWriter, r *http.Request) { @@ -270,14 +303,14 @@ func (s *Service) login(w http.ResponseWriter, r *http.Request) { jsonOut(w, 500, map[string]string{"error": "session failed"}) return } - s.setCookie(w, customerCookie, sid, s.cfg.SessionTTL) + s.setCookie(w, r, customerCookie, sid, s.cfg.SessionTTL) jsonOut(w, 200, map[string]any{"ok": true}) } func (s *Service) logout(w http.ResponseWriter, r *http.Request) { if c, err := r.Cookie(customerCookie); err == nil { s.store.DeleteSession(r.Context(), c.Value) } - s.clearCookie(w, customerCookie) + s.clearCookie(w, r, customerCookie) jsonOut(w, 200, map[string]bool{"ok": true}) } func (s *Service) me(w http.ResponseWriter, r *http.Request) { @@ -1213,7 +1246,7 @@ func (s *Service) adminLogin(w http.ResponseWriter, r *http.Request) { } sid := RandomToken(32) s.adminSessions.Store(sid, time.Now().Add(12*time.Hour)) - s.setCookie(w, csAdminCookie, sid, 12*time.Hour) + s.setCookie(w, r, csAdminCookie, sid, 12*time.Hour) jsonOut(w, 200, map[string]bool{"ok": true}) } func (s *Service) requireAdmin(next http.Handler) http.Handler { @@ -1237,7 +1270,7 @@ func (s *Service) adminLogout(w http.ResponseWriter, r *http.Request) { if c, err := r.Cookie(csAdminCookie); err == nil { s.adminSessions.Delete(c.Value) } - s.clearCookie(w, csAdminCookie) + s.clearCookie(w, r, csAdminCookie) jsonOut(w, 200, map[string]bool{"ok": true}) } func (s *Service) adminOverview(w http.ResponseWriter, r *http.Request) { diff --git a/internal/customer/server_test.go b/internal/customer/server_test.go index 3b52da6..afa12b4 100644 --- a/internal/customer/server_test.go +++ b/internal/customer/server_test.go @@ -35,3 +35,27 @@ func TestCustomerCookieUsesLaxAndAdminStrict(t *testing.T) { t.Fatalf("admin cookie SameSite = %v; want Strict", got) } } + +func TestAdminCookieSecureAutoForVPNHTTPAndHTTPSProxy(t *testing.T) { + s := &Service{cfg: Config{CookieSecure: true, AdminCookieSecureMode: "auto"}} + + httpReq, err := http.NewRequest(http.MethodGet, "http://192.168.16.3:8091/admin", nil) + if err != nil { + t.Fatal(err) + } + if s.cookieSecure(httpReq, csAdminCookie) { + t.Fatal("admin cookie should not be Secure for direct HTTP/VPN access in auto mode") + } + if !s.cookieSecure(httpReq, customerCookie) { + t.Fatal("public customer cookie must remain Secure") + } + + httpsProxyReq, err := http.NewRequest(http.MethodGet, "http://customer-service:8091/admin", nil) + if err != nil { + t.Fatal(err) + } + httpsProxyReq.Header.Set("X-Forwarded-Proto", "https") + if !s.cookieSecure(httpsProxyReq, csAdminCookie) { + t.Fatal("admin cookie should be Secure behind an HTTPS proxy in auto mode") + } +}