ZDDC/zddc/internal/handler/sidecar.go
ZDDC f7233237cd 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>
2026-06-03 13:23:00 -05:00

70 lines
3 KiB
Go

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