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

312 lines
9.3 KiB
Go

package hanzodns
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
)
// newTestServer builds the API in explicit dev-insecure mode so handler tests
// exercise routing/validation without wiring OIDC or a key. Production is
// fail-closed (see TestAuthChainFailsClosedByDefault); a caller that sets
// HANZO_DNS_API_KEY or HANZO_DNS_OIDC_ISSUER before this call overrides it.
func newTestServer() (*http.ServeMux, *Store) {
os.Setenv("HANZO_DNS_DEV_INSECURE", "1")
store := NewStore()
mux := http.NewServeMux()
registerRoutes(mux, store)
return mux, store
}
func TestHealthEndpoint(t *testing.T) {
mux, _ := newTestServer()
req := httptest.NewRequest(http.MethodGet, "/v1/dns/health", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var body map[string]string
json.NewDecoder(w.Body).Decode(&body)
if body["status"] != "ok" {
t.Fatalf("expected ok, got %s", body["status"])
}
}
func TestAuthRequired(t *testing.T) {
os.Setenv("HANZO_DNS_API_KEY", "test-secret-key")
defer os.Unsetenv("HANZO_DNS_API_KEY")
mux, _ := newTestServer()
// No auth header.
req := httptest.NewRequest(http.MethodGet, "/v1/dns/zones", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 without auth, got %d", w.Code)
}
// Wrong token.
req = httptest.NewRequest(http.MethodGet, "/v1/dns/zones", nil)
req.Header.Set("Authorization", "Bearer wrong-key")
w = httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 with wrong token, got %d", w.Code)
}
// Correct token.
req = httptest.NewRequest(http.MethodGet, "/v1/dns/zones", nil)
req.Header.Set("Authorization", "Bearer test-secret-key")
w = httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 with correct token, got %d", w.Code)
}
}
func TestDevInsecureAllows(t *testing.T) {
os.Unsetenv("HANZO_DNS_API_KEY")
os.Unsetenv("HANZO_DNS_OIDC_ISSUER")
mux, _ := newTestServer() // sets HANZO_DNS_DEV_INSECURE=1
req := httptest.NewRequest(http.MethodGet, "/v1/dns/zones", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 in explicit dev-insecure mode, got %d", w.Code)
}
}
// TestAuthChainFailsClosedByDefault asserts that with NO auth configured (no
// OIDC issuer, no API key, no dev opt-in) the API denies every request — the old
// anonymous allow-all is not reachable in a misconfigured prod pod.
func TestAuthChainFailsClosedByDefault(t *testing.T) {
os.Unsetenv("HANZO_DNS_API_KEY")
os.Unsetenv("HANZO_DNS_OIDC_ISSUER")
os.Unsetenv("HANZO_DNS_DEV_INSECURE")
store := NewStore()
mux := http.NewServeMux()
registerRoutes(mux, store)
req := httptest.NewRequest(http.MethodGet, "/v1/dns/zones", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503 fail-closed with no auth configured, got %d", w.Code)
}
// Health stays public even fail-closed (liveness must be probe-able).
req = httptest.NewRequest(http.MethodGet, "/v1/dns/health", nil)
w = httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected health 200 even fail-closed, got %d", w.Code)
}
}
func TestZoneCRUD(t *testing.T) {
os.Unsetenv("HANZO_DNS_API_KEY")
mux, _ := newTestServer()
// Create zone.
body := `{"zone":"example.com"}`
req := httptest.NewRequest(http.MethodPost, "/v1/dns/zones", bytes.NewBufferString(body))
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("create zone: expected 201, got %d: %s", w.Code, w.Body.String())
}
var zone Zone
json.NewDecoder(w.Body).Decode(&zone)
if zone.Name != "example.com." {
t.Fatalf("expected example.com., got %s", zone.Name)
}
// List zones.
req = httptest.NewRequest(http.MethodGet, "/v1/dns/zones", nil)
w = httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("list zones: expected 200, got %d", w.Code)
}
var listResp struct {
Zones []Zone `json:"zones"`
Total int `json:"total"`
}
json.NewDecoder(w.Body).Decode(&listResp)
if listResp.Total != 1 {
t.Fatalf("expected 1 zone, got %d", listResp.Total)
}
// Get zone.
req = httptest.NewRequest(http.MethodGet, "/v1/dns/zones/example.com", nil)
w = httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("get zone: expected 200, got %d", w.Code)
}
// Duplicate zone.
req = httptest.NewRequest(http.MethodPost, "/v1/dns/zones", bytes.NewBufferString(body))
w = httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusConflict {
t.Fatalf("dup zone: expected 409, got %d", w.Code)
}
// Delete zone.
req = httptest.NewRequest(http.MethodDelete, "/v1/dns/zones/example.com", nil)
w = httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("delete zone: expected 200, got %d", w.Code)
}
// Delete again -- 404.
req = httptest.NewRequest(http.MethodDelete, "/v1/dns/zones/example.com", nil)
w = httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("delete missing zone: expected 404, got %d", w.Code)
}
}
func TestRecordCRUD(t *testing.T) {
os.Unsetenv("HANZO_DNS_API_KEY")
mux, _ := newTestServer()
// Create zone first.
req := httptest.NewRequest(http.MethodPost, "/v1/dns/zones", bytes.NewBufferString(`{"zone":"example.com"}`))
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("create zone: expected 201, got %d", w.Code)
}
// Create record.
recBody := `{"name":"www","type":"A","content":"1.2.3.4","ttl":300}`
req = httptest.NewRequest(http.MethodPost, "/v1/dns/zones/example.com/records", bytes.NewBufferString(recBody))
w = httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("create record: expected 201, got %d: %s", w.Code, w.Body.String())
}
var rec Record
json.NewDecoder(w.Body).Decode(&rec)
if rec.Name != "www" || rec.Content != "1.2.3.4" {
t.Fatalf("unexpected record: %+v", rec)
}
// List records.
req = httptest.NewRequest(http.MethodGet, "/v1/dns/zones/example.com/records", nil)
w = httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("list records: expected 200, got %d", w.Code)
}
// Get record.
req = httptest.NewRequest(http.MethodGet, "/v1/dns/zones/example.com/records/"+rec.ID, nil)
w = httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("get record: expected 200, got %d", w.Code)
}
// Update record (PATCH).
patchBody := `{"content":"5.6.7.8"}`
req = httptest.NewRequest(http.MethodPatch, "/v1/dns/zones/example.com/records/"+rec.ID, bytes.NewBufferString(patchBody))
w = httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("update record: expected 200, got %d: %s", w.Code, w.Body.String())
}
var updated Record
json.NewDecoder(w.Body).Decode(&updated)
if updated.Content != "5.6.7.8" {
t.Fatalf("expected 5.6.7.8, got %s", updated.Content)
}
// Delete record.
req = httptest.NewRequest(http.MethodDelete, "/v1/dns/zones/example.com/records/"+rec.ID, nil)
w = httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("delete record: expected 200, got %d", w.Code)
}
// Delete again -- 404.
req = httptest.NewRequest(http.MethodDelete, "/v1/dns/zones/example.com/records/"+rec.ID, nil)
w = httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("delete missing record: expected 404, got %d", w.Code)
}
}
func TestRecordValidation(t *testing.T) {
os.Unsetenv("HANZO_DNS_API_KEY")
mux, _ := newTestServer()
req := httptest.NewRequest(http.MethodPost, "/v1/dns/zones", bytes.NewBufferString(`{"zone":"example.com"}`))
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
tests := []struct {
name string
body string
code int
}{
{"missing name", `{"type":"A","content":"1.2.3.4"}`, http.StatusBadRequest},
{"missing type", `{"name":"www","content":"1.2.3.4"}`, http.StatusBadRequest},
{"missing content", `{"name":"www","type":"A"}`, http.StatusBadRequest},
{"invalid json", `{bad}`, http.StatusBadRequest},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/v1/dns/zones/example.com/records", bytes.NewBufferString(tt.body))
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != tt.code {
t.Fatalf("expected %d, got %d: %s", tt.code, w.Code, w.Body.String())
}
})
}
}
func TestRecordInNonexistentZone(t *testing.T) {
os.Unsetenv("HANZO_DNS_API_KEY")
mux, _ := newTestServer()
// List records for nonexistent zone.
req := httptest.NewRequest(http.MethodGet, "/v1/dns/zones/nope.com/records", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", w.Code)
}
// Create record in nonexistent zone.
req = httptest.NewRequest(http.MethodPost, "/v1/dns/zones/nope.com/records", bytes.NewBufferString(`{"name":"www","type":"A","content":"1.2.3.4"}`))
w = httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", w.Code)
}
}