This commit is contained in:
@@ -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
|
||||
|
||||
20
V4.2.4_VPN_ADMIN_COOKIE_FIX.md
Normal file
20
V4.2.4_VPN_ADMIN_COOKIE_FIX.md
Normal 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.
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user