From f7233237cdbf9531bebf418f7f89941ccb41b29a Mon Sep 17 00:00:00 2001 From: ZDDC Date: Wed, 3 Jun 2026 08:38:28 -0500 Subject: [PATCH] feat(server): collapse dot-guard into one admin-gated .zddc.d reserve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the blanket "block every dot/underscore segment" dispatch guard with a single reserved namespace, .zddc.d/, which is admin-only at every depth. Everything else dot-prefixed is now ordinary ACL-governed content; a leading dot only hides an entry from listings (UI), not from the ACL. .zddc.d/ holds the bearer-token store, so it must stay closed even under a broad operator grant (e.g. `*: rwcd`). The path-tree cascade has no match-this-name-at-any-depth rule, so .zddc.d/ is gated by segment name via a hard rule that overrides operator ACLs — on reads in dispatch (404, existence-hidden) and on writes in authorizeAction (403 defense-in-depth for direct callers). Token validation is unaffected: it reads .zddc.d/tokens directly from the filesystem in ACLMiddleware, before the HTTP-layer gate. The segment match is case-insensitive (strings.EqualFold): ZDDC_ROOT may sit on a case-insensitive filesystem (SMB/CIFS/Azure Files) where .ZDDC.D resolves to the same dir, so a write to a case-varied path — e.g. a MOVE destination header that skips dispatch's canonical case-folding — must not slip past the gate and plant a forged token. The dispatch gate also runs BEFORE the raw .zddc view so the reserve's own cascade (//.zddc.d/.zddc) is existence-hidden rather than leaked by ServeZddcFile. Regression tests cover both. To keep all bookkeeping inside the one reserve, relocate the last two caches under it (both regenerable, no data migration): the apps cache _app/ -> .zddc.d/apps/ and the per-directory MD-conversion cache /.converted/ -> /.zddc.d/converted/. New internal/handler/sidecar.go defines ReservedSidecar + the HasReservedSidecar / ActiveAdminForSidecar predicates used by both the dispatch read-gate and the write-path gate. Co-Authored-By: Claude Opus 4.8 (1M context) --- zddc/cmd/zddc-server/main.go | 88 +++++++------------- zddc/cmd/zddc-server/main_test.go | 71 ++++++++++------ zddc/internal/apps/apps.go | 24 +++--- zddc/internal/handler/converthandler.go | 10 +-- zddc/internal/handler/fileapi.go | 31 +++---- zddc/internal/handler/fileapi_test.go | 32 +++++-- zddc/internal/handler/paths.go | 20 ++--- zddc/internal/handler/profilehandler_test.go | 9 +- zddc/internal/handler/sidecar.go | 70 ++++++++++++++++ 9 files changed, 219 insertions(+), 136 deletions(-) create mode 100644 zddc/internal/handler/sidecar.go diff --git a/zddc/cmd/zddc-server/main.go b/zddc/cmd/zddc-server/main.go index 1ef0dae..936124f 100644 --- a/zddc/cmd/zddc-server/main.go +++ b/zddc/cmd/zddc-server/main.go @@ -509,9 +509,9 @@ func newGzipWrapper() (func(http.Handler) http.HandlerFunc, error) { // setupApps creates the cache + fetcher + server. No seeding, no refresh, // no admin UI — the server fetches once on first request, caches forever -// in /_app/, and falls back to the embedded HTML on any failure. +// in /.zddc.d/apps/, and falls back to the embedded HTML on any failure. func setupApps(cfg config.Config) (*apps.Server, error) { - cache, err := apps.NewCache(filepath.Join(cfg.Root, apps.CacheDirName)) + cache, err := apps.NewCache(filepath.Join(cfg.Root, handler.ReservedSidecar, apps.CacheDirName)) if err != nil { return nil, fmt.Errorf("create cache: %w", err) } @@ -812,70 +812,44 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps // Split path into segments segments := strings.Split(strings.Trim(urlPath, "/"), "/") + // One reserved namespace: /.zddc.d/ holds all server bookkeeping + // (tokens, history, logs, apps + converted caches). It is admin-only at + // every depth — a hard rule that overrides operator ACLs so a broad grant + // (e.g. `*: rwcd`) can never expose the token store — gated here by segment + // name and mirrored on the write path in authorizeAction. 404 (not 403) + // keeps the store existence-hidden from non-admins. + // + // This gate runs BEFORE the raw .zddc view below so a request for the + // reserve's own cascade (e.g. //.zddc.d/.zddc) is existence-hidden + // too — otherwise IsZddcFileRequest would match the leaf and ServeZddcFile + // would leak the reserve's effective cascade to a non-admin. + // + // Everything else dot-/underscore-prefixed is ordinary ACL-governed + // content: the listing pipeline (internal/fs, internal/listing) hides such + // entries from directory views unless ?hidden=1, but direct URL access is + // governed by the ACL chain like any other file. (.profile/.tokens/.auth + // were routed above; non-reserved .zddc GET goes to ServeZddcFile just + // below and .zddc writes fall through to ServeFileAPI; .archive follows.) + // + // Bearer-token validation reads .zddc.d/tokens via the filesystem in + // ACLMiddleware, before this gate, so it is unaffected. + if handler.HasReservedSidecar(urlPath) && !handler.ActiveAdminForSidecar(cfg, r, urlPath) { + http.NotFound(w, r) + return + } + // Raw .zddc YAML view: /.zddc is reachable at every depth // and returns the on-disk file's bytes (Content-Type: application/yaml) // or — when no file exists — a synthetic placeholder body with a // cascade summary so the user can see what's effective here. The - // leaf is carved out of the dot-prefix guard below so GET/HEAD - // land here and PUT/DELETE/POST fall through to ServeFileAPI. + // reserved-sidecar gate above already filtered out .zddc.d/.zddc, so + // GET/HEAD land here for ordinary paths and PUT/DELETE/POST fall + // through to ServeFileAPI. if handler.IsZddcFileRequest(urlPath) && (r.Method == http.MethodGet || r.Method == http.MethodHead) { handler.ServeZddcFile(cfg, w, r) return } - // Reserve dot-prefixed path segments. The listing pipeline already hides - // hidden entries (internal/fs/tree.go:90, projectshandler.go:40), - // but direct URL access would still serve them. 404 here so server - // bookkeeping under the reserved .zddc.d/ sidecar (tokens, history, …) - // cannot be fetched raw. The recognized virtual prefixes (.profile - // handled above, cfg.IndexPath handled below) are explicitly allowed - // through. - // - // (Part B will replace this blanket block with a .zddc.d/ admin-fence so - // dot-content is uniformly ACL-governed; until then the block stands.) - // - // Also reserve the apps cache directory (`_app`): the cached HTML files - // there must be served via the apps resolver (with proper headers and - // ACL), never raw at /_app/...html. The apps cache stays reserved - // even with ?hidden=1 — its files must go through the resolver for - // proper ETag/MIME/X-ZDDC-Source headers. - // - // ?hidden=1 on a GET/HEAD relaxes the dot-prefix guard for everything - // EXCEPT _app. The ACL chain on the resolved path is still the gate; - // anyone who couldn't list this hidden file via fs.ListDirectory - // can't reach it via direct URL either. Write methods stay blocked - // from hidden paths (the file API has its own segment check that - // the ?hidden flag does NOT relax). - hiddenOK := r.URL.Query().Has("hidden") && - (r.Method == http.MethodGet || r.Method == http.MethodHead) - for i, seg := range segments { - if seg == "" { - continue - } - if seg == apps.CacheDirName { - http.NotFound(w, r) - return - } - if !strings.HasPrefix(seg, ".") && !strings.HasPrefix(seg, "_") { - continue - } - if seg == cfg.IndexPath { - continue - } - // `.zddc` is the only writable dot-prefixed file: GET/HEAD was - // handled by ServeZddcFile above; PUT/DELETE/POST fall through - // to ServeFileAPI. Only the LEAF segment carves through — - // `.zddc.d` and other intermediate dot dirs stay reserved. - if seg == handler.ZddcFileBasename && i == len(segments)-1 { - continue - } - if hiddenOK { - continue - } - http.NotFound(w, r) - return - } - // Check for .archive segment in the path. .archive is project-scoped // and addressed at exactly one depth — //.archive/... — even // though offline-built HTML files reference siblings via diff --git a/zddc/cmd/zddc-server/main_test.go b/zddc/cmd/zddc-server/main_test.go index 94d7603..7b9743e 100644 --- a/zddc/cmd/zddc-server/main_test.go +++ b/zddc/cmd/zddc-server/main_test.go @@ -22,14 +22,13 @@ import ( "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" ) -// TestDispatchHidesDotPrefixedSegments asserts the dispatch() guard that -// rejects requests whose URL contains a dot-prefixed segment (other than -// the recognized virtual prefixes .archive and /.profile handled separately). -// -// The guard keeps server bookkeeping under the reserved .zddc.d/ sidecar -// (tokens, history, …) from being fetched raw over HTTP. (Part B will -// replace this blanket block with a .zddc.d/ admin-fence.) -func TestDispatchHidesDotPrefixedSegments(t *testing.T) { +// TestDispatchReservesZddcD asserts the dispatch() gate that reserves the one +// bookkeeping namespace, .zddc.d/. Non-admin requests to .zddc.d/ are 404'd at +// every depth (existence-hidden token store), while every OTHER dot-/underscore- +// prefixed path is ordinary ACL-governed content and is served like any normal +// file (a leading dot only hides it from listings). The recognized virtual +// prefixes (.archive, /.profile) are routed before the gate. +func TestDispatchReservesZddcD(t *testing.T) { root := t.TempDir() // Realistic shape: a project dir, a reserved .zddc.d/ token store, and a @@ -58,21 +57,31 @@ func TestDispatchHidesDotPrefixedSegments(t *testing.T) { path string wantStatus int }{ - // Reserved .zddc.d/ bookkeeping — every shape blocked. + // Reserved .zddc.d/ bookkeeping — every shape 404'd for a non-admin + // (no ACLMiddleware here ⇒ unelevated principal). {"reserved .zddc.d dir", "/.zddc.d/", http.StatusNotFound}, {"reserved .zddc.d token", "/.zddc.d/tokens/abc123", http.StatusNotFound}, + // Case variants must ALSO be reserved: on a case-insensitive root + // (SMB/CIFS/Azure Files) `.ZDDC.D` resolves to the same dir, so the + // gate folds case (HasReservedSidecar uses EqualFold). + {"reserved .ZDDC.D upper", "/.ZDDC.D/tokens/abc123", http.StatusNotFound}, + {"reserved .Zddc.D mixed", "/Project-A/.Zddc.D/history/x", http.StatusNotFound}, + // The reserve's own .zddc cascade is hidden too: the sidecar gate runs + // before the raw .zddc view, so this never reaches ServeZddcFile. + {"reserved .zddc.d/.zddc cascade", "/Project-A/.zddc.d/.zddc", http.StatusNotFound}, - // Hidden segment under a real project dir — also blocked. - {"hidden segment mid path", "/Project-A/.internal/notes.md", http.StatusNotFound}, + // Other dot-prefixed content is no longer blocked by dispatch — it's + // ACL-governed like any file. This harness passes a nil decider, so the + // hidden file serves (the cascade is what gates it in production). + {"hidden dot content served", "/Project-A/.internal/notes.md", http.StatusOK}, - // Sanity: recognized virtual prefixes are NOT blocked. .archive falls - // through to its own handler (which 404s on missing tracking number); - // .profile is handled by ServeProfile and the page itself is public. - // /.admin no longer exists — it is hard-cut and falls through to the - // dot-prefix guard, which 404s. + // Sanity: recognized virtual prefixes are routed before the gate. + // .archive falls through to its own handler (404 on missing tracking); + // .profile is public. /.admin was hard-cut and now simply 404s as a + // not-found file (no dot-prefix guard left to reject it). {".archive prefix passes guard", "/.archive/UNKNOWN", http.StatusNotFound}, // unknown tracking → 404 from archive handler {".profile not blocked by guard", "/.profile/", http.StatusOK}, // public page renders for anonymous - {".admin hard-cut → dot-prefix guard", "/.admin/whoami", http.StatusNotFound}, + {".admin → not found", "/.admin/whoami", http.StatusNotFound}, // Normal files unaffected. {"plain file", "/Project-A/doc.txt", http.StatusOK}, @@ -96,7 +105,7 @@ func TestDispatchHidesDotPrefixedSegments(t *testing.T) { // - GET / serves the landing app from the apps subsystem // - GET /archive.html serves the archive app via fetch+cache // - second GET /archive.html serves from cache (X-ZDDC-Source: cache:) -// - direct URL access to /_zddc/... is rejected +// - direct URL access to the reserved cache (/.zddc.d/apps/...) is rejected func TestDispatchAppsResolution(t *testing.T) { root := t.TempDir() @@ -191,11 +200,13 @@ func TestDispatchAppsResolution(t *testing.T) { t.Errorf("GET /: status=%d", rec3.Code) } - // Direct URL access to /_app/ → 404 + // The apps cache lives under the reserved sidecar (.zddc.d/apps/); direct + // URL access by a non-admin is 404'd by the sidecar gate, so cached HTML + // can only ever be served through the apps resolver (proper headers/ACL). rec4 := httptest.NewRecorder() - dispatch(cfg, idx, ring, appsSrv, nil, rec4, httptest.NewRequest(http.MethodGet, "/_app/foo.html", nil)) + dispatch(cfg, idx, ring, appsSrv, nil, rec4, httptest.NewRequest(http.MethodGet, "/.zddc.d/apps/foo.html", nil)) if rec4.Code != http.StatusNotFound { - t.Errorf("/_app/ direct: status=%d, want 404", rec4.Code) + t.Errorf("/.zddc.d/apps/ direct: status=%d, want 404", rec4.Code) } // Folder availability rules: classifier should NOT be served at root @@ -428,13 +439,25 @@ func TestDispatchZddcWriteRouting(t *testing.T) { t.Fatalf("PUT /.zddc by stranger: want 403, got %d body=%s", rec.Code, rec.Body.String()) } - // Intermediate .zddc.d segments stay reserved — only the LEAF .zddc - // is carved through. A PUT to /.zddc.d/foo must 404 at the guard. + // The reserved .zddc.d/ sidecar is admin-only, but to an admin it's normal + // files — an elevated root admin can PUT into it. req = withAuth(httptest.NewRequest(http.MethodPut, "/.zddc.d/something", bytes.NewReader([]byte("x"))), "admin@example.com", true) rec = httptest.NewRecorder() dispatch(cfg, idx, ring, nil, nil, rec, req) + if rec.Code != http.StatusCreated { + t.Fatalf("PUT /.zddc.d/something by admin: want 201, got %d body=%s", rec.Code, rec.Body.String()) + } + + // A non-admin (even elevated) is hard-denied on .zddc.d/ — the dispatch + // gate 404s it (existence-hidden) before the file API is reached, keeping + // the token store closed regardless of any operator ACL. (The file API's + // own authorizeAction 403 is the defense-in-depth layer for direct + // callers; see fileapi_test.go TestFileAPI_DotContentAllowedButZddcDReserved.) + req = withAuth(httptest.NewRequest(http.MethodPut, "/.zddc.d/evil", bytes.NewReader([]byte("x"))), "stranger@example.com", true) + rec = httptest.NewRecorder() + dispatch(cfg, idx, ring, nil, nil, rec, req) if rec.Code != http.StatusNotFound { - t.Fatalf("PUT /.zddc.d/something: want 404 (reserved segment), got %d", rec.Code) + t.Fatalf("PUT /.zddc.d/evil by stranger: want 404, got %d body=%s", rec.Code, rec.Body.String()) } } diff --git a/zddc/internal/apps/apps.go b/zddc/internal/apps/apps.go index 940b783..9ab35bf 100644 --- a/zddc/internal/apps/apps.go +++ b/zddc/internal/apps/apps.go @@ -18,15 +18,15 @@ // // Spec forms (each is a string value in `.zddc apps:`): // -// :stable / :v0.0.4 — channel-only -// stable / v0.0.4 / 0.0.4 — channel-only (no leading colon) -// https://host/path — URL-prefix only (combines with cascade channel) -// https://host/path:stable — URL-prefix + channel (composes) -// https://host/path/file.html — terminal full URL (used as-is) -// ./local.html / /abs/local.html — terminal local path +// :stable / :v0.0.4 — channel-only +// stable / v0.0.4 / 0.0.4 — channel-only (no leading colon) +// https://host/path — URL-prefix only (combines with cascade channel) +// https://host/path:stable — URL-prefix + channel (composes) +// https://host/path/file.html — terminal full URL (used as-is) +// ./local.html / /abs/local.html — terminal local path // // No background refresh, no SHA-256 verification. To pick up new upstream -// bytes, delete the cache file (or the whole _app/ tree). +// bytes, delete the cache file (or the whole .zddc.d/apps/ tree). package apps import ( @@ -50,10 +50,12 @@ const DefaultUpstreamReleases = DefaultUpstream + "/releases" // specifies one. const DefaultChannel = "stable" -// CacheDirName is the directory under ZDDC_ROOT where fetched URL sources -// are cached. The leading underscore excludes it from project listings; -// dispatch additionally blocks direct URL access. -const CacheDirName = "_app" +// CacheDirName is the directory under /.zddc.d/ where fetched URL +// sources are cached. Living under the reserved .zddc.d/ sidecar means the +// cache is hidden from listings and admin-gated for direct URL access like all +// other server bookkeeping (see handler.ReservedSidecar); the resolver itself +// reads/writes it via the filesystem, not over HTTP. +const CacheDirName = "apps" // DefaultAppsKey is the special key in `apps:` that provides the baseline // URL prefix and channel for any app not overridden per-name. Cascades diff --git a/zddc/internal/handler/converthandler.go b/zddc/internal/handler/converthandler.go index 1c95c29..66ea426 100644 --- a/zddc/internal/handler/converthandler.go +++ b/zddc/internal/handler/converthandler.go @@ -28,7 +28,7 @@ import ( // // The source file's read policy (enforced by the dispatcher before this // handler runs) gates the response. The converted bytes are cached at -// /.converted/., with mtime synced to the source — so a +// /.zddc.d/converted/., with mtime synced to the source — so a // fast-path GET that finds a fresh cache hit serves the disk file via // http.ServeContent without invoking pandoc at all. // @@ -36,7 +36,7 @@ import ( // 1. Reads source bytes. // 2. Walks the .zddc cascade to assemble the convert.Metadata. // 3. Calls convert.ToDocx / ToHTML / ToPDF (containerised pandoc). -// 4. Atomically writes the result to .converted/ and syncs mtime. +// 4. Atomically writes the result to .zddc.d/converted/ and syncs mtime. // 5. Serves it. // // Concurrent requests for the same URL share a single conversion via @@ -121,7 +121,7 @@ func ServeConverted(cfg config.Config, w http.ResponseWriter, r *http.Request, s base := strings.TrimSuffix(filepath.Base(srcAbs), filepath.Ext(srcAbs)) dir := filepath.Dir(srcAbs) - cacheDir := filepath.Join(dir, ".converted") + cacheDir := filepath.Join(dir, ReservedSidecar, "converted") cacheAbs := filepath.Join(cacheDir, base+"."+format) // Fast path: cached file present and mtime-equal to source. @@ -290,7 +290,7 @@ func contentDispositionFor(format, base string) string { return fmt.Sprintf(`inline; filename="%s.%s"`, base, format) } -// purgeConverted removes the cached .converted/.{docx,html,pdf} +// purgeConverted removes the cached .zddc.d/converted/.{docx,html,pdf} // sidecars for an .md source. Called from the file API after a // successful PUT/DELETE/MOVE so the next GET ?convert= regenerates. // Best-effort: errors (including "directory doesn't exist") are @@ -303,7 +303,7 @@ func purgeConverted(srcAbs string) { dir := filepath.Dir(srcAbs) base := strings.TrimSuffix(filepath.Base(srcAbs), filepath.Ext(srcAbs)) for _, ext := range []string{".docx", ".html", ".pdf"} { - _ = os.Remove(filepath.Join(dir, ".converted", base+ext)) + _ = os.Remove(filepath.Join(dir, ReservedSidecar, "converted", base+ext)) } } diff --git a/zddc/internal/handler/fileapi.go b/zddc/internal/handler/fileapi.go index e215300..364a9f9 100644 --- a/zddc/internal/handler/fileapi.go +++ b/zddc/internal/handler/fileapi.go @@ -96,24 +96,10 @@ func resolveTargetPath(cfg config.Config, urlPath string) (absPath, cleanURL str cleanURL += "/" } - // Reject hidden / reserved segments. Mirrors dispatch's guard, - // applied here too because external callers reach ServeFileAPI - // only via dispatch — but defense in depth costs nothing. - // Carve-out: `.zddc` as a leaf segment is writable (admin-gated) - // via the file API. Other dot/underscore segments stay reserved. - segs := strings.Split(strings.Trim(cleanURL, "/"), "/") - for i, seg := range segs { - if seg == "" { - continue - } - if seg == ZddcFileBasename && i == len(segs)-1 { - continue - } - if seg == "_app" || strings.HasPrefix(seg, ".") || strings.HasPrefix(seg, "_") { - return "", "", false, http.StatusNotFound, "reserved path segment" - } - } - + // Dot-/underscore-prefixed paths are ordinary ACL-governed content now; + // the one reserved namespace, .zddc.d/, is admin-gated in authorizeAction + // (which all write verbs funnel through) rather than blocked here, so an + // admin can read/write the sidecar like normal files. See sidecar.go. rel := filepath.FromSlash(strings.TrimPrefix(strings.TrimSuffix(cleanURL, "/"), "/")) abs := filepath.Join(cfg.Root, rel) if !strings.HasPrefix(abs, cfg.Root+string(filepath.Separator)) && abs != cfg.Root { @@ -153,6 +139,15 @@ func authorizeAction(cfg config.Config, w http.ResponseWriter, r *http.Request, slog.Warn("file API ACL chain error", "path", absPath, "err", err) } + // Hard reserve: writes anywhere under a .zddc.d/ segment are admin-only, + // and this overrides operator ACLs — a broad grant (e.g. `*: rwcd`) must + // never let a non-admin write the token store. Denying here (before the + // decider) leaves the admin path to proceed normally below. See sidecar.go. + if HasReservedSidecar(urlPath) && !ActiveAdminForSidecar(cfg, r, urlPath) { + writeForbidden(w, action) + return false + } + decider := DeciderFromContext(r) allowed, _ := policy.AllowActionFromChainP(r.Context(), decider, chain, p, urlPath, action) if !allowed { diff --git a/zddc/internal/handler/fileapi_test.go b/zddc/internal/handler/fileapi_test.go index 71bbb8e..aea37cd 100644 --- a/zddc/internal/handler/fileapi_test.go +++ b/zddc/internal/handler/fileapi_test.go @@ -206,16 +206,34 @@ func TestFileAPI_PutDenyForbiddenOverwriteVerb(t *testing.T) { } } -func TestFileAPI_PutHiddenSegmentRejected(t *testing.T) { +func TestFileAPI_DotContentAllowedButZddcDReserved(t *testing.T) { _, do, _ := fileAPITestSetup(t, nil, nil) - // .zddc as a leaf is carved out — gated on admin authority via the - // decider, not blocked at the segment guard. Every other dot/ - // underscore segment stays reserved. - for _, p := range []string{"/foo/.hidden", "/_app/spoof.html", "/_template/x", "/.zddc.d/x"} { + // Dot-/underscore-prefixed paths are ordinary ACL-governed content now: + // alice has a broad rwcd grant via *@example.com, so these writes succeed. + // (A leading dot only hides an entry from listings, not from the ACL.) + for _, p := range []string{"/foo/.hidden", "/_app/spoof.html", "/_template/x"} { rec := do(http.MethodPut, p, "alice@example.com", []byte("x"), nil) - if rec.Code != http.StatusNotFound { - t.Fatalf("want 404 for %s, got %d", p, rec.Code) + if rec.Code != http.StatusCreated && rec.Code != http.StatusOK { + t.Fatalf("want 200/201 for content path %s, got %d: %s", p, rec.Code, rec.Body.String()) + } + } + + // The one reserved namespace, .zddc.d/, is admin-only and the gate + // OVERRIDES the broad operator grant: alice is elevated but not an admin, + // so she is hard-denied at every depth — this is what keeps the token + // store un-forgeable even under a permissive ACL. Case variants + // (.ZDDC.D, .Zddc.D) MUST be denied too: on a case-insensitive root they + // resolve to the same dir, so a write to a case-varied path — e.g. a MOVE + // destination header that skips dispatch's canonical case-folding — would + // otherwise plant a forged bearer token. HasReservedSidecar folds case. + for _, p := range []string{ + "/.zddc.d/tokens/forged", "/Project/.zddc.d/history/x", + "/.ZDDC.D/tokens/forged", "/Project/.Zddc.D/history/x", + } { + rec := do(http.MethodPut, p, "alice@example.com", []byte("x"), nil) + if rec.Code != http.StatusForbidden { + t.Fatalf("want 403 for reserved %s, got %d: %s", p, rec.Code, rec.Body.String()) } } } diff --git a/zddc/internal/handler/paths.go b/zddc/internal/handler/paths.go index 4a69265..0de11ee 100644 --- a/zddc/internal/handler/paths.go +++ b/zddc/internal/handler/paths.go @@ -12,10 +12,11 @@ import ( // resolvePath translates a URL `path=` query (relative to fsRoot, with // '/' separator and leading '/') into an absolute filesystem path. It -// rejects path traversal and any segment beginning with '.' or '_' so -// reserved namespaces (e.g. the .zddc.d/ bookkeeping sidecar) cannot be -// addressed through admin APIs. Returns the cleaned absolute path or an -// error suitable for a 404. +// rejects path traversal and the reserved .zddc.d/ bookkeeping sidecar so +// the token store et al. cannot be addressed through admin APIs (admins +// manage tokens via /.tokens, not the generic file path). All other +// dot-/underscore-prefixed paths are ordinary content. Returns the cleaned +// absolute path or an error suitable for a 404. func resolvePath(fsRoot, urlPath string) (string, error) { urlPath = strings.TrimSpace(urlPath) if urlPath == "" { @@ -26,14 +27,11 @@ func resolvePath(fsRoot, urlPath string) (string, error) { } cleanURL := filepath.ToSlash(filepath.Clean(urlPath)) - // Reject reserved-prefix segments so callers cannot create - // .foo/.zddc or _bar/.zddc through admin APIs. + // Reject the one reserved namespace (.zddc.d/) so admin APIs cannot + // address the token store / history / caches through a generic path. for _, seg := range strings.Split(strings.Trim(cleanURL, "/"), "/") { - if seg == "" { - continue - } - if strings.HasPrefix(seg, ".") || strings.HasPrefix(seg, "_") { - return "", errors.New("reserved-prefix path segment") + if seg == ReservedSidecar { + return "", errors.New("reserved path segment") } } diff --git a/zddc/internal/handler/profilehandler_test.go b/zddc/internal/handler/profilehandler_test.go index 612249f..6a4a207 100644 --- a/zddc/internal/handler/profilehandler_test.go +++ b/zddc/internal/handler/profilehandler_test.go @@ -845,10 +845,13 @@ func TestServeProfileProjectsCreate(t *testing.T) { t.Errorf("path-separator name status=%d, want 400", rec.Code) } - // Reserved-prefix parent. - rec = post("root@example.com", `{"parent":"/.foo", "name":"x"}`) + // Reserved-sidecar parent — .zddc.d/ is the one reserved namespace that + // admin APIs cannot address through a generic path (404). Other dot- + // prefixed parents are ordinary content and fall through to the + // missing-parent check below. + rec = post("root@example.com", `{"parent":"/.zddc.d", "name":"x"}`) if rec.Code != http.StatusNotFound { - t.Errorf("reserved-prefix parent status=%d, want 404", rec.Code) + t.Errorf("reserved .zddc.d parent status=%d, want 404", rec.Code) } // Non-existent parent. diff --git a/zddc/internal/handler/sidecar.go b/zddc/internal/handler/sidecar.go new file mode 100644 index 0000000..0652984 --- /dev/null +++ b/zddc/internal/handler/sidecar.go @@ -0,0 +1,70 @@ +package handler + +import ( + "net/http" + "path/filepath" + "strings" + + "codeberg.org/VARASYS/ZDDC/zddc/internal/config" + "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" +) + +// ReservedSidecar is the single reserved namespace under any directory. ALL +// server bookkeeping lives under /.zddc.d/ — bearer tokens +// (.zddc.d/tokens), access logs (.zddc.d/logs), edit history +// (.zddc.d/history), the apps cache (.zddc.d/apps) and the MD-conversion +// cache (.zddc.d/converted). +// +// It is the one hard rule that overrides operator ACLs: a broad grant +// (e.g. `*: rwcd`) must never expose the token store, so .zddc.d is admin-only +// at every depth via this segment-name gate rather than a cascade ACL fence +// (the path-tree cascade has no match-this-name-at-any-depth rule). Everything +// else dot-prefixed is ordinary ACL-governed content; a leading dot only hides +// an entry from directory listings (UI) — see internal/fs and internal/listing. +// +// The gate is applied on reads in dispatch (cmd/zddc-server) and mirrored on +// the write path in authorizeAction. Bearer-token validation reads +// .zddc.d/tokens directly from the filesystem in ACLMiddleware, before any of +// this, so it is never affected by the HTTP-layer gate. +const ReservedSidecar = ".zddc.d" + +// HasReservedSidecar reports whether any segment of urlPath is the reserved +// .zddc.d sidecar. The comparison is case-insensitive: ZDDC_ROOT may sit on a +// case-insensitive filesystem (SMB/CIFS/Azure Files), where `.ZDDC.D/tokens` +// resolves to the same directory as `.zddc.d/tokens` — so the gate must catch +// every case variant, not just the literal lowercase form, or a write to a +// case-varied path (e.g. a MOVE destination that skips dispatch's canonical +// case-folding) could reach the token store. +func HasReservedSidecar(urlPath string) bool { + for _, seg := range strings.Split(strings.Trim(urlPath, "/"), "/") { + if strings.EqualFold(seg, ReservedSidecar) { + return true + } + } + return false +} + +// ActiveAdminForSidecar reports whether the request's principal is an active +// (elevated) admin with authority over the subtree that contains the first +// .zddc.d segment of urlPath. It is only meaningful when +// HasReservedSidecar(urlPath) is true. Root .zddc.d (e.g. /.zddc.d/tokens) +// requires a root admin; a per-directory .zddc.d (e.g. +// //.../.zddc.d/history) requires an admin over that subtree. Bearer +// clients are implicitly elevated by ACLMiddleware, so a CLI caller with admin +// authority passes. +func ActiveAdminForSidecar(cfg config.Config, r *http.Request, urlPath string) bool { + p := 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, ReservedSidecar) { + 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) +}