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.
59 lines
1.6 KiB
Go
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),
|
|
})
|
|
}
|