From 613092b30ef9b863296a69ba399f399d4d3f17c1 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Thu, 4 Jun 2026 10:39:57 -0500 Subject: [PATCH] feat(server): elevated admins can browse the .zddc.zip config bundle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The site-root .zddc.zip bundle was existence-hidden (404) over HTTP for everyone. Now an active (elevated) admin over its directory can browse it in the file tree like any other zip: GET /.zddc.zip/ lists members, GET /.zddc.zip/ extracts one, and a bare GET downloads it. Everyone else — including the same admin un-elevated — still gets 404 for every URL shape, which additionally closes a prior by-name member read (the old gate only 404'd the bundle base, so /.zddc.zip/ leaked to any reader of the root). The dispatch gate now keys off the bundle segment anywhere in the path and requires activeAdminForBundle (mirrors ActiveAdminForSidecar). The listing (fs.ListDirectory) surfaces the .zddc.d reserve and .zddc.zip bundle only to an active admin, so non-admins don't even see the names under ?hidden=1. Client needs no change: splitExtension('.zddc.zip').extension == 'zip', so browse already renders it as a navigable archive (tree.js isZip). Internal apps.Bundle FS resolution never goes through dispatch, so it's unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) --- zddc/cmd/zddc-server/main.go | 46 +++++++++++++++++++++-- zddc/cmd/zddc-server/main_test.go | 61 +++++++++++++++++++++++++++++++ zddc/internal/fs/tree.go | 9 +++++ 3 files changed, 112 insertions(+), 4 deletions(-) diff --git a/zddc/cmd/zddc-server/main.go b/zddc/cmd/zddc-server/main.go index d1fdf6f..3801e40 100644 --- a/zddc/cmd/zddc-server/main.go +++ b/zddc/cmd/zddc-server/main.go @@ -709,6 +709,30 @@ func splitZipPath(fsRoot, urlPath string) (zipAbs, member string, ok bool) { return "", "", false } +// activeAdminForBundle reports whether the request principal is an active +// (elevated) admin over the directory that holds the .zddc.zip config bundle +// referenced by urlPath. Mirrors handler.ActiveAdminForSidecar: the bundle is +// existence-hidden config for everyone else, but an elevated admin over its +// directory may browse its members and download it. Works for every bundle URL +// shape (bare, trailing-slash listing, and /) since it keys off +// the path segment that precedes the bundle name. +func activeAdminForBundle(cfg config.Config, r *http.Request, urlPath string) bool { + p := handler.PrincipalFromContext(r) + if !p.Elevated || p.Email == "" { + return false + } + parent := make([]string, 0) + for _, seg := range strings.Split(strings.Trim(urlPath, "/"), "/") { + if strings.EqualFold(seg, apps.BundleName) { + break + } + parent = append(parent, seg) + } + dir := filepath.Join(cfg.Root, filepath.FromSlash(strings.Join(parent, "/"))) + chain, _ := zddc.EffectivePolicy(cfg.Root, dir) + return zddc.IsAdminForChain(chain, p.Email) +} + // dispatch routes a request to the appropriate handler. func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, appsSrv *apps.Server, tokens *auth.Store, w http.ResponseWriter, r *http.Request) { // URL paths are case-insensitive: resolve each segment against the @@ -807,10 +831,24 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps } // The site-root config bundle /.zddc.zip is config, not - // browsable content — existence-hidden over HTTP for everyone. The - // server reads its members from the filesystem internally (apps.Bundle) - // to resolve tool HTML, so the 404 here doesn't affect resolution. - if strings.EqualFold(filepath.Base(strings.TrimRight(urlPath, "/")), apps.BundleName) { + // ordinary content: existence-hidden over HTTP for everyone EXCEPT an + // active (elevated) admin over its directory, who may browse it in the + // file tree. For an admin every bundle URL falls through to normal + // handling — GET / lists its members (the zip-as-directory + // intercept below), GET /member extracts one, and a bare + // GET downloads it. Everyone else gets 404 for every form, + // which also keeps individual members from being fetched by name. The + // server reads members from the filesystem internally (apps.Bundle) to + // resolve tool HTML — that path never goes through dispatch, so this + // gate doesn't affect resolution. + bundlePath := false + for _, seg := range segments { + if strings.EqualFold(seg, apps.BundleName) { + bundlePath = true + break + } + } + if bundlePath && !activeAdminForBundle(cfg, r, urlPath) { http.NotFound(w, r) return } diff --git a/zddc/cmd/zddc-server/main_test.go b/zddc/cmd/zddc-server/main_test.go index d0ea327..4a8db55 100644 --- a/zddc/cmd/zddc-server/main_test.go +++ b/zddc/cmd/zddc-server/main_test.go @@ -1132,3 +1132,64 @@ func TestDispatchFileToFormView(t *testing.T) { t.Errorf("no-views file body=%q, want raw YAML", rec4.Body.String()) } } + +// TestDispatchBundleAdminView locks in admin-mode visibility of the site-root +// .zddc.zip config bundle: an active (elevated) admin may browse it as a zip +// directory (list members, extract a member) and download it, while everyone +// else — including the same admin un-elevated, and non-admins — gets 404 for +// every bundle URL shape (closing the previous by-name member read). +func TestDispatchBundleAdminView(t *testing.T) { + root := t.TempDir() + // alice is a root admin; bob is a plain reader. + mustWrite(t, filepath.Join(root, ".zddc"), + "acl:\n permissions:\n \"alice@x\": rwcda\n \"bob@x\": r\nadmins:\n - alice@x\n") + writeRootBundle(t, root, map[string]string{ + "archive.html": "BUNDLE archive", + "sub/note.txt": "a member note", + }) + + idx, err := archive.BuildIndex(root) + if err != nil { + t.Fatalf("BuildIndex: %v", err) + } + cfg := config.Config{Root: root, IndexPath: ".archive", EmailHeader: "X-Auth-Request-Email"} + ring := handler.NewLogRing(10) + appsSrv, err := setupApps(cfg) + if err != nil { + t.Fatalf("setupApps: %v", err) + } + + do := func(path, email string, elevated bool) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, path, nil) + ctx := context.WithValue(req.Context(), handler.EmailKey, email) + ctx = context.WithValue(ctx, handler.ElevatedKey, elevated) + req = req.WithContext(ctx) + rec := httptest.NewRecorder() + dispatch(cfg, idx, ring, appsSrv, nil, rec, req) + return rec + } + + // Elevated admin: member listing, member extract, and bare download all work. + if rec := do("/.zddc.zip/", "alice@x", true); rec.Code != http.StatusOK { + t.Errorf("admin GET /.zddc.zip/ : status=%d body=%s, want 200 listing", rec.Code, rec.Body.String()) + } + if rec := do("/.zddc.zip/archive.html", "alice@x", true); rec.Code != http.StatusOK || + !strings.Contains(rec.Body.String(), "BUNDLE archive") { + t.Errorf("admin GET member: status=%d body=%s, want 200 member bytes", rec.Code, rec.Body.String()) + } + if rec := do("/.zddc.zip", "alice@x", true); rec.Code != http.StatusOK { + t.Errorf("admin GET bare /.zddc.zip : status=%d, want 200 download", rec.Code) + } + + // Same admin un-elevated → 404 (sudo model: powers are per-request). + if rec := do("/.zddc.zip/", "alice@x", false); rec.Code != http.StatusNotFound { + t.Errorf("un-elevated admin GET /.zddc.zip/ : status=%d, want 404", rec.Code) + } + // Non-admin reader → 404 for listing AND by-name member (no leak). + if rec := do("/.zddc.zip/", "bob@x", true); rec.Code != http.StatusNotFound { + t.Errorf("non-admin GET /.zddc.zip/ : status=%d, want 404", rec.Code) + } + if rec := do("/.zddc.zip/archive.html", "bob@x", true); rec.Code != http.StatusNotFound { + t.Errorf("non-admin GET member: status=%d, want 404 (no by-name leak)", rec.Code) + } +} diff --git a/zddc/internal/fs/tree.go b/zddc/internal/fs/tree.go index 0792f0e..eeebd56 100644 --- a/zddc/internal/fs/tree.go +++ b/zddc/internal/fs/tree.go @@ -120,6 +120,15 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath, continue } + // Reserved config — the .zddc.d sidecar reserve and the .zddc.zip + // config bundle — is surfaced only to an active (elevated) admin over + // this directory. Everyone else can't open it anyway (dispatch 404s + // the access), so listing the names would just advertise hidden + // config. The plain .zddc file stays visible/editable (handled below). + if (strings.EqualFold(name, ".zddc.d") || strings.EqualFold(name, ".zddc.zip")) && !parentActiveAdmin { + continue + } + info, err := entry.Info() if err != nil { continue