ZDDC/zddc/internal/handler/configpath.go
ZDDC 42f520e087 fix(server): MOVE must require config-edit authority for .zddc/.zddc.zip
serveFileMove authorized config files with content verbs — the destination
as ActionCreate, a .zddc source as ActionWrite — so a caller holding only
create/write authority could plant or relocate an attacker-controlled
.zddc / .zddc.zip cascade (admins:/acl:) that PUT and DELETE both gate
behind ActionAdmin (VerbA / IsConfigEditor). The MOVE destination rides in
the X-ZDDC-Destination header, which no dispatch gate inspects, so the bar
must be enforced at the handler on the resolved target path.

Centralize the escalation in configWriteAction() (.zddc / .zddc.zip →
ActionAdmin, case-insensitive) and apply it to BOTH sides of serveFileMove;
replace the inlined `.zddc` checks in serveFilePut/serveFileDelete with the
same helper (also escalating whole-file .zddc.zip writes at the handler
layer, where previously only the dispatch visibility gate covered them).

Found via an authz-subsystem audit; the existing suite did not pin this
path. Adds TestFileAPI_MoveOntoConfigRequiresConfigEdit (non-editor MOVE
onto/away-from config → 403; config-editor → 200). Full Go suite + vet green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 18:06:28 -05:00

37 lines
1.7 KiB
Go

package handler
import (
"path/filepath"
"strings"
"codeberg.org/VARASYS/ZDDC/zddc/internal/apps"
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
)
// configWriteAction returns the action a write to absPath must be authorized
// as. The .zddc cascade file and the .zddc.zip bundle are policy, not content:
// mutating either is a VerbA operation requiring standing config-edit authority
// (IsConfigEditor — a subtree admin or `a`-verb holder, no elevation), which
// the decider enforces when the action is tagged ActionAdmin. For any other
// path the supplied default action is returned unchanged.
//
// This is the single predicate behind the per-verb escalation that previously
// lived inlined in serveFilePut/serveFileDelete (.zddc only) and was MISSING
// from serveFileMove — letting a MOVE plant or relocate a policy file with mere
// create/write authority. PUT/DELETE on a URL-visible .zddc.zip are also
// existence-gated to config-editors at dispatch (the bundle visibility gate in
// cmd/zddc-server), but a MOVE destination rides in the X-ZDDC-Destination
// header and never reaches that gate — so the authority bar must be enforced
// here, on the resolved target path, for every write verb.
//
// Matching is case-insensitive to align with HasReservedSidecar: ZDDC_ROOT may
// sit on a case-insensitive filesystem (SMB/CIFS/Azure Files) where `.ZDDC` /
// `.ZDDC.ZIP` resolve to the same files, and a case-varied target must not slip
// past the gate.
func configWriteAction(absPath, def string) string {
base := filepath.Base(absPath)
if strings.EqualFold(base, ".zddc") || strings.EqualFold(base, apps.BundleName) {
return policy.ActionAdmin
}
return def
}