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.
105 lines
3.7 KiB
Go
105 lines
3.7 KiB
Go
package hanzodns
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
)
|
|
|
|
// A zone is served by exactly one backend, named by its provider. Authoritative
|
|
// zones live in this CoreDNS store and are answered locally; provider zones are
|
|
// delegated to a third-party control plane (Cloudflare today) via the org's
|
|
// KMS-sealed credential. Both are first-class: an org may run some zones on
|
|
// Hanzo's own nameservers and point others at a connected Cloudflare account.
|
|
const (
|
|
ProviderAuthoritative = "authoritative"
|
|
ProviderCloudflare = "cloudflare"
|
|
)
|
|
|
|
// validProviders is the set of provider names a zone may declare.
|
|
var validProviders = map[string]bool{
|
|
ProviderAuthoritative: true,
|
|
ProviderCloudflare: true,
|
|
}
|
|
|
|
// ProviderZone is a zone as reported by an upstream provider.
|
|
type ProviderZone struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Status string `json:"status"`
|
|
NameServers []string `json:"name_servers"`
|
|
}
|
|
|
|
// ProviderRecord is a record as reported by an upstream provider.
|
|
type ProviderRecord 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"`
|
|
}
|
|
|
|
// RecordInput is the payload for creating or updating a provider record.
|
|
type RecordInput struct {
|
|
Name string
|
|
Type string
|
|
Content string
|
|
TTL uint32
|
|
Proxied bool
|
|
Priority uint16
|
|
}
|
|
|
|
// Provider is the connector contract for a third-party DNS control plane. It
|
|
// mirrors the platform's dns-provider.ts interface: an implementation holds a
|
|
// per-org credential and maps the canonical operations onto the provider API.
|
|
// Records are addressed by the provider's own zone/record ids; ZoneIDForName
|
|
// bridges the Hanzo API (keyed by zone NAME) to that id space.
|
|
type Provider interface {
|
|
Type() string
|
|
ListZones(ctx context.Context) ([]ProviderZone, error)
|
|
ZoneIDForName(ctx context.Context, name string) (string, error)
|
|
ListRecords(ctx context.Context, zoneID string) ([]ProviderRecord, error)
|
|
CreateRecord(ctx context.Context, zoneID string, in RecordInput) (ProviderRecord, error)
|
|
// UpdateRecord applies a PARTIAL update: only the patch's set fields change,
|
|
// so an out-of-band change to the record's other fields upstream is preserved.
|
|
UpdateRecord(ctx context.Context, zoneID, recordID string, patch RecordPatch) (ProviderRecord, error)
|
|
DeleteRecord(ctx context.Context, zoneID, recordID string) error
|
|
}
|
|
|
|
// providerFor builds the Provider for (org, kind).
|
|
//
|
|
// For an authoritative zone it returns (nil, nil): the local store is the
|
|
// backend, so there is nothing to construct. For "cloudflare" it reads the
|
|
// org's scoped API token from KMS using the caller's own bearer (relayed from
|
|
// ctx by kmsSecret) and returns a Cloudflare connector bound to that token. The
|
|
// token is auth-method-agnostic: whether the org connected Cloudflare via an
|
|
// API key or OAuth, both seal the token to the same KMS coordinate, so this
|
|
// path just reads it.
|
|
func providerFor(ctx context.Context, org, kind string) (Provider, error) {
|
|
switch kind {
|
|
case "", ProviderAuthoritative:
|
|
return nil, nil
|
|
case ProviderCloudflare:
|
|
token, err := kmsSecret(ctx, org, "integrations/cloudflare/api_token")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return newCloudflareProvider(token), nil
|
|
default:
|
|
return nil, fmt.Errorf("unknown provider %q", kind)
|
|
}
|
|
}
|
|
|
|
// normProvider validates and normalizes a requested provider name, defaulting
|
|
// to authoritative when unset.
|
|
func normProvider(p string) (string, error) {
|
|
if p == "" {
|
|
return ProviderAuthoritative, nil
|
|
}
|
|
if !validProviders[p] {
|
|
return "", fmt.Errorf("unknown provider %q", p)
|
|
}
|
|
return p, nil
|
|
}
|