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:
parent
9cec423361
commit
ef70f5dcc1
2 changed files with 69 additions and 3 deletions
|
|
@ -1160,10 +1160,24 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
|||
requestDir := filepath.Join(cfg.Root, filepath.FromSlash(requestDirRel))
|
||||
if apps.AppAvailableAt(cfg.Root, requestDir, app) {
|
||||
chain, _ := zddc.EffectivePolicy(cfg.Root, requestDir)
|
||||
// Root-path tool shells are public, mirroring the
|
||||
// landing page + ServeDirectory's root bypass: the
|
||||
// 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)
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// import even when we trim test cases later.
|
||||
var _ = apps.DefaultUpstream
|
||||
|
|
|
|||
Loading…
Reference in a new issue