feat(server): refuse to start without root .zddc; default CORS to empty

Two safe-by-default flips, both opt-out via explicit acknowledgement.

1. --insecure / ZDDC_INSECURE=1: zddc-server now refuses to start when
   no <ZDDC_ROOT>/.zddc exists. With no .zddc anywhere in the chain,
   AllowedWithChain falls through to "HasAnyFile=false → allow" and
   the tree is publicly accessible to anonymous callers — almost never
   what an operator wants on a fresh deployment, and previously a
   silent footgun. The flag is the escape hatch for deliberately-
   public archives (no .zddc anywhere by design).

2. ZDDC_CORS_ORIGIN now defaults to empty (CORS disabled) instead of
   the canonical "https://zddc.varasys.io". The embedded-tools install
   path serves tools and data same-origin, so the default never needed
   to permit cross-origin XHRs from a third-party host. Every deployment
   was implicitly trusting zddc.varasys.io to make authenticated XHRs
   on behalf of every logged-in user; if that origin were ever
   compromised, the blast radius extended to every customer server.
   Operators who deliberately use the CDN-bootstrap pattern or self-
   hosted tools at a different host now set the value explicitly.

Helm chart values updated accordingly: prod default is empty; dev
keeps localhost:8000 for tool-iteration workflows. Existing deployments
that depended on the old defaults will need to either set the value
explicitly or pass --insecure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-04 17:40:34 -05:00
parent 9d5430db81
commit 6b973906c3
4 changed files with 125 additions and 17 deletions

View file

@ -22,7 +22,11 @@ zddc:
rootPath: /srv
addr: ":8080"
emailHeader: X-Auth-Request-Email
corsOrigin: "https://zddc.varasys.io,http://localhost:8000"
# Empty (default) disables CORS — fine for embedded-tools / same-origin.
# Dev typically keeps localhost in here for the iterate-on-tool-builds
# workflow where you load a tool from `./dev-server start` (8000) and
# point it at this server. Add other tool-host origins as needed.
corsOrigin: "http://localhost:8000"
logLevel: debug # full request headers logged; sensitive!
indexPath: ".archive"

View file

@ -27,9 +27,13 @@ zddc:
# Email-header convention from your authenticating reverse proxy.
emailHeader: X-Auth-Request-Email
# Comma-separated CORS allowlist. Set to your tools host, or empty
# to disable CORS entirely (when tools are same-origin).
corsOrigin: "https://zddc.varasys.io"
# Comma-separated CORS allowlist. Empty (default) disables CORS —
# appropriate for the embedded-tools install path where tools are
# served same-origin by zddc-server itself. Set to a specific origin
# only if browser-loaded pages from a different host call back into
# this server (e.g. self-hosted tools at https://tools.acme.com,
# or the CDN-bootstrap pattern from https://zddc.varasys.io).
corsOrigin: ""
# info / warn / error / debug. Production stays on info; debug logs
# every request's full header map (includes cookies/auth tokens).

View file

@ -23,8 +23,9 @@ type Config struct {
LogLevel string // --log-level / ZDDC_LOG_LEVEL — debug/info/warn/error (default info)
IndexPath string // --index-path / ZDDC_INDEX_PATH — virtual archive prefix (default .archive)
EmailHeader string // --email-header / ZDDC_EMAIL_HEADER — auth header name (default X-Auth-Request-Email)
CORSOrigins []string // --cors-origin / ZDDC_CORS_ORIGIN — comma-separated allowlist; default https://zddc.varasys.io; empty disables
CORSOrigins []string // --cors-origin / ZDDC_CORS_ORIGIN — comma-separated allowlist; default empty (CORS disabled); explicit value enables
AccessLog string // --access-log / ZDDC_ACCESS_LOG — file path for tee'd JSON access log; empty = stderr only
Insecure bool // --insecure / ZDDC_INSECURE=1 — opt out of safety checks (currently: allow start without a root .zddc, leaving the tree publicly accessible)
}
// ErrHelpRequested is returned by Load when --help is passed; the caller
@ -72,9 +73,11 @@ func Load(args []string) (Config, error) {
emailHeaderFlag := fs.String("email-header", getEnv("ZDDC_EMAIL_HEADER", "X-Auth-Request-Email"),
"HTTP header carrying the authenticated user's email.")
corsOriginFlag := fs.String("cors-origin", "",
"Comma-separated CORS allowlist. Empty = CORS disabled. Default: ZDDC_CORS_ORIGIN or https://zddc.varasys.io.")
"Comma-separated CORS allowlist. Empty (default) = CORS disabled. Set to your tool-host origin (e.g. https://tools.acme.com) only if browser-loaded pages from that origin call back into this server.")
insecureDirectFlag := fs.Bool("insecure-direct", os.Getenv("ZDDC_INSECURE_DIRECT") == "1",
"Allow plain HTTP on non-loopback addresses (only safe behind an authenticating proxy).")
insecureFlag := fs.Bool("insecure", os.Getenv("ZDDC_INSECURE") == "1",
"Allow startup with no root .zddc file (the tree is then publicly accessible). Default: refuse to start.")
accessLogFlag := fs.String("access-log", os.Getenv("ZDDC_ACCESS_LOG"),
"Tee structured access logs to this file (JSON, size-rotated). "+
"Default: <ZDDC_ROOT>/.zddc.d/logs/access-<hostname>.log. "+
@ -127,6 +130,7 @@ func Load(args []string) (Config, error) {
EmailHeader: *emailHeaderFlag,
CORSOrigins: resolveCORS(corsFlagSet, *corsOriginFlag),
AccessLog: *accessLogFlag,
Insecure: *insecureFlag,
}
// Default Root to the current working directory.
@ -146,6 +150,24 @@ func Load(args []string) (Config, error) {
return Config{}, fmt.Errorf("--root %q is not a directory", cfg.Root)
}
// Refuse to start when the served tree has no root .zddc file. With no
// .zddc anywhere in the chain, AllowedWithChain falls through to its
// "HasAnyFile=false → allow" default, so every directory is publicly
// accessible to anonymous callers. The vast majority of operators do not
// want that — and the few who do (a deliberately public archive) can pass
// --insecure to acknowledge it. See zddc/README.md § Access control.
if !cfg.Insecure {
if _, err := os.Stat(filepath.Join(cfg.Root, ".zddc")); os.IsNotExist(err) {
return Config{}, fmt.Errorf(
"no %s/.zddc file found; the served tree would be publicly accessible to anonymous callers. "+
"Create a starter .zddc (at minimum: `admins: [you@yourcompany.com]`) "+
"or pass --insecure (or ZDDC_INSECURE=1) to acknowledge a deliberately-public deployment",
cfg.Root)
} else if err != nil {
return Config{}, fmt.Errorf("could not stat %s/.zddc: %w", cfg.Root, err)
}
}
// Audit-log default: if neither flag nor env was explicitly set,
// default to <Root>/.zddc.d/logs/access-<hostname>.log so the
// server captures an audit trail by default. Setting the flag/env
@ -211,8 +233,9 @@ func Usage(w io.Writer) {
fs.String("log-level", "info", "Log level: debug, info, warn, error.")
fs.String("index-path", ".archive", "URL segment for the virtual archive index.")
fs.String("email-header", "X-Auth-Request-Email", "HTTP header carrying the authenticated user's email.")
fs.String("cors-origin", "", "Comma-separated CORS allowlist. Empty = CORS disabled.")
fs.String("cors-origin", "", "Comma-separated CORS allowlist. Empty (default) = CORS disabled.")
fs.Bool("insecure-direct", false, "Allow plain HTTP on non-loopback addresses.")
fs.Bool("insecure", false, "Allow startup with no root .zddc file (publicly accessible). Default: refuse.")
fs.String("access-log", "", "Tee structured access logs to this file (JSON, size-rotated). Default <ZDDC_ROOT>/.zddc.d/logs/access-<hostname>.log; --access-log= disables.")
fs.Bool("help", false, "Print this help and exit.")
fs.Bool("version", false, "Print version info and exit.")
@ -222,7 +245,14 @@ func Usage(w io.Writer) {
// resolveCORS implements the precedence rules for the CORS allowlist:
// - flag explicitly set → use flag value (empty = disabled)
// - else env var explicitly set → use env value (empty = disabled)
// - else → default to the canonical upstream
// - else → default to nil (CORS disabled)
//
// Default-empty is intentional: the embedded-tools deployment path (the install
// default) serves tools and data from the same origin, so CORS is unneeded.
// Operators who deliberately load tools from a different origin (e.g. the
// CDN-bootstrap pattern from https://zddc.varasys.io, or self-hosted at
// https://tools.acme.com) opt in by setting the value explicitly. This avoids
// implicit cross-origin trust on third-party domains.
func resolveCORS(flagSet bool, flagValue string) []string {
if flagSet {
return parseCSV(flagValue)
@ -230,7 +260,7 @@ func resolveCORS(flagSet bool, flagValue string) []string {
if v, ok := os.LookupEnv("ZDDC_CORS_ORIGIN"); ok {
return parseCSV(v)
}
return []string{"https://zddc.varasys.io"}
return nil
}
// parseCSV splits a comma-separated list and trims whitespace. Empty

View file

@ -35,11 +35,17 @@ func TestIsLoopbackAddr(t *testing.T) {
func TestLoad(t *testing.T) {
root := t.TempDir()
// Drop a placeholder .zddc so subtests using this root don't trip the
// "no root .zddc → refuse to start" safety check. Tests that explicitly
// exercise the missing-.zddc path use a dedicated tmpdir without one.
if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte("admins: [test@example.com]\n"), 0o644); err != nil {
t.Fatalf("seed root .zddc: %v", err)
}
// Pre-set the env so each subtest can override what it needs.
type envSet map[string]string
clearAll := func() {
for _, k := range []string{"ZDDC_ROOT", "ZDDC_ADDR", "ZDDC_TLS_CERT", "ZDDC_TLS_KEY", "ZDDC_INSECURE_DIRECT", "ZDDC_LOG_LEVEL", "ZDDC_INDEX_PATH", "ZDDC_EMAIL_HEADER", "ZDDC_CORS_ORIGIN"} {
for _, k := range []string{"ZDDC_ROOT", "ZDDC_ADDR", "ZDDC_TLS_CERT", "ZDDC_TLS_KEY", "ZDDC_INSECURE_DIRECT", "ZDDC_INSECURE", "ZDDC_LOG_LEVEL", "ZDDC_INDEX_PATH", "ZDDC_EMAIL_HEADER", "ZDDC_CORS_ORIGIN"} {
os.Unsetenv(k)
}
}
@ -59,7 +65,10 @@ func TestLoad(t *testing.T) {
}{
{
name: "missing root defaults to CWD",
env: envSet{},
// ZDDC_INSECURE=1 because the package's CWD has no .zddc; this test
// is specifically about Root resolution falling back to CWD, not
// about the .zddc safety check.
env: envSet{"ZDDC_INSECURE": "1"},
// ZDDC_ROOT not set → Load falls back to os.Getwd().
check: func(t *testing.T, cfg Config) {
cwd, err := os.Getwd()
@ -95,8 +104,8 @@ func TestLoad(t *testing.T) {
if cfg.EmailHeader != "X-Auth-Request-Email" {
t.Errorf("EmailHeader = %q, want X-Auth-Request-Email", cfg.EmailHeader)
}
if len(cfg.CORSOrigins) != 1 || cfg.CORSOrigins[0] != "https://zddc.varasys.io" {
t.Errorf("CORSOrigins = %v, want [https://zddc.varasys.io]", cfg.CORSOrigins)
if len(cfg.CORSOrigins) != 0 {
t.Errorf("CORSOrigins = %v, want empty (CORS disabled by default)", cfg.CORSOrigins)
}
},
},
@ -237,10 +246,23 @@ func TestLoad(t *testing.T) {
}
}
// tmpRootWithZddc creates a temp dir and seeds a minimal .zddc so the
// post-Root-stat safety check (refuse to start with no root .zddc) does
// not fire. Tests that exercise the safety check explicitly use
// t.TempDir() directly without seeding.
func tmpRootWithZddc(t *testing.T) string {
t.Helper()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, ".zddc"), []byte("admins: [test@example.com]\n"), 0o644); err != nil {
t.Fatalf("seed root .zddc: %v", err)
}
return dir
}
// TestLoadFlags_OverrideEnv: --root flag wins over ZDDC_ROOT env var.
func TestLoadFlags_OverrideEnv(t *testing.T) {
envRoot := t.TempDir()
flagRoot := t.TempDir()
envRoot := tmpRootWithZddc(t)
flagRoot := tmpRootWithZddc(t)
os.Setenv("ZDDC_ROOT", envRoot)
defer os.Unsetenv("ZDDC_ROOT")
@ -255,7 +277,7 @@ func TestLoadFlags_OverrideEnv(t *testing.T) {
// TestLoadFlags_AddrLogLevelFromFlags: arbitrary flags override env defaults.
func TestLoadFlags_AddrLogLevelFromFlags(t *testing.T) {
root := t.TempDir()
root := tmpRootWithZddc(t)
cfg, err := Load([]string{
"--root", root,
"--addr", "127.0.0.1:9999",
@ -282,7 +304,7 @@ func TestLoadFlags_AddrLogLevelFromFlags(t *testing.T) {
// TestLoadFlags_CORSExplicitEmptyDisables: --cors-origin="" explicitly disables CORS.
func TestLoadFlags_CORSExplicitEmptyDisables(t *testing.T) {
root := t.TempDir()
root := tmpRootWithZddc(t)
cfg, err := Load([]string{"--root", root, "--cors-origin", ""})
if err != nil {
t.Fatalf("Load: %v", err)
@ -311,6 +333,10 @@ func TestLoadFlags_VersionRequested(t *testing.T) {
// TestLoadFlags_RootFlagDefaultsToCWD: with no --root and no ZDDC_ROOT, falls back to CWD.
func TestLoadFlags_RootFlagDefaultsToCWD(t *testing.T) {
os.Unsetenv("ZDDC_ROOT")
// ZDDC_INSECURE=1 because the package's CWD has no .zddc; this test is
// specifically about the CWD fallback, not the .zddc safety check.
os.Setenv("ZDDC_INSECURE", "1")
defer os.Unsetenv("ZDDC_INSECURE")
cfg, err := Load([]string{})
if err != nil {
t.Fatalf("Load: %v", err)
@ -320,3 +346,47 @@ func TestLoadFlags_RootFlagDefaultsToCWD(t *testing.T) {
t.Errorf("Root=%q, want CWD=%q", cfg.Root, cwd)
}
}
// TestLoad_MissingRootZddcRefusesStartByDefault: with no .zddc at root and no
// --insecure, Load refuses to start (the public-by-default footgun).
func TestLoad_MissingRootZddcRefusesStartByDefault(t *testing.T) {
root := t.TempDir() // no .zddc seeded
os.Setenv("ZDDC_ROOT", root)
defer os.Unsetenv("ZDDC_ROOT")
_, err := Load([]string{})
if err == nil {
t.Fatal("Load() = nil error, want error about missing root .zddc")
}
if !strings.Contains(err.Error(), ".zddc") || !strings.Contains(err.Error(), "publicly accessible") {
t.Errorf("Load() error = %v, want substring about missing .zddc and public access", err)
}
}
// TestLoad_MissingRootZddcAllowedWithInsecure: --insecure allows startup
// when the root .zddc is missing (acknowledges the public-tree shape).
func TestLoad_MissingRootZddcAllowedWithInsecure(t *testing.T) {
root := t.TempDir() // no .zddc seeded
os.Setenv("ZDDC_ROOT", root)
os.Setenv("ZDDC_INSECURE", "1")
defer os.Unsetenv("ZDDC_ROOT")
defer os.Unsetenv("ZDDC_INSECURE")
cfg, err := Load([]string{})
if err != nil {
t.Fatalf("Load() unexpected error: %v", err)
}
if !cfg.Insecure {
t.Errorf("cfg.Insecure = false, want true (set via ZDDC_INSECURE=1)")
}
}
// TestLoad_MissingRootZddcAllowedWithInsecureFlag: same but via --insecure flag.
func TestLoad_MissingRootZddcAllowedWithInsecureFlag(t *testing.T) {
root := t.TempDir() // no .zddc seeded
cfg, err := Load([]string{"--root", root, "--insecure"})
if err != nil {
t.Fatalf("Load() unexpected error: %v", err)
}
if !cfg.Insecure {
t.Errorf("cfg.Insecure = false, want true (set via --insecure)")
}
}