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.
247 lines
7.1 KiB
Go
247 lines
7.1 KiB
Go
package hanzodns
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// cfAPIBase is Cloudflare's API v4 origin. Overridable via CLOUDFLARE_API_BASE
|
|
// for tests (an httptest server) and CF-compatible endpoints; read at call time.
|
|
func cfAPIBase() string {
|
|
if v := strings.TrimSpace(os.Getenv("CLOUDFLARE_API_BASE")); v != "" {
|
|
return strings.TrimRight(v, "/")
|
|
}
|
|
return "https://api.cloudflare.com/client/v4"
|
|
}
|
|
|
|
var cfHTTPClient = &http.Client{Timeout: 20 * time.Second}
|
|
|
|
// cloudflareProvider implements Provider against the Cloudflare API v4 using a
|
|
// per-org scoped API token custodied in KMS. The token rides only the
|
|
// Authorization header — never a query parameter, error, or log line.
|
|
type cloudflareProvider struct {
|
|
token string
|
|
base string
|
|
}
|
|
|
|
func newCloudflareProvider(token string) *cloudflareProvider {
|
|
return &cloudflareProvider{token: token, base: cfAPIBase()}
|
|
}
|
|
|
|
func (p *cloudflareProvider) Type() string { return ProviderCloudflare }
|
|
|
|
// cfEnvelope is the shared Cloudflare API v4 response envelope.
|
|
type cfEnvelope struct {
|
|
Success bool `json:"success"`
|
|
Errors []struct {
|
|
Code int `json:"code"`
|
|
Message string `json:"message"`
|
|
} `json:"errors"`
|
|
}
|
|
|
|
func (e cfEnvelope) err(status int) error {
|
|
if e.Success {
|
|
return nil
|
|
}
|
|
if len(e.Errors) > 0 {
|
|
return fmt.Errorf("cloudflare API error (%d): [%d] %s", status, e.Errors[0].Code, e.Errors[0].Message)
|
|
}
|
|
return fmt.Errorf("cloudflare API error (status %d)", status)
|
|
}
|
|
|
|
// cfDo performs a Cloudflare API v4 call, JSON-encoding body, unwrapping the
|
|
// {success, errors, result} envelope, and decoding result into out. It fails
|
|
// closed: a transport error, an unsuccessful envelope, or a non-2xx status
|
|
// yields an error, and the error never contains the token.
|
|
func (p *cloudflareProvider) cfDo(ctx context.Context, method, path string, body, out any) error {
|
|
var reader io.Reader
|
|
if body != nil {
|
|
b, err := json.Marshal(body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
reader = bytes.NewReader(b)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, method, p.base+path, reader)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+p.token)
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
|
|
resp, err := cfHTTPClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("cloudflare request failed")
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode == http.StatusNoContent {
|
|
return nil
|
|
}
|
|
|
|
data, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
|
|
var env cfEnvelope
|
|
if len(data) > 0 {
|
|
_ = json.Unmarshal(data, &env)
|
|
}
|
|
if err := env.err(resp.StatusCode); err != nil {
|
|
return err
|
|
}
|
|
if out != nil {
|
|
var wrap struct {
|
|
Result json.RawMessage `json:"result"`
|
|
}
|
|
if err := json.Unmarshal(data, &wrap); err != nil {
|
|
return fmt.Errorf("cloudflare: malformed response")
|
|
}
|
|
if len(wrap.Result) > 0 {
|
|
if err := json.Unmarshal(wrap.Result, out); err != nil {
|
|
return fmt.Errorf("cloudflare: malformed result")
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type cfZone struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Status string `json:"status"`
|
|
NameServers []string `json:"name_servers"`
|
|
}
|
|
|
|
type cfRecord struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
Content string `json:"content"`
|
|
TTL uint32 `json:"ttl"`
|
|
Proxied bool `json:"proxied"`
|
|
Priority uint16 `json:"priority,omitempty"`
|
|
}
|
|
|
|
func (r cfRecord) toProvider() ProviderRecord {
|
|
return ProviderRecord{
|
|
ID: r.ID, Name: r.Name, Type: r.Type, Content: r.Content,
|
|
TTL: r.TTL, Proxied: r.Proxied, Priority: r.Priority,
|
|
}
|
|
}
|
|
|
|
func (p *cloudflareProvider) ListZones(ctx context.Context) ([]ProviderZone, error) {
|
|
var zones []cfZone
|
|
if err := p.cfDo(ctx, http.MethodGet, "/zones?per_page=50", nil, &zones); err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]ProviderZone, 0, len(zones))
|
|
for _, z := range zones {
|
|
out = append(out, ProviderZone{ID: z.ID, Name: z.Name, Status: z.Status, NameServers: z.NameServers})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// ZoneIDForName resolves a zone NAME to its Cloudflare zone id via the CF
|
|
// name filter, so the Hanzo API's name-keyed routes address CF's id-keyed
|
|
// record endpoints.
|
|
func (p *cloudflareProvider) ZoneIDForName(ctx context.Context, name string) (string, error) {
|
|
name = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(name)), ".")
|
|
var zones []cfZone
|
|
if err := p.cfDo(ctx, http.MethodGet, "/zones?name="+url.QueryEscape(name), nil, &zones); err != nil {
|
|
return "", err
|
|
}
|
|
for _, z := range zones {
|
|
if strings.EqualFold(strings.TrimSuffix(z.Name, "."), name) {
|
|
return z.ID, nil
|
|
}
|
|
}
|
|
return "", fmt.Errorf("zone %q not found in the connected cloudflare account", name)
|
|
}
|
|
|
|
func (p *cloudflareProvider) ListRecords(ctx context.Context, zoneID string) ([]ProviderRecord, error) {
|
|
var recs []cfRecord
|
|
if err := p.cfDo(ctx, http.MethodGet, "/zones/"+url.PathEscape(zoneID)+"/dns_records?per_page=100", nil, &recs); err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]ProviderRecord, 0, len(recs))
|
|
for _, r := range recs {
|
|
out = append(out, r.toProvider())
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (p *cloudflareProvider) CreateRecord(ctx context.Context, zoneID string, in RecordInput) (ProviderRecord, error) {
|
|
var rec cfRecord
|
|
if err := p.cfDo(ctx, http.MethodPost, "/zones/"+url.PathEscape(zoneID)+"/dns_records", cfRecordBody(in), &rec); err != nil {
|
|
return ProviderRecord{}, err
|
|
}
|
|
return rec.toProvider(), nil
|
|
}
|
|
|
|
func (p *cloudflareProvider) UpdateRecord(ctx context.Context, zoneID, recordID string, patch RecordPatch) (ProviderRecord, error) {
|
|
var rec cfRecord
|
|
// PATCH applies only the provided fields, leaving the rest of the upstream
|
|
// record untouched.
|
|
if err := p.cfDo(ctx, http.MethodPatch, "/zones/"+url.PathEscape(zoneID)+"/dns_records/"+url.PathEscape(recordID), cfPatchBody(patch), &rec); err != nil {
|
|
return ProviderRecord{}, err
|
|
}
|
|
return rec.toProvider(), nil
|
|
}
|
|
|
|
func (p *cloudflareProvider) DeleteRecord(ctx context.Context, zoneID, recordID string) error {
|
|
return p.cfDo(ctx, http.MethodDelete, "/zones/"+url.PathEscape(zoneID)+"/dns_records/"+url.PathEscape(recordID), nil, nil)
|
|
}
|
|
|
|
// cfRecordBody builds the Cloudflare create payload. proxied (the orange cloud)
|
|
// is always sent; ttl and priority are included only when set.
|
|
func cfRecordBody(in RecordInput) map[string]any {
|
|
m := map[string]any{
|
|
"type": in.Type,
|
|
"name": in.Name,
|
|
"content": in.Content,
|
|
"proxied": in.Proxied,
|
|
}
|
|
if in.TTL > 0 {
|
|
m["ttl"] = in.TTL
|
|
}
|
|
if in.Priority > 0 {
|
|
m["priority"] = in.Priority
|
|
}
|
|
return m
|
|
}
|
|
|
|
// cfPatchBody builds a Cloudflare PATCH payload from a partial record patch —
|
|
// only the fields the caller set are included, so unspecified fields keep their
|
|
// current upstream value.
|
|
func cfPatchBody(patch RecordPatch) map[string]any {
|
|
m := map[string]any{}
|
|
if patch.Type != nil {
|
|
m["type"] = string(*patch.Type)
|
|
}
|
|
if patch.Name != nil {
|
|
m["name"] = *patch.Name
|
|
}
|
|
if patch.Content != nil {
|
|
m["content"] = *patch.Content
|
|
}
|
|
if patch.TTL != nil {
|
|
m["ttl"] = *patch.TTL
|
|
}
|
|
if patch.Priority != nil {
|
|
m["priority"] = *patch.Priority
|
|
}
|
|
if patch.Proxied != nil {
|
|
m["proxied"] = *patch.Proxied
|
|
}
|
|
return m
|
|
}
|