hub v0.148.0 (code): SECURITY — the customer list and box reports need the operator key (R-951); R-950 rotated; R-952, R-953 filed (97 -> 99 rows)
gates / gates (push) Successful in 6m8s

This commit is contained in:
2026-10-12 16:28:33 +02:00
parent fa4c505a55
commit b67b8cea8f
6 changed files with 142 additions and 4 deletions
+82
View File
@@ -0,0 +1,82 @@
package api
import (
"net/http"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// R-951 (security review 2026-10-12): GET /customers, /customers/{id} and /customers/{id}/history answered with NO
// credential and returned every customer's name, dashboard address and stored box report. Since v0.148.0 they are
// operator/global key only. Red-proof (2026-10-12): with the requireGlobalKey call removed from handleCustomer,
// TestCustomersRoutes_NoCredentialIsRefused failed (GET /customers/c1 answered 200).
const customersTestName = "Teszt Csalad R935"
// newCustomersHandler: customer c1 (key ckey) with one stored report and a host h1 (key hkey) under it; a second
// customer c2 (key c2key).
func newCustomersHandler(t *testing.T) (*Handler, *store.Store) {
t.Helper()
h, st := newTwoCustomerHandler(t)
if err := st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "hkey"}); err != nil {
t.Fatalf("UpsertHost: %v", err)
}
rr := do(h, http.MethodPost, "/report", "ckey", `{"customer_id":"c1","customer_name":"`+customersTestName+`"}`)
if rr.Code != http.StatusOK {
t.Fatalf("seed report: status = %d, body=%s", rr.Code, rr.Body.String())
}
return h, st
}
var customersRoutes = []string{"/customers", "/customers/c1", "/customers/c1/history"}
// Scenario A — a stranger, no Authorization header.
func TestCustomersRoutes_NoCredentialIsRefused(t *testing.T) {
h, _ := newCustomersHandler(t)
for _, p := range customersRoutes {
rr := do(h, http.MethodGet, p, "", "")
if rr.Code != http.StatusUnauthorized && rr.Code != http.StatusForbidden {
t.Errorf("GET %s with no credential: status = %d, want 401/403", p, rr.Code)
}
if strings.Contains(rr.Body.String(), customersTestName) || strings.Contains(rr.Body.String(), `"c1"`) {
t.Errorf("GET %s with no credential: body names a customer: %q", p, rr.Body.String())
}
}
}
// Scenario B — a box's own keys (per-customer, per-host, another household's) never list customers.
func TestCustomersRoutes_BoxKeysAreRefused(t *testing.T) {
h, _ := newCustomersHandler(t)
for _, key := range []string{"ckey", "hkey", "c2key", "not-a-key"} {
for _, p := range customersRoutes {
if rr := do(h, http.MethodGet, p, key, ""); rr.Code != http.StatusForbidden {
t.Errorf("GET %s with box key %q: status = %d, want 403", p, key, rr.Code)
}
}
}
}
// Scenario C — the operator key gets what v0.147.0 returned to anyone.
func TestCustomersRoutes_GlobalKeyUnchanged(t *testing.T) {
h, st := newCustomersHandler(t)
rr := do(h, http.MethodGet, "/customers", globalKey, "")
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), customersTestName) {
t.Fatalf("GET /customers with global key: status = %d body=%s", rr.Code, rr.Body.String())
}
c, err := st.GetCustomer("c1")
if err != nil || c == nil {
t.Fatalf("GetCustomer: %v %v", c, err)
}
rr = do(h, http.MethodGet, "/customers/c1", globalKey, "")
if rr.Code != http.StatusOK || rr.Body.String() != c.ReportJSON {
t.Fatalf("GET /customers/c1 with global key: status = %d, body differs from the stored report", rr.Code)
}
if rr = do(h, http.MethodGet, "/customers/c1/history", globalKey, ""); rr.Code != http.StatusOK {
t.Fatalf("GET /customers/c1/history with global key: status = %d", rr.Code)
}
}
+29
View File
@@ -230,6 +230,17 @@ func (h *Handler) checkAuthHost(r *http.Request) (hostID, customerID string, isG
return host.HostID, host.CustomerID, false, true
}
// requireGlobalKey answers 403 and returns false unless the request carries the operator/global key.
// The same check as the /admin/wg/* handlers (wg.go), in one place for routes that read every customer.
func (h *Handler) requireGlobalKey(w http.ResponseWriter, r *http.Request) bool {
_, _, isGlobal, ok := h.checkAuthHost(r)
if !ok || !isGlobal {
http.Error(w, "Forbidden: global key required", http.StatusForbidden)
return false
}
return true
}
// ServeHTTP routes API requests.
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/v1")
@@ -2415,6 +2426,12 @@ func (h *Handler) handleEvent(w http.ResponseWriter, r *http.Request) {
}
func (h *Handler) handleCustomers(w http.ResponseWriter, r *http.Request) {
// R-951: operator/global key only. These routes return every customer's name, dashboard address and
// stored box report; until v0.148.0 they answered with no credential at all. A box's own key must not
// list customers either (TestCustomersRoutes_*).
if !h.requireGlobalKey(w, r) {
return
}
customers, err := h.store.GetCustomers()
if err != nil {
h.logger.Printf("[ERROR] Failed to get customers: %v", err)
@@ -2458,6 +2475,12 @@ func (h *Handler) handleCustomers(w http.ResponseWriter, r *http.Request) {
}
func (h *Handler) handleCustomer(w http.ResponseWriter, r *http.Request, customerID string) {
// R-951: operator/global key only. These routes return every customer's name, dashboard address and
// stored box report; until v0.148.0 they answered with no credential at all. A box's own key must not
// list customers either (TestCustomersRoutes_*).
if !h.requireGlobalKey(w, r) {
return
}
customer, err := h.store.GetCustomer(customerID)
if err != nil {
h.logger.Printf("[ERROR] Failed to get customer %s: %v", customerID, err)
@@ -2475,6 +2498,12 @@ func (h *Handler) handleCustomer(w http.ResponseWriter, r *http.Request, custome
}
func (h *Handler) handleCustomerHistory(w http.ResponseWriter, r *http.Request, customerID string) {
// R-951: operator/global key only. These routes return every customer's name, dashboard address and
// stored box report; until v0.148.0 they answered with no credential at all. A box's own key must not
// list customers either (TestCustomersRoutes_*).
if !h.requireGlobalKey(w, r) {
return
}
period := r.URL.Query().Get("period")
var since time.Duration
switch period {