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

109 lines
2.6 KiB
Go

package hanzodns
import (
"fmt"
"time"
"github.com/google/uuid"
)
// SyncZoneRequest represents a bulk zone sync payload. The owning org is NOT
// taken from the body — it is the caller's validated `owner` claim, applied by
// handleSync — so a sync can never target or overwrite another org's zone.
type SyncZoneRequest struct {
Zone string `json:"zone"`
Records []SyncRecord `json:"records"`
}
// SyncRecord is a record in a bulk sync request.
type SyncRecord struct {
ID string `json:"id,omitempty"`
Name string `json:"name"`
Type RecordType `json:"type"`
TTL uint32 `json:"ttl"`
Content string `json:"content"`
Priority uint16 `json:"priority,omitempty"`
Proxied bool `json:"proxied"`
}
// SyncResponse is returned from the bulk sync endpoint.
type SyncResponse struct {
Zone string `json:"zone"`
RecordCount int `json:"record_count"`
Created int `json:"created"`
Deleted int `json:"deleted"`
}
// BulkSync atomically replaces all records for org's zone. If the zone does not
// exist it is created for org; if it exists but is owned by a DIFFERENT org the
// sync is refused (a caller can never overwrite another tenant's zone). Returns
// counts of records created and deleted.
func (s *Store) BulkSync(org string, req SyncZoneRequest) (*SyncResponse, error) {
key := normZone(req.Zone)
s.mu.Lock()
defer s.notify()
defer s.mu.Unlock()
now := time.Now().UTC()
zd, exists := s.zones[key]
if exists {
if zd.zone.Org != org {
return nil, fmt.Errorf("zone %q already exists", key)
}
} else {
zd = &zoneData{
zone: Zone{
ID: uuid.New().String(),
Name: key,
Org: org,
Status: "active",
Nameservers: []string{"ns1.hanzo.ai.", "ns2.hanzo.ai."},
CreatedAt: now,
UpdatedAt: now,
},
records: make(map[string]*Record),
}
s.zones[key] = zd
}
deleted := len(zd.records)
// Replace all records atomically.
newRecords := make(map[string]*Record, len(req.Records))
for _, sr := range req.Records {
if !ValidRecordTypes[sr.Type] {
continue
}
id := sr.ID
if id == "" {
id = uuid.New().String()
}
ttl := sr.TTL
if ttl == 0 {
ttl = 300
}
newRecords[id] = &Record{
ID: id,
Name: sr.Name,
Type: sr.Type,
TTL: ttl,
Content: sr.Content,
Priority: sr.Priority,
Proxied: sr.Proxied,
CreatedAt: now,
UpdatedAt: now,
}
}
zd.records = newRecords
zd.zone.UpdatedAt = now
return &SyncResponse{
Zone: key,
RecordCount: len(newRecords),
Created: len(newRecords),
Deleted: deleted,
}, nil
}