hub v0.3.8 — CSRF protection + secure session model

- server.go: replace literal hub_session=authenticated with random 64-char hex
  session tokens stored server-side (hubSession map + sync.RWMutex); per-session
  CSRF tokens; CleanupSessions goroutine; SameSite=Lax+Secure cookie; CSRF
  validation in ServeHTTP; csrfToken/csrfField helpers
- configs.go: add html/template import; pass CSRFField/CSRFToken to all template
  renders; renderConfigForm gains r *http.Request parameter
- config_form.html: {{.CSRFField}} in form
- customer_unified.html: meta csrf-token + csrfHeaders() JS; {{.CSRFField}} in
  all 5 POST forms; csrfHeaders() on 3 fetch calls
- main.go: start CleanupSessions goroutine

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-21 16:39:14 +01:00
parent da991fad57
commit 67f53a4ccd
6 changed files with 187 additions and 21 deletions
+18
View File
@@ -158,6 +158,24 @@ The asset manager (`internal/assets/`) scans the assets directory on startup, bu
Protected by bcrypt password + session cookie (7-day expiry). Protected by bcrypt password + session cookie (7-day expiry).
### Authentication & Session Model (`internal/web/server.go`)
- Login generates a **cryptographically random 64-char hex session token** stored server-side in a `map[string]*hubSession` (+ `sync.RWMutex`). The old literal `hub_session=authenticated` cookie is gone.
- Each session also stores a **per-session CSRF token** (separate 64-char hex random value).
- Cookie attributes: `SameSite=Lax`, `Secure` (when TLS), `HttpOnly`, 7-day `Max-Age`.
- `RequireAuth` middleware validates the session token with `subtle.ConstantTimeCompare` and redirects to `/login` on failure.
- `CleanupSessions(ctx)` goroutine runs hourly to purge expired sessions.
### CSRF Protection (`internal/web/server.go`)
Synchronizer-token CSRF protection on all browser POST/DELETE/PATCH operations:
- CSRF validation block runs at the top of `ServeHTTP` before routing.
- Skipped when: no session cookie present (API/Basic-Auth path); or safe methods (GET/HEAD/OPTIONS).
- Token read from `_csrf` form field or `X-CSRF-Token` request header.
- On failure: JSON `{"ok":false,"error":"CSRF token missing or invalid"}` for `/api/` paths; HTTP 403 text otherwise.
- Template delivery: `csrfToken(r)` and `csrfField(r)` helpers inject `CSRFToken` and `CSRFField` into every render data struct via `configs.go`. Templates use `{{.CSRFField}}` in forms and `csrfHeaders()` JS helper for fetch calls.
### Pages ### Pages
- **Dashboard (`/`)** — Fleet overview table showing all customers with live status and event count badges (error+warning in last 24h). Config-only customers (no reports yet) appear as "PENDING" with gray badge. Blocked customers are hidden. Auto-refreshes every 60 seconds. - **Dashboard (`/`)** — Fleet overview table showing all customers with live status and event count badges (error+warning in last 24h). Config-only customers (no reports yet) appear as "PENDING" with gray badge. Blocked customers are hidden. Auto-refreshes every 60 seconds.
+3
View File
@@ -192,6 +192,9 @@ func main() {
} }
webServer.SetVersionChecker(versionChecker) webServer.SetVersionChecker(versionChecker)
// Session cleanup — removes expired sessions every hour
go webServer.CleanupSessions(ctx)
// Prune on startup, then daily at configured time (default 04:30) // Prune on startup, then daily at configured time (default 04:30)
if cfg.Retention.MaxDays > 0 { if cfg.Retention.MaxDays > 0 {
pruneAll(dataStore, cfg.Retention.MaxDays, logger) pruneAll(dataStore, cfg.Retention.MaxDays, logger)
+16 -3
View File
@@ -4,6 +4,7 @@ import (
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"html/template"
"io" "io"
"net/http" "net/http"
"regexp" "regexp"
@@ -106,10 +107,12 @@ func (s *Server) handleConfigList(w http.ResponseWriter, r *http.Request) {
Customers []customerListEntry Customers []customerListEntry
ActiveNav string ActiveNav string
Flash string Flash string
CSRFToken string
}{ }{
Customers: entries, Customers: entries,
ActiveNav: "configs", ActiveNav: "configs",
Flash: r.URL.Query().Get("flash"), Flash: r.URL.Query().Get("flash"),
CSRFToken: s.csrfToken(r),
} }
s.templates.ExecuteTemplate(w, "configs.html", data) s.templates.ExecuteTemplate(w, "configs.html", data)
} }
@@ -276,6 +279,8 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c
Flash string Flash string
ActiveNav string ActiveNav string
CSRFField template.HTML
CSRFToken string
} }
data := pageData{ data := pageData{
@@ -312,6 +317,8 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c
Flash: r.URL.Query().Get("flash"), Flash: r.URL.Query().Get("flash"),
ActiveNav: "configs", ActiveNav: "configs",
CSRFField: s.csrfField(r),
CSRFToken: s.csrfToken(r),
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
@@ -328,11 +335,13 @@ func (s *Server) handleConfigNewForm(w http.ResponseWriter, r *http.Request) {
Overrides map[string]interface{} Overrides map[string]interface{}
ActiveNav string ActiveNav string
Error string Error string
CSRFField template.HTML
}{ }{
IsNew: true, IsNew: true,
Config: &store.CustomerConfig{}, Config: &store.CustomerConfig{},
Overrides: make(map[string]interface{}), Overrides: make(map[string]interface{}),
ActiveNav: "configs", ActiveNav: "configs",
CSRFField: s.csrfField(r),
} }
s.templates.ExecuteTemplate(w, "config_form.html", data) s.templates.ExecuteTemplate(w, "config_form.html", data)
} }
@@ -346,7 +355,7 @@ func (s *Server) handleConfigCreate(w http.ResponseWriter, r *http.Request) {
customerID := strings.TrimSpace(r.FormValue("customer_id")) customerID := strings.TrimSpace(r.FormValue("customer_id"))
if customerID == "" || !validCustomerID.MatchString(customerID) { if customerID == "" || !validCustomerID.MatchString(customerID) {
s.renderConfigForm(w, true, &store.CustomerConfig{ s.renderConfigForm(w, r, true, &store.CustomerConfig{
CustomerName: r.FormValue("customer_name"), CustomerName: r.FormValue("customer_name"),
Domain: r.FormValue("domain"), Domain: r.FormValue("domain"),
Email: r.FormValue("email"), Email: r.FormValue("email"),
@@ -357,7 +366,7 @@ func (s *Server) handleConfigCreate(w http.ResponseWriter, r *http.Request) {
// Check for duplicates // Check for duplicates
existing, _ := s.store.GetCustomerConfig(customerID) existing, _ := s.store.GetCustomerConfig(customerID)
if existing != nil { if existing != nil {
s.renderConfigForm(w, true, &store.CustomerConfig{ s.renderConfigForm(w, r, true, &store.CustomerConfig{
CustomerID: customerID, CustomerID: customerID,
CustomerName: r.FormValue("customer_name"), CustomerName: r.FormValue("customer_name"),
Domain: r.FormValue("domain"), Domain: r.FormValue("domain"),
@@ -418,11 +427,13 @@ func (s *Server) handleConfigEditForm(w http.ResponseWriter, r *http.Request, cu
Overrides map[string]interface{} Overrides map[string]interface{}
ActiveNav string ActiveNav string
Error string Error string
CSRFField template.HTML
}{ }{
IsNew: false, IsNew: false,
Config: cfg, Config: cfg,
Overrides: overrides, Overrides: overrides,
ActiveNav: "configs", ActiveNav: "configs",
CSRFField: s.csrfField(r),
} }
s.templates.ExecuteTemplate(w, "config_form.html", data) s.templates.ExecuteTemplate(w, "config_form.html", data)
} }
@@ -655,7 +666,7 @@ func (s *Server) handleCreateConfigFromReport(w http.ResponseWriter, r *http.Req
} }
// renderConfigForm is a helper to re-render the form with an error. // renderConfigForm is a helper to re-render the form with an error.
func (s *Server) renderConfigForm(w http.ResponseWriter, isNew bool, cfg *store.CustomerConfig, overrides map[string]interface{}, errMsg string) { func (s *Server) renderConfigForm(w http.ResponseWriter, r *http.Request, isNew bool, cfg *store.CustomerConfig, overrides map[string]interface{}, errMsg string) {
if overrides == nil { if overrides == nil {
overrides = make(map[string]interface{}) overrides = make(map[string]interface{})
} }
@@ -665,12 +676,14 @@ func (s *Server) renderConfigForm(w http.ResponseWriter, isNew bool, cfg *store.
Overrides map[string]interface{} Overrides map[string]interface{}
ActiveNav string ActiveNav string
Error string Error string
CSRFField template.HTML
}{ }{
IsNew: isNew, IsNew: isNew,
Config: cfg, Config: cfg,
Overrides: overrides, Overrides: overrides,
ActiveNav: "configs", ActiveNav: "configs",
Error: errMsg, Error: errMsg,
CSRFField: s.csrfField(r),
} }
s.templates.ExecuteTemplate(w, "config_form.html", data) s.templates.ExecuteTemplate(w, "config_form.html", data)
} }
+139 -15
View File
@@ -1,6 +1,10 @@
package web package web
import ( import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"html/template" "html/template"
@@ -10,12 +14,19 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
"gitea.dooplex.hu/admin/felhom-hub/internal/store" "gitea.dooplex.hu/admin/felhom-hub/internal/store"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
// hubSession holds per-session auth and CSRF data.
type hubSession struct {
expiresAt time.Time
csrfToken string
}
// Server handles the dashboard web UI. // Server handles the dashboard web UI.
type Server struct { type Server struct {
store *store.Store store *store.Store
@@ -27,6 +38,9 @@ type Server struct {
staleThreshold time.Duration staleThreshold time.Duration
versionChecker *VersionChecker versionChecker *VersionChecker
templateFetcher *TemplateFetcher templateFetcher *TemplateFetcher
sessions map[string]*hubSession
sessionsMu sync.RWMutex
} }
// New creates a new web server. // New creates a new web server.
@@ -61,6 +75,28 @@ func New(store *store.Store, passwordHash, apiKey, version string, staleThreshol
logger: logger, logger: logger,
templates: tmpl, templates: tmpl,
staleThreshold: staleThreshold, staleThreshold: staleThreshold,
sessions: make(map[string]*hubSession),
}
}
// CleanupSessions removes expired sessions. Call with: go s.CleanupSessions(ctx).
func (s *Server) CleanupSessions(ctx context.Context) {
ticker := time.NewTicker(15 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.sessionsMu.Lock()
now := time.Now()
for t, sess := range s.sessions {
if now.After(sess.expiresAt) {
delete(s.sessions, t)
}
}
s.sessionsMu.Unlock()
}
} }
} }
@@ -78,6 +114,18 @@ func (s *Server) SetTemplateFetcher(tf *TemplateFetcher) {
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path path := r.URL.Path
// CSRF protection for all state-changing requests (web routes only).
// API routes (/api/v1/) are Bearer-token authenticated and exempt.
if r.Method != http.MethodGet && r.Method != http.MethodHead && r.Method != http.MethodOptions {
if path != "/login" && s.passwordHash != "" {
if !s.validateCSRF(r) {
s.logger.Printf("[WARN] CSRF rejected: %s %s from %s", r.Method, path, r.RemoteAddr)
http.Error(w, "CSRF token missing or invalid. Please reload the page.", http.StatusForbidden)
return
}
}
}
switch { switch {
case path == "/": case path == "/":
s.handleDashboard(w, r) s.handleDashboard(w, r)
@@ -190,7 +238,7 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
} }
} }
// RequireAuth wraps a handler with basic authentication. // RequireAuth wraps a handler with session or basic authentication.
func (s *Server) RequireAuth(next http.Handler) http.Handler { func (s *Server) RequireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Skip auth if no password configured // Skip auth if no password configured
@@ -199,49 +247,125 @@ func (s *Server) RequireAuth(next http.Handler) http.Handler {
return return
} }
// Check session cookie // Always allow the login page through (GET and POST)
if cookie, err := r.Cookie("hub_session"); err == nil && cookie.Value == "authenticated" { if r.URL.Path == "/login" {
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
return return
} }
// Check basic auth // Check session cookie (random token stored server-side)
if cookie, err := r.Cookie("hub_session"); err == nil {
s.sessionsMu.RLock()
sess, ok := s.sessions[cookie.Value]
s.sessionsMu.RUnlock()
if ok && time.Now().Before(sess.expiresAt) {
next.ServeHTTP(w, r)
return
}
}
// Check basic auth (for programmatic/CLI access)
_, password, ok := r.BasicAuth() _, password, ok := r.BasicAuth()
if ok && bcrypt.CompareHashAndPassword([]byte(s.passwordHash), []byte(password)) == nil { if ok && bcrypt.CompareHashAndPassword([]byte(s.passwordHash), []byte(password)) == nil {
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
return return
} }
// Show login page for browser requests // Redirect browsers to login page; send 401 for API-like requests
if r.URL.Path == "/login" && r.Method == http.MethodPost { if r.Header.Get("Accept") == "application/json" || r.Header.Get("X-Requested-With") != "" {
s.handleLogin(w, r) w.Header().Set("WWW-Authenticate", `Basic realm="Felhom Hub"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return return
} }
http.Redirect(w, r, "/login", http.StatusFound)
w.Header().Set("WWW-Authenticate", `Basic realm="Felhom Hub"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
}) })
} }
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost { if r.Method == http.MethodPost {
password := r.FormValue("password") password := r.FormValue("password")
if bcrypt.CompareHashAndPassword([]byte(s.passwordHash), []byte(password)) == nil { if s.passwordHash != "" && bcrypt.CompareHashAndPassword([]byte(s.passwordHash), []byte(password)) == nil {
// Generate random session token
b := make([]byte, 32)
_, _ = rand.Read(b)
sessionToken := hex.EncodeToString(b)
// Generate CSRF token
cb := make([]byte, 32)
_, _ = rand.Read(cb)
csrfToken := hex.EncodeToString(cb)
s.sessionsMu.Lock()
s.sessions[sessionToken] = &hubSession{
expiresAt: time.Now().Add(7 * 24 * time.Hour),
csrfToken: csrfToken,
}
s.sessionsMu.Unlock()
isSecure := r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: "hub_session", Name: "hub_session",
Value: "authenticated", Value: sessionToken,
Path: "/", Path: "/",
HttpOnly: true, HttpOnly: true,
MaxAge: 86400 * 7, // 7 days SameSite: http.SameSiteLaxMode,
Secure: isSecure,
MaxAge: 86400 * 7,
}) })
http.Redirect(w, r, "/", http.StatusSeeOther) http.Redirect(w, r, "/", http.StatusSeeOther)
return return
} }
http.Error(w, "Invalid password", http.StatusUnauthorized) // Render login with error
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`<html><head><title>Felhom Hub — Bejelentkezés</title></head><body style="font-family:sans-serif;display:flex;justify-content:center;padding-top:4rem"><form method="post" style="display:flex;flex-direction:column;gap:.75rem;width:300px"><h2>Felhom Hub</h2><p style="color:red">Hibás jelszó</p><input type="password" name="password" placeholder="Jelszó" autofocus style="padding:.5rem;border:1px solid #ccc;border-radius:4px"><button type="submit" style="padding:.5rem;background:#0088cc;color:#fff;border:none;border-radius:4px;cursor:pointer">Bejelentkezés</button></form></body></html>`))
return return
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
w.Write([]byte(`<html><body><form method="post"><input type="password" name="password"><button>Login</button></form></body></html>`)) w.Write([]byte(`<html><head><title>Felhom Hub — Bejelentkezés</title></head><body style="font-family:sans-serif;display:flex;justify-content:center;padding-top:4rem"><form method="post" style="display:flex;flex-direction:column;gap:.75rem;width:300px"><h2>Felhom Hub</h2><input type="password" name="password" placeholder="Jelszó" autofocus style="padding:.5rem;border:1px solid #ccc;border-radius:4px"><button type="submit" style="padding:.5rem;background:#0088cc;color:#fff;border:none;border-radius:4px;cursor:pointer">Bejelentkezés</button></form></body></html>`))
}
// validateCSRF checks the CSRF token for a session-based request.
// Returns true if CSRF is valid or if no session cookie is present (Basic Auth path).
func (s *Server) validateCSRF(r *http.Request) bool {
cookie, err := r.Cookie("hub_session")
if err != nil {
// No session cookie — likely Basic Auth or programmatic access; skip CSRF
return true
}
s.sessionsMu.RLock()
sess, ok := s.sessions[cookie.Value]
s.sessionsMu.RUnlock()
if !ok {
return false
}
submitted := r.FormValue("_csrf")
if submitted == "" {
submitted = r.Header.Get("X-CSRF-Token")
}
return submitted != "" && subtle.ConstantTimeCompare([]byte(submitted), []byte(sess.csrfToken)) == 1
}
// csrfToken returns the CSRF token for the current session.
func (s *Server) csrfToken(r *http.Request) string {
cookie, err := r.Cookie("hub_session")
if err != nil {
return ""
}
s.sessionsMu.RLock()
sess, ok := s.sessions[cookie.Value]
s.sessionsMu.RUnlock()
if !ok {
return ""
}
return sess.csrfToken
}
// csrfField returns an HTML hidden input for embedding in forms.
func (s *Server) csrfField(r *http.Request) template.HTML {
tok := s.csrfToken(r)
return template.HTML(`<input type="hidden" name="_csrf" value="` + template.HTMLEscapeString(tok) + `">`)
} }
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
@@ -24,6 +24,7 @@
{{end}} {{end}}
<form method="POST" action="{{if .IsNew}}/configs/new{{else}}/configs/{{.Config.CustomerID}}/edit{{end}}" class="config-form"> <form method="POST" action="{{if .IsNew}}/configs/new{{else}}/configs/{{.Config.CustomerID}}/edit{{end}}" class="config-form">
{{.CSRFField}}
<div class="card"> <div class="card">
<h2>Customer Identity</h2> <h2>Customer Identity</h2>
<div class="form-grid"> <div class="form-grid">
@@ -6,6 +6,8 @@
<title>{{if .CustomerName}}{{.CustomerName}}{{else}}{{.CustomerID}}{{end}} — Felhom Hub</title> <title>{{if .CustomerName}}{{.CustomerName}}{{else}}{{.CustomerID}}{{end}} — Felhom Hub</title>
<link rel="stylesheet" href="/style.css"> <link rel="stylesheet" href="/style.css">
{{if .HasReports}}<meta http-equiv="refresh" content="60">{{end}} {{if .HasReports}}<meta http-equiv="refresh" content="60">{{end}}
<meta name="csrf-token" content="{{.CSRFToken}}">
<script>function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}</script>
</head> </head>
<body> <body>
<div class="container"> <div class="container">
@@ -52,20 +54,24 @@
<a href="/configs/{{.CustomerID}}/edit" class="btn btn-outline btn-sm">Edit</a> <a href="/configs/{{.CustomerID}}/edit" class="btn btn-outline btn-sm">Edit</a>
{{if .IsBlocked}} {{if .IsBlocked}}
<form method="POST" action="/customers/{{.CustomerID}}/unblock" style="display:inline"> <form method="POST" action="/customers/{{.CustomerID}}/unblock" style="display:inline">
{{.CSRFField}}
<button type="submit" class="btn btn-sm">Unblock</button> <button type="submit" class="btn btn-sm">Unblock</button>
</form> </form>
{{else}} {{else}}
<form method="POST" action="/customers/{{.CustomerID}}/block" style="display:inline" <form method="POST" action="/customers/{{.CustomerID}}/block" style="display:inline"
onsubmit="return confirm('Block this customer? They will be hidden from the Dashboard.')"> onsubmit="return confirm('Block this customer? They will be hidden from the Dashboard.')">
{{.CSRFField}}
<button type="submit" class="btn btn-outline btn-sm">Block</button> <button type="submit" class="btn btn-outline btn-sm">Block</button>
</form> </form>
{{end}} {{end}}
<form method="POST" action="/configs/{{.CustomerID}}/delete" style="display:inline" <form method="POST" action="/configs/{{.CustomerID}}/delete" style="display:inline"
onsubmit="return confirm('Delete configuration for {{.CustomerID}}? This cannot be undone.')"> onsubmit="return confirm('Delete configuration for {{.CustomerID}}? This cannot be undone.')">
{{.CSRFField}}
<button type="submit" class="btn btn-danger btn-sm">Delete</button> <button type="submit" class="btn btn-danger btn-sm">Delete</button>
</form> </form>
{{else}} {{else}}
<form method="POST" action="/customers/{{.CustomerID}}/create-config" style="display:inline"> <form method="POST" action="/customers/{{.CustomerID}}/create-config" style="display:inline">
{{.CSRFField}}
<button type="submit" class="btn btn-sm">Create Config</button> <button type="submit" class="btn btn-sm">Create Config</button>
</form> </form>
{{end}} {{end}}
@@ -301,6 +307,7 @@
</div> </div>
<form method="POST" action="/configs/{{.CustomerID}}/regen-password" style="margin-top: 0.5rem;" <form method="POST" action="/configs/{{.CustomerID}}/regen-password" style="margin-top: 0.5rem;"
onsubmit="return confirm('Regenerate retrieval password? The old password will stop working immediately.')"> onsubmit="return confirm('Regenerate retrieval password? The old password will stop working immediately.')">
{{.CSRFField}}
<button type="submit" class="btn btn-outline btn-sm">Regenerate</button> <button type="submit" class="btn btn-outline btn-sm">Regenerate</button>
</form> </form>
</div> </div>
@@ -560,7 +567,7 @@
btn.disabled = true; btn.disabled = true;
btn.textContent = 'Triggering...'; btn.textContent = 'Triggering...';
msg.style.display = 'none'; msg.style.display = 'none';
fetch('/customers/' + customerID + '/trigger-update', {method: 'POST'}) fetch('/customers/' + customerID + '/trigger-update', {method: 'POST', headers: csrfHeaders()})
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(data) { .then(function(data) {
if (data.ok) { if (data.ok) {
@@ -591,7 +598,7 @@
btn.disabled = true; btn.disabled = true;
btn.textContent = 'Pushing...'; btn.textContent = 'Pushing...';
msg.style.display = 'none'; msg.style.display = 'none';
fetch('/customers/' + customerID + '/push-config', {method: 'POST'}) fetch('/customers/' + customerID + '/push-config', {method: 'POST', headers: csrfHeaders()})
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(data) { .then(function(data) {
if (data.ok) { if (data.ok) {
@@ -622,7 +629,7 @@
btn.disabled = true; btn.disabled = true;
btn.textContent = 'Pulling...'; btn.textContent = 'Pulling...';
msg.style.display = 'none'; msg.style.display = 'none';
fetch('/customers/' + customerID + '/pull-config', {method: 'POST'}) fetch('/customers/' + customerID + '/pull-config', {method: 'POST', headers: csrfHeaders()})
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(data) { .then(function(data) {
if (data.ok) { if (data.ok) {