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,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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue