RC-9
All checks were successful
release-tag / release-image (push) Successful in 4m39s

This commit is contained in:
2026-08-11 19:41:59 +02:00
parent ce63ca6851
commit 1f61989c2d
5 changed files with 94 additions and 12 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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")
}
}