hub (tests only): five cross-box refusal tests (security review part 3); R-955 filed (100 -> 101 rows)
gates / gates (push) Successful in 6m25s

This commit is contained in:
2026-10-12 17:06:51 +02:00
parent 9d8da61dc3
commit 013ee81369
2 changed files with 86 additions and 1 deletions
+84
View File
@@ -0,0 +1,84 @@
package api
import (
"net/http"
"testing"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// Security review 2026-10-12, Part 3 (R-955): every box-facing route that names a host or customer must refuse a
// key that belongs to a different box. Most routes already had such a test (TestEscrowGet_CrossHostRefused,
// TestGetDesiredState_SelfScoped, …); these five had none. Each asserts the refusal AND that the other box's
// record is unchanged where the route writes. Red-proof (2026-10-12): with handleGetRestoreDirective's host check
// disabled, TestCrossBox_RestoreDirectiveForOtherHostRefused failed (h1's key got 200); the other four were not
// red-proved one by one (R-955).
func newTwoBoxHandler(t *testing.T) (*Handler, *store.Store) {
t.Helper()
h, st := newTwoCustomerHandler(t) // customers c1/ckey and c2/c2key
for _, hst := range []*store.Host{
{HostID: "h1", CustomerID: "c1", APIKey: "h1key"},
{HostID: "h2", CustomerID: "c2", APIKey: "h2key"},
} {
if err := st.UpsertHost(hst); err != nil {
t.Fatalf("UpsertHost %s: %v", hst.HostID, err)
}
}
return h, st
}
func refused(code int) bool { return code == http.StatusUnauthorized || code == http.StatusForbidden }
func TestCrossBox_ReportForOtherCustomerRefused(t *testing.T) {
h, st := newTwoBoxHandler(t)
rr := do(h, http.MethodPost, "/report", "c2key", `{"customer_id":"c1","customer_name":"written by c2"}`)
if !refused(rr.Code) {
t.Fatalf("c2's key reporting as c1: status = %d, want 401/403", rr.Code)
}
if c, _ := st.GetCustomer("c1"); c != nil {
t.Fatalf("c1's report was stored from c2's key: %q", c.ReportJSON)
}
}
func TestCrossBox_EventForOtherCustomerRefused(t *testing.T) {
h, st := newTwoBoxHandler(t)
rr := do(h, http.MethodPost, "/event", "c2key", eventBody("error", "test"))
if !refused(rr.Code) {
t.Fatalf("c2's key posting an event for c1: status = %d, want 401/403", rr.Code)
}
if evs, _ := st.GetRecentEvents("c1", 10); len(evs) != 0 {
t.Fatalf("c1 got %d event(s) from c2's key", len(evs))
}
}
func TestCrossBox_EscrowPutForOtherHostRefused(t *testing.T) {
h, _ := newTwoBoxHandler(t)
rr := do(h, http.MethodPut, "/hosts/h2/escrow", "h1key", `{}`)
if !refused(rr.Code) {
t.Fatalf("h1's key writing h2's escrow: status = %d, want 401/403", rr.Code)
}
}
func TestCrossBox_RestoreDirectiveForOtherHostRefused(t *testing.T) {
h, _ := newTwoBoxHandler(t)
// Arm h2's recovery mode, so the "not in recovery mode" 403 cannot mask a missing host check.
if rr := do(h, http.MethodPut, "/admin/hosts/h2/recovery-mode", globalKey, `{"ttl_seconds":600}`); rr.Code != http.StatusOK {
t.Fatalf("arm h2 recovery mode: status = %d", rr.Code)
}
if rr := do(h, http.MethodGet, "/hosts/h2/restore-directive", "h2key", ""); rr.Code != http.StatusOK {
t.Fatalf("positive control: h2's own key: status = %d, want 200", rr.Code)
}
rr := do(h, http.MethodGet, "/hosts/h2/restore-directive", "h1key", "")
if !refused(rr.Code) {
t.Fatalf("h1's key reading h2's restore directive: status = %d, want 401/403", rr.Code)
}
}
func TestCrossBox_OffsitePasswordForOtherCustomerRefused(t *testing.T) {
h, _ := newTwoBoxHandler(t)
rr := do(h, http.MethodPost, "/offsite/consume-password/c1", "c2key", `{}`)
if !refused(rr.Code) {
t.Fatalf("c2's key on c1's off-site password: status = %d, want 401/403", rr.Code)
}
}