ZDDC/zddc/internal/handler/paths.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

83 lines
2.6 KiB
Go

package handler
import (
"errors"
"path/filepath"
"strings"
)
// URL ↔ filesystem path math used by several handler files. Pure
// string manipulation — no I/O, no policy decisions — so it lives
// in its own file rather than being attached to any one feature.
// resolvePath translates a URL `path=` query (relative to fsRoot, with
// '/' separator and leading '/') into an absolute filesystem path. It
// 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 == "" {
urlPath = "/"
}
if !strings.HasPrefix(urlPath, "/") {
return "", errors.New("path must be absolute (start with /)")
}
cleanURL := filepath.ToSlash(filepath.Clean(urlPath))
// 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 == ReservedSidecar {
return "", errors.New("reserved path segment")
}
}
rel := strings.TrimPrefix(cleanURL, "/")
abs := filepath.Join(fsRoot, filepath.FromSlash(rel))
abs = filepath.Clean(abs)
// Path containment.
if abs != fsRoot && !strings.HasPrefix(abs, fsRoot+string(filepath.Separator)) {
return "", errors.New("path escapes root")
}
return abs, nil
}
// urlPathOf produces the URL form of an absolute filesystem path under
// fsRoot. Returns "/" for fsRoot itself, otherwise "/<rel>".
func urlPathOf(fsRoot, abs string) string {
if abs == fsRoot {
return "/"
}
rel, err := filepath.Rel(fsRoot, abs)
if err != nil {
return "/"
}
return "/" + filepath.ToSlash(rel)
}
// chainDirs reproduces EffectivePolicy's directory walk so callers can
// label each policy-chain level with the directory it came from. Used
// by the virtual-.zddc body to annotate which ancestor contributed
// which rule.
func chainDirs(fsRoot, dirPath string) []string {
fsRoot = filepath.Clean(fsRoot)
dirPath = filepath.Clean(dirPath)
dirs := []string{fsRoot}
if dirPath == fsRoot {
return dirs
}
rel, err := filepath.Rel(fsRoot, dirPath)
if err != nil || rel == "." {
return dirs
}
current := fsRoot
for _, part := range strings.Split(rel, string(filepath.Separator)) {
current = filepath.Join(current, part)
dirs = append(dirs, current)
}
return dirs
}