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>
70 lines
3 KiB
Go
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)
|
|
}
|