feat(server): collapse dot-guard into one admin-gated .zddc.d reserve

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
(/<dir>/.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
<dir>/.converted/ -> <dir>/.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) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-06-03 08:38:28 -05:00
parent b59a7f6100
commit f7233237cd
9 changed files with 219 additions and 136 deletions

View file

@ -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 <ZDDC_ROOT>/_app/, and falls back to the embedded HTML on any failure.
// in <ZDDC_ROOT>/.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: <dir>/.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. /<dir>/.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: <dir>/.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 — /<project>/.archive/... — even
// though offline-built HTML files reference siblings via

View file

@ -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())
}
}

View file

@ -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_ROOT>/.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

View file

@ -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
// <dir>/.converted/<base>.<ext>, with mtime synced to the source — so a
// <dir>/.zddc.d/converted/<base>.<ext>, 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/<base>.{docx,html,pdf}
// purgeConverted removes the cached .zddc.d/converted/<base>.{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))
}
}

View file

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

View file

@ -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())
}
}
}

View file

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

View file

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

View file

@ -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 <dir>/.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.
// /<proj>/.../.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)
}