Files
zeekay 16e9b4d525 fix(hanzodns): org-scope the store + fail-closed auth (red HIGH/MED/LOW)
Red found the authoritative store had no tenant isolation: only the CF-token
read was org-scoped, so on the shared multi-tenant instance any authenticated
org could list/read/mutate/delete another org's zones and records (DNS hijack /
outage). Fixes:

HIGH — org-scope the store structurally. Zone carries an Org (the validated
`owner` claim, stamped server-side); every Store method takes org and funnels
through zoneForOrg(org,name), so a caller can never reach another org's zone
even if a handler forgets the check. List filters to the caller's org;
get/mutate/delete of a foreign or absent zone is 'not found' (404, no existence
confirmation); zone names are globally unique on the shared nameservers so a
name taken by another org is a conflict. /v1/dns/sync derives the owner from the
ctx principal (never the body — the dead SyncZoneRequest.OrgID is removed) and
refuses to overwrite another org's zone.

MED — auth fails closed. authChain denies (503) when neither OIDC nor a static
key is configured; the anonymous path is reachable only behind an explicit
HANZO_DNS_DEV_INSECURE=1 opt-in (authMiddleware no longer allow-alls on an empty
key). OIDC audience is a comma-separated allowlist (string-or-array aud), and a
token with no exp is rejected.

LOW — Cloudflare updates now PATCH only the caller's changed fields (a stale
local mirror can't revert an out-of-band upstream change); a create/update
mirror failure is logged, not swallowed.

Tests: TestCrossTenantIsolation (org B gets 404 on list/get/create/update/
delete of org A's zone+records, org A's data intact), TestSyncIsOrgScoped,
TestAuthChainFailsClosedByDefault, TestDevInsecureAllows. go test -tags metrics
-race green; go build ./... green.
2026-07-18 10:58:01 -07:00

159 lines
3.9 KiB
Go

package hanzodns
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestBulkSync(t *testing.T) {
store := NewStore()
// Sync a zone with records.
resp, err := store.BulkSync(testOrg, SyncZoneRequest{
Zone: "example.com",
Records: []SyncRecord{
{Name: "www", Type: TypeA, Content: "1.2.3.4", TTL: 300},
{Name: "@", Type: TypeMX, Content: "mail.example.com", TTL: 3600, Priority: 10},
},
})
require.NoError(t, err)
assert.Equal(t, "example.com.", resp.Zone)
assert.Equal(t, 2, resp.RecordCount)
assert.Equal(t, 2, resp.Created)
assert.Equal(t, 0, resp.Deleted)
// Verify records are in store.
records, err := store.ListRecords(testOrg, "example.com")
require.NoError(t, err)
assert.Len(t, records, 2)
// Re-sync with different records — old ones should be replaced.
resp, err = store.BulkSync(testOrg, SyncZoneRequest{
Zone: "example.com",
Records: []SyncRecord{
{Name: "api", Type: TypeA, Content: "5.6.7.8", TTL: 60},
},
})
require.NoError(t, err)
assert.Equal(t, 1, resp.RecordCount)
assert.Equal(t, 1, resp.Created)
assert.Equal(t, 2, resp.Deleted)
// Verify old records are gone.
records, err = store.ListRecords(testOrg, "example.com")
require.NoError(t, err)
assert.Len(t, records, 1)
assert.Equal(t, "api", records[0].Name)
}
func TestBulkSyncCreatesZone(t *testing.T) {
store := NewStore()
// Zone should not exist yet.
assert.Nil(t, store.GetZone(testOrg, "newzone.io"))
resp, err := store.BulkSync(testOrg, SyncZoneRequest{
Zone: "newzone.io",
Records: []SyncRecord{
{Name: "@", Type: TypeA, Content: "10.0.0.1"},
},
})
require.NoError(t, err)
assert.Equal(t, 1, resp.RecordCount)
// Zone should now exist.
z := store.GetZone(testOrg, "newzone.io")
require.NotNil(t, z)
assert.Equal(t, "active", z.Status)
}
func TestBulkSyncSkipsInvalidTypes(t *testing.T) {
store := NewStore()
resp, err := store.BulkSync(testOrg, SyncZoneRequest{
Zone: "test.com",
Records: []SyncRecord{
{Name: "www", Type: TypeA, Content: "1.2.3.4"},
{Name: "bad", Type: RecordType("INVALID"), Content: "x"},
},
})
require.NoError(t, err)
assert.Equal(t, 1, resp.RecordCount) // only the valid one
}
func TestSyncEndpoint(t *testing.T) {
store := NewStore()
mux := http.NewServeMux()
mux.HandleFunc("/v1/dns/sync", func(w http.ResponseWriter, r *http.Request) {
handleSync(w, r, store)
})
body := SyncRequest{
Zones: []SyncZoneRequest{
{
Zone: "test.com",
Records: []SyncRecord{
{Name: "@", Type: TypeA, Content: "10.0.0.1", TTL: 300},
{Name: "www", Type: TypeCNAME, Content: "test.com", TTL: 300},
},
},
{
Zone: "other.com",
Records: []SyncRecord{
{Name: "@", Type: TypeA, Content: "10.0.0.2", TTL: 300},
},
},
},
}
b, _ := json.Marshal(body)
req := httptest.NewRequest(http.MethodPost, "/v1/dns/sync", bytes.NewReader(b)).
WithContext(ctxWithPrincipal(testOrg, "b"))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var result map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &result)
require.NoError(t, err)
assert.Equal(t, float64(2), result["total"])
// Verify both zones exist.
zones := store.ListZones(testOrg)
assert.Len(t, zones, 2)
}
func TestSyncEndpointEmptyZones(t *testing.T) {
store := NewStore()
body := SyncRequest{Zones: []SyncZoneRequest{}}
b, _ := json.Marshal(body)
req := httptest.NewRequest(http.MethodPost, "/v1/dns/sync", bytes.NewReader(b))
w := httptest.NewRecorder()
handleSync(w, req, store)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestSyncEndpointMethodNotAllowed(t *testing.T) {
store := NewStore()
req := httptest.NewRequest(http.MethodGet, "/v1/dns/sync", nil)
w := httptest.NewRecorder()
handleSync(w, req, store)
assert.Equal(t, http.StatusMethodNotAllowed, w.Code)
}