fix(server): serve root-level tool shells without a root read grant

The app-HTML dispatch branch (/archive.html, /browse.html at root) gated
serving the tool shell on read permission for the root dir — which no
per-project-scoped user has — so the root-level multi-project archive view
(/archive.html?projects=A,B) returned 403 to anyone but a root-elevated
admin. The landing page (/) and ServeDirectory already treat the root path
as a public shell and filter data per-project; the app-HTML branch didn't
get the same bypass.

Skip the read gate when the tool's request dir is the root: the shell is a
static app carrying no data, and the tool's own per-project/per-dir fetches
stay ACL-gated (fs.ListDirectory filters per entry). Non-root tool paths
(/<project>/archive.html) keep their read gate.

Test: a non-root, un-elevated user gets 200 on /archive.html but still 403
on a project directory they can't read.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-22 08:57:24 -05:00
parent 9cec423361
commit ef70f5dcc1
2 changed files with 69 additions and 3 deletions

View file

@ -1160,9 +1160,23 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
requestDir := filepath.Join(cfg.Root, filepath.FromSlash(requestDirRel)) requestDir := filepath.Join(cfg.Root, filepath.FromSlash(requestDirRel))
if apps.AppAvailableAt(cfg.Root, requestDir, app) { if apps.AppAvailableAt(cfg.Root, requestDir, app) {
chain, _ := zddc.EffectivePolicy(cfg.Root, requestDir) chain, _ := zddc.EffectivePolicy(cfg.Root, requestDir)
if allowed, _ := policy.AllowFromChainP(r.Context(), handler.DeciderFromContext(r), chain, handler.PrincipalFromContext(r), urlPath); !allowed { // Root-path tool shells are public, mirroring the
http.Error(w, "Forbidden", http.StatusForbidden) // landing page + ServeDirectory's root bypass: the
return // shell is a static app that carries no data, and the
// tool's own per-project/per-dir fetches are
// independently ACL-gated (fs.ListDirectory filters
// per entry). Gating the shell here would block the
// root-level multi-project archive/browse views for
// any caller without a root-level read grant — which
// no normal (per-project-scoped) user has. Non-root
// tool paths (e.g. /<project>/archive.html) keep the
// read gate so a project you can't read won't serve
// its tool there.
if requestDir != cfg.Root {
if allowed, _ := policy.AllowFromChainP(r.Context(), handler.DeciderFromContext(r), chain, handler.PrincipalFromContext(r), urlPath); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
} }
appsSrv.Serve(w, r, app, chain, requestDir) appsSrv.Serve(w, r, app, chain, requestDir)
return return

View file

@ -216,6 +216,58 @@ func TestDispatchAppsResolution(t *testing.T) {
} }
} }
// TestDispatchRootAppShellPublicButDataGated locks in the root-path tool-shell
// bypass: a non-root, un-elevated user (no read grant anywhere at/under root)
// can GET /archive.html — the shell is a static app, served like the landing
// page — but the underlying data stays ACL-gated, so the same user is still
// Forbidden from reading a project directory they have no grant on. This is
// what makes the root-level multi-project archive (/archive.html?projects=A,B)
// usable by per-project-scoped users without admin elevation.
func TestDispatchRootAppShellPublicButDataGated(t *testing.T) {
root := t.TempDir()
// Root grants only alice; eve has no read grant at root or anywhere under it.
zf := zddc.ZddcFile{
ACL: zddc.ACLRules{Permissions: map[string]string{"alice@example.com": "rwcd"}},
}
if err := zddc.WriteFile(root, zf); err != nil {
t.Fatalf("WriteFile: %v", err)
}
mustMkdir(t, filepath.Join(root, "Project-A"))
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)
}
// eve: no grant anywhere, NOT elevated (ElevatedKey unset → false).
eveReq := func(method, path string) *http.Request {
req := httptest.NewRequest(method, path, nil)
return req.WithContext(context.WithValue(req.Context(), handler.EmailKey, "eve@example.com"))
}
// The shell at root is served regardless of a root read grant.
rec := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, nil, rec, eveReq(http.MethodGet, "/archive.html"))
if rec.Code != http.StatusOK {
t.Fatalf("GET /archive.html as non-root user: status=%d, want 200 (root tool shell is public); body=%s",
rec.Code, rec.Body.String())
}
// ...but data is still ACL-gated: eve cannot read a project she has no grant on.
rec2 := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, nil, rec2, eveReq(http.MethodGet, "/Project-A/"))
if rec2.Code != http.StatusForbidden {
t.Errorf("GET /Project-A/ as non-root user: status=%d, want 403 (data stays ACL-gated)", rec2.Code)
}
}
// silence "imported and not used" if apps not referenced elsewhere — keep // silence "imported and not used" if apps not referenced elsewhere — keep
// import even when we trim test cases later. // import even when we trim test cases later.
var _ = apps.DefaultUpstream var _ = apps.DefaultUpstream