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:
parent
b59a7f6100
commit
f7233237cd
9 changed files with 219 additions and 136 deletions
|
|
@ -509,9 +509,9 @@ func newGzipWrapper() (func(http.Handler) http.HandlerFunc, error) {
|
||||||
|
|
||||||
// setupApps creates the cache + fetcher + server. No seeding, no refresh,
|
// setupApps creates the cache + fetcher + server. No seeding, no refresh,
|
||||||
// no admin UI — the server fetches once on first request, caches forever
|
// 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) {
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("create cache: %w", err)
|
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
|
// Split path into segments
|
||||||
segments := strings.Split(strings.Trim(urlPath, "/"), "/")
|
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
|
// Raw .zddc YAML view: <dir>/.zddc is reachable at every depth
|
||||||
// and returns the on-disk file's bytes (Content-Type: application/yaml)
|
// and returns the on-disk file's bytes (Content-Type: application/yaml)
|
||||||
// or — when no file exists — a synthetic placeholder body with a
|
// or — when no file exists — a synthetic placeholder body with a
|
||||||
// cascade summary so the user can see what's effective here. The
|
// 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
|
// reserved-sidecar gate above already filtered out .zddc.d/.zddc, so
|
||||||
// land here and PUT/DELETE/POST fall through to ServeFileAPI.
|
// 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) {
|
if handler.IsZddcFileRequest(urlPath) && (r.Method == http.MethodGet || r.Method == http.MethodHead) {
|
||||||
handler.ServeZddcFile(cfg, w, r)
|
handler.ServeZddcFile(cfg, w, r)
|
||||||
return
|
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
|
// Check for .archive segment in the path. .archive is project-scoped
|
||||||
// and addressed at exactly one depth — /<project>/.archive/... — even
|
// and addressed at exactly one depth — /<project>/.archive/... — even
|
||||||
// though offline-built HTML files reference siblings via
|
// though offline-built HTML files reference siblings via
|
||||||
|
|
|
||||||
|
|
@ -22,14 +22,13 @@ import (
|
||||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestDispatchHidesDotPrefixedSegments asserts the dispatch() guard that
|
// TestDispatchReservesZddcD asserts the dispatch() gate that reserves the one
|
||||||
// rejects requests whose URL contains a dot-prefixed segment (other than
|
// bookkeeping namespace, .zddc.d/. Non-admin requests to .zddc.d/ are 404'd at
|
||||||
// the recognized virtual prefixes .archive and /.profile handled separately).
|
// every depth (existence-hidden token store), while every OTHER dot-/underscore-
|
||||||
//
|
// prefixed path is ordinary ACL-governed content and is served like any normal
|
||||||
// The guard keeps server bookkeeping under the reserved .zddc.d/ sidecar
|
// file (a leading dot only hides it from listings). The recognized virtual
|
||||||
// (tokens, history, …) from being fetched raw over HTTP. (Part B will
|
// prefixes (.archive, /.profile) are routed before the gate.
|
||||||
// replace this blanket block with a .zddc.d/ admin-fence.)
|
func TestDispatchReservesZddcD(t *testing.T) {
|
||||||
func TestDispatchHidesDotPrefixedSegments(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
|
|
||||||
// Realistic shape: a project dir, a reserved .zddc.d/ token store, and a
|
// Realistic shape: a project dir, a reserved .zddc.d/ token store, and a
|
||||||
|
|
@ -58,21 +57,31 @@ func TestDispatchHidesDotPrefixedSegments(t *testing.T) {
|
||||||
path string
|
path string
|
||||||
wantStatus int
|
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 dir", "/.zddc.d/", http.StatusNotFound},
|
||||||
{"reserved .zddc.d token", "/.zddc.d/tokens/abc123", 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.
|
// Other dot-prefixed content is no longer blocked by dispatch — it's
|
||||||
{"hidden segment mid path", "/Project-A/.internal/notes.md", http.StatusNotFound},
|
// 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
|
// Sanity: recognized virtual prefixes are routed before the gate.
|
||||||
// through to its own handler (which 404s on missing tracking number);
|
// .archive falls through to its own handler (404 on missing tracking);
|
||||||
// .profile is handled by ServeProfile and the page itself is public.
|
// .profile is public. /.admin was hard-cut and now simply 404s as a
|
||||||
// /.admin no longer exists — it is hard-cut and falls through to the
|
// not-found file (no dot-prefix guard left to reject it).
|
||||||
// dot-prefix guard, which 404s.
|
|
||||||
{".archive prefix passes guard", "/.archive/UNKNOWN", http.StatusNotFound}, // unknown tracking → 404 from archive handler
|
{".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
|
{".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.
|
// Normal files unaffected.
|
||||||
{"plain file", "/Project-A/doc.txt", http.StatusOK},
|
{"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 / serves the landing app from the apps subsystem
|
||||||
// - GET /archive.html serves the archive app via fetch+cache
|
// - GET /archive.html serves the archive app via fetch+cache
|
||||||
// - second GET /archive.html serves from cache (X-ZDDC-Source: 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) {
|
func TestDispatchAppsResolution(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
|
|
||||||
|
|
@ -191,11 +200,13 @@ func TestDispatchAppsResolution(t *testing.T) {
|
||||||
t.Errorf("GET /: status=%d", rec3.Code)
|
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()
|
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 {
|
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
|
// 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())
|
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
|
// The reserved .zddc.d/ sidecar is admin-only, but to an admin it's normal
|
||||||
// is carved through. A PUT to /.zddc.d/foo must 404 at the guard.
|
// 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)
|
req = withAuth(httptest.NewRequest(http.MethodPut, "/.zddc.d/something", bytes.NewReader([]byte("x"))), "admin@example.com", true)
|
||||||
rec = httptest.NewRecorder()
|
rec = httptest.NewRecorder()
|
||||||
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
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 {
|
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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@
|
||||||
// ./local.html / /abs/local.html — terminal local path
|
// ./local.html / /abs/local.html — terminal local path
|
||||||
//
|
//
|
||||||
// No background refresh, no SHA-256 verification. To pick up new upstream
|
// 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
|
package apps
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|
@ -50,10 +50,12 @@ const DefaultUpstreamReleases = DefaultUpstream + "/releases"
|
||||||
// specifies one.
|
// specifies one.
|
||||||
const DefaultChannel = "stable"
|
const DefaultChannel = "stable"
|
||||||
|
|
||||||
// CacheDirName is the directory under ZDDC_ROOT where fetched URL sources
|
// CacheDirName is the directory under <ZDDC_ROOT>/.zddc.d/ where fetched URL
|
||||||
// are cached. The leading underscore excludes it from project listings;
|
// sources are cached. Living under the reserved .zddc.d/ sidecar means the
|
||||||
// dispatch additionally blocks direct URL access.
|
// cache is hidden from listings and admin-gated for direct URL access like all
|
||||||
const CacheDirName = "_app"
|
// 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
|
// DefaultAppsKey is the special key in `apps:` that provides the baseline
|
||||||
// URL prefix and channel for any app not overridden per-name. Cascades
|
// URL prefix and channel for any app not overridden per-name. Cascades
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ import (
|
||||||
//
|
//
|
||||||
// The source file's read policy (enforced by the dispatcher before this
|
// The source file's read policy (enforced by the dispatcher before this
|
||||||
// handler runs) gates the response. The converted bytes are cached at
|
// 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
|
// fast-path GET that finds a fresh cache hit serves the disk file via
|
||||||
// http.ServeContent without invoking pandoc at all.
|
// http.ServeContent without invoking pandoc at all.
|
||||||
//
|
//
|
||||||
|
|
@ -36,7 +36,7 @@ import (
|
||||||
// 1. Reads source bytes.
|
// 1. Reads source bytes.
|
||||||
// 2. Walks the .zddc cascade to assemble the convert.Metadata.
|
// 2. Walks the .zddc cascade to assemble the convert.Metadata.
|
||||||
// 3. Calls convert.ToDocx / ToHTML / ToPDF (containerised pandoc).
|
// 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.
|
// 5. Serves it.
|
||||||
//
|
//
|
||||||
// Concurrent requests for the same URL share a single conversion via
|
// 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))
|
base := strings.TrimSuffix(filepath.Base(srcAbs), filepath.Ext(srcAbs))
|
||||||
dir := filepath.Dir(srcAbs)
|
dir := filepath.Dir(srcAbs)
|
||||||
cacheDir := filepath.Join(dir, ".converted")
|
cacheDir := filepath.Join(dir, ReservedSidecar, "converted")
|
||||||
cacheAbs := filepath.Join(cacheDir, base+"."+format)
|
cacheAbs := filepath.Join(cacheDir, base+"."+format)
|
||||||
|
|
||||||
// Fast path: cached file present and mtime-equal to source.
|
// 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)
|
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
|
// sidecars for an .md source. Called from the file API after a
|
||||||
// successful PUT/DELETE/MOVE so the next GET ?convert= regenerates.
|
// successful PUT/DELETE/MOVE so the next GET ?convert= regenerates.
|
||||||
// Best-effort: errors (including "directory doesn't exist") are
|
// Best-effort: errors (including "directory doesn't exist") are
|
||||||
|
|
@ -303,7 +303,7 @@ func purgeConverted(srcAbs string) {
|
||||||
dir := filepath.Dir(srcAbs)
|
dir := filepath.Dir(srcAbs)
|
||||||
base := strings.TrimSuffix(filepath.Base(srcAbs), filepath.Ext(srcAbs))
|
base := strings.TrimSuffix(filepath.Base(srcAbs), filepath.Ext(srcAbs))
|
||||||
for _, ext := range []string{".docx", ".html", ".pdf"} {
|
for _, ext := range []string{".docx", ".html", ".pdf"} {
|
||||||
_ = os.Remove(filepath.Join(dir, ".converted", base+ext))
|
_ = os.Remove(filepath.Join(dir, ReservedSidecar, "converted", base+ext))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -96,24 +96,10 @@ func resolveTargetPath(cfg config.Config, urlPath string) (absPath, cleanURL str
|
||||||
cleanURL += "/"
|
cleanURL += "/"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reject hidden / reserved segments. Mirrors dispatch's guard,
|
// Dot-/underscore-prefixed paths are ordinary ACL-governed content now;
|
||||||
// applied here too because external callers reach ServeFileAPI
|
// the one reserved namespace, .zddc.d/, is admin-gated in authorizeAction
|
||||||
// only via dispatch — but defense in depth costs nothing.
|
// (which all write verbs funnel through) rather than blocked here, so an
|
||||||
// Carve-out: `.zddc` as a leaf segment is writable (admin-gated)
|
// admin can read/write the sidecar like normal files. See sidecar.go.
|
||||||
// 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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rel := filepath.FromSlash(strings.TrimPrefix(strings.TrimSuffix(cleanURL, "/"), "/"))
|
rel := filepath.FromSlash(strings.TrimPrefix(strings.TrimSuffix(cleanURL, "/"), "/"))
|
||||||
abs := filepath.Join(cfg.Root, rel)
|
abs := filepath.Join(cfg.Root, rel)
|
||||||
if !strings.HasPrefix(abs, cfg.Root+string(filepath.Separator)) && abs != cfg.Root {
|
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)
|
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)
|
decider := DeciderFromContext(r)
|
||||||
allowed, _ := policy.AllowActionFromChainP(r.Context(), decider, chain, p, urlPath, action)
|
allowed, _ := policy.AllowActionFromChainP(r.Context(), decider, chain, p, urlPath, action)
|
||||||
if !allowed {
|
if !allowed {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
_, do, _ := fileAPITestSetup(t, nil, nil)
|
||||||
|
|
||||||
// .zddc as a leaf is carved out — gated on admin authority via the
|
// Dot-/underscore-prefixed paths are ordinary ACL-governed content now:
|
||||||
// decider, not blocked at the segment guard. Every other dot/
|
// alice has a broad rwcd grant via *@example.com, so these writes succeed.
|
||||||
// underscore segment stays reserved.
|
// (A leading dot only hides an entry from listings, not from the ACL.)
|
||||||
for _, p := range []string{"/foo/.hidden", "/_app/spoof.html", "/_template/x", "/.zddc.d/x"} {
|
for _, p := range []string{"/foo/.hidden", "/_app/spoof.html", "/_template/x"} {
|
||||||
rec := do(http.MethodPut, p, "alice@example.com", []byte("x"), nil)
|
rec := do(http.MethodPut, p, "alice@example.com", []byte("x"), nil)
|
||||||
if rec.Code != http.StatusNotFound {
|
if rec.Code != http.StatusCreated && rec.Code != http.StatusOK {
|
||||||
t.Fatalf("want 404 for %s, got %d", p, rec.Code)
|
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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,11 @@ import (
|
||||||
|
|
||||||
// resolvePath translates a URL `path=` query (relative to fsRoot, with
|
// resolvePath translates a URL `path=` query (relative to fsRoot, with
|
||||||
// '/' separator and leading '/') into an absolute filesystem path. It
|
// '/' separator and leading '/') into an absolute filesystem path. It
|
||||||
// rejects path traversal and any segment beginning with '.' or '_' so
|
// rejects path traversal and the reserved .zddc.d/ bookkeeping sidecar so
|
||||||
// reserved namespaces (e.g. the .zddc.d/ bookkeeping sidecar) cannot be
|
// the token store et al. cannot be addressed through admin APIs (admins
|
||||||
// addressed through admin APIs. Returns the cleaned absolute path or an
|
// manage tokens via /.tokens, not the generic file path). All other
|
||||||
// error suitable for a 404.
|
// 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) {
|
func resolvePath(fsRoot, urlPath string) (string, error) {
|
||||||
urlPath = strings.TrimSpace(urlPath)
|
urlPath = strings.TrimSpace(urlPath)
|
||||||
if urlPath == "" {
|
if urlPath == "" {
|
||||||
|
|
@ -26,14 +27,11 @@ func resolvePath(fsRoot, urlPath string) (string, error) {
|
||||||
}
|
}
|
||||||
cleanURL := filepath.ToSlash(filepath.Clean(urlPath))
|
cleanURL := filepath.ToSlash(filepath.Clean(urlPath))
|
||||||
|
|
||||||
// Reject reserved-prefix segments so callers cannot create
|
// Reject the one reserved namespace (.zddc.d/) so admin APIs cannot
|
||||||
// .foo/.zddc or _bar/.zddc through admin APIs.
|
// address the token store / history / caches through a generic path.
|
||||||
for _, seg := range strings.Split(strings.Trim(cleanURL, "/"), "/") {
|
for _, seg := range strings.Split(strings.Trim(cleanURL, "/"), "/") {
|
||||||
if seg == "" {
|
if seg == ReservedSidecar {
|
||||||
continue
|
return "", errors.New("reserved path segment")
|
||||||
}
|
|
||||||
if strings.HasPrefix(seg, ".") || strings.HasPrefix(seg, "_") {
|
|
||||||
return "", errors.New("reserved-prefix path segment")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -845,10 +845,13 @@ func TestServeProfileProjectsCreate(t *testing.T) {
|
||||||
t.Errorf("path-separator name status=%d, want 400", rec.Code)
|
t.Errorf("path-separator name status=%d, want 400", rec.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reserved-prefix parent.
|
// Reserved-sidecar parent — .zddc.d/ is the one reserved namespace that
|
||||||
rec = post("root@example.com", `{"parent":"/.foo", "name":"x"}`)
|
// 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 {
|
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.
|
// Non-existent parent.
|
||||||
|
|
|
||||||
70
zddc/internal/handler/sidecar.go
Normal file
70
zddc/internal/handler/sidecar.go
Normal 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)
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue