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.
109 lines
2.6 KiB
Go
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
|
|
}
|