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