mirror of
https://github.com/hanzoai/dns.git
synced 2026-10-30 18:40:55 +00:00
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.
159 lines
3.9 KiB
Go
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)
|
|
}
|