feat(server): elevated admins can browse the .zddc.zip config bundle

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/<member> 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/<member> 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) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-06-04 10:39:57 -05:00
parent ee371c5bb2
commit 613092b30e
3 changed files with 112 additions and 4 deletions

View file

@ -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 <bundle>/<member>) 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_ROOT>/.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 <bundle>/ lists its members (the zip-as-directory
// intercept below), GET <bundle>/member extracts one, and a bare
// GET <bundle> 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
}

View file

@ -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": "<!doctype 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)
}
}

View file

@ -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