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:
parent
9d5430db81
commit
6b973906c3
4 changed files with 125 additions and 17 deletions
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue