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.
312 lines
9.3 KiB
Go
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)
|
|
}
|
|
}
|