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

59 lines
1.6 KiB
Go

package hanzodns
import (
"encoding/json"
"net/http"
"strings"
)
// SyncRequest is the top-level request body for POST /v1/dns/sync.
type SyncRequest struct {
Zones []SyncZoneRequest `json:"zones"`
}
// handleSync handles POST /v1/dns/sync — bulk pushes zones + records.
func handleSync(w http.ResponseWriter, r *http.Request, store *Store) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "only POST is allowed")
return
}
var body SyncRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "invalid JSON body")
return
}
if len(body.Zones) == 0 {
writeError(w, http.StatusBadRequest, "bad_request", "at least one zone is required")
return
}
// The owning org is the caller's validated `owner` claim, never the body, so
// a sync can only create or replace the caller's own zones.
org := reqOrg(r)
results := make([]SyncResponse, 0, len(body.Zones))
for _, zr := range body.Zones {
if zr.Zone == "" {
writeError(w, http.StatusBadRequest, "bad_request", "zone name is required in each entry")
return
}
resp, err := store.BulkSync(org, zr)
if err != nil {
// A zone owned by another org is refused as a conflict, not a 500.
if strings.Contains(err.Error(), "already exists") {
writeError(w, http.StatusConflict, "conflict", err.Error())
return
}
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
return
}
results = append(results, *resp)
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"synced": results,
"total": len(results),
})
}