RC-9
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
+43 -10
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) {
+24
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")
}
}