diff --git a/helm/zddc-server-dev/values.yaml.example b/helm/zddc-server-dev/values.yaml.example index cfe651e..7e27ea8 100644 --- a/helm/zddc-server-dev/values.yaml.example +++ b/helm/zddc-server-dev/values.yaml.example @@ -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" diff --git a/helm/zddc-server-prod/values.yaml.example b/helm/zddc-server-prod/values.yaml.example index af38f81..4d8dd67 100644 --- a/helm/zddc-server-prod/values.yaml.example +++ b/helm/zddc-server-prod/values.yaml.example @@ -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). diff --git a/zddc/internal/config/config.go b/zddc/internal/config/config.go index 21eaef1..44d997c 100644 --- a/zddc/internal/config/config.go +++ b/zddc/internal/config/config.go @@ -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.d/logs/access-.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 /.zddc.d/logs/access-.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.d/logs/access-.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 diff --git a/zddc/internal/config/config_test.go b/zddc/internal/config/config_test.go index 3d48344..6575db8 100644 --- a/zddc/internal/config/config_test.go +++ b/zddc/internal/config/config_test.go @@ -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)") + } +}