+43
-10
@@ -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