From f0198f865d1ce85b657ce1423ece4952cf35cf21 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 9 Nov 2026 21:20:02 +0000 Subject: [PATCH] Validate access to derived host roots --- docs/roadmap/remediation.md | 2 + facade_main_relative_integration_test.go | 56 ++++++++++++++++++++++++ internal/engine/acquisition.go | 2 +- internal/engine/filesystem.go | 17 ++++--- 4 files changed, 71 insertions(+), 6 deletions(-) diff --git a/docs/roadmap/remediation.md b/docs/roadmap/remediation.md index 9461856..1afd4fb 100644 --- a/docs/roadmap/remediation.md +++ b/docs/roadmap/remediation.md @@ -133,6 +133,8 @@ and downstream consumers. Findings 1–4 remain open. ## Finding 5 — Derived host roots skip standalone access validation +**Status** — Complete. Derived and explicit standalone contexts share the host root open and close checks. The permission regression passed for both paths. + **Priority P2.** When a companion binds to the main context with imports disabled, `acquireStandaloneInput` checks the resolved directories with `Stat`. An equivalent explicit context goes through diff --git a/facade_main_relative_integration_test.go b/facade_main_relative_integration_test.go index 93711b5..b46be57 100644 --- a/facade_main_relative_integration_test.go +++ b/facade_main_relative_integration_test.go @@ -1,6 +1,7 @@ package configkit_test import ( + "errors" "os" "path/filepath" "strings" @@ -9,6 +10,61 @@ import ( configkit "go.maximumdirect.net/configkit" ) +func TestMainRelativeStandaloneRejectsInaccessibleHostRoot(t *testing.T) { + root := t.TempDir() + mainPath := filepath.Join(root, "main.yml") + if err := os.WriteFile(mainPath, []byte("count: 1\n"), 0600); err != nil { + t.Fatal(err) + } + if err := os.Chmod(root, 0100); err != nil { + t.Skipf("cannot set search-only permissions: %v", err) + } + t.Cleanup(func() { + if err := os.Chmod(root, 0700); err != nil { + t.Error(err) + } + }) + handle, err := os.OpenRoot(root) + if err == nil { + _ = handle.Close() + t.Skip("host allows opening this search-only directory") + } + if !errors.Is(err, os.ErrPermission) { + t.Fatalf("open root = %v, want permission denied", err) + } + if _, err := os.ReadFile(mainPath); err != nil { + t.Skipf("main must remain readable by name: %v", err) + } + + main := configkit.Input{Source: configkit.File(mainPath)} + for _, tc := range []struct { + name string + filesystem *configkit.FilesystemContext + }{ + {name: "derived"}, + {name: "explicit", filesystem: &configkit.FilesystemContext{Root: root, BaseDir: root}}, + } { + t.Run(tc.name, func(t *testing.T) { + underlay := configkit.Input{ + Source: configkit.Bytes("companion", []byte("{}\n")), + MainRelative: &configkit.MainRelativeContext{RequireBase: true}, + Filesystem: tc.filesystem, + } + _, err := configkit.LoadLayered(main, configkit.LayeredOptions[layeredFacadeConfig]{ + Underlays: []configkit.Underlay{{Inputs: []configkit.Input{underlay}}}, + }) + var failure *configkit.LoadError + if !errors.As(err, &failure) { + t.Fatalf("error = %v, want a load error", err) + } + diagnostics := failure.Diagnostics() + if len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeImportPermission { + t.Fatalf("diagnostics = %#v, want import.permission", diagnostics) + } + }) + } +} + func TestMainRelativeDiscoveryAndEvidence(t *testing.T) { root := t.TempDir() first, second := filepath.Join(root, "first"), filepath.Join(root, "second") diff --git a/internal/engine/acquisition.go b/internal/engine/acquisition.go index f99f8c0..f288a1e 100644 --- a/internal/engine/acquisition.go +++ b/internal/engine/acquisition.go @@ -137,7 +137,7 @@ func acquireStandaloneInput(input assembledInput, request inputAcquisitionReques input.document = processing.payload } if request.resolved != nil { - if diagnostic := validateResolvedFilesystemDirectories(*request.resolved); diagnostic != nil { + if diagnostic := validateResolvedFilesystemContext(*request.resolved); diagnostic != nil { return input, []Diagnostic{*diagnostic} } input.resolvedFilesystem = request.resolved diff --git a/internal/engine/filesystem.go b/internal/engine/filesystem.go index 1fe0aa6..c2953c9 100644 --- a/internal/engine/filesystem.go +++ b/internal/engine/filesystem.go @@ -17,23 +17,30 @@ func validateFilesystemContext(source Source, context *FilesystemContext, main p } return resolvedFilesystemContext{}, diagnostic } - if diagnostic := validateResolvedFilesystemDirectories(resolved); diagnostic != nil { + if diagnostic := validateResolvedFilesystemContext(resolved); diagnostic != nil { return resolvedFilesystemContext{}, diagnostic } + return resolved, nil +} + +func validateResolvedFilesystemContext(resolved resolvedFilesystemContext) *Diagnostic { + if diagnostic := validateResolvedFilesystemDirectories(resolved); diagnostic != nil { + return diagnostic + } if resolved.native != nil { - return resolved, nil + return nil } root, err := os.OpenRoot(resolved.rootPath) if err != nil { value := globFilesystemDiagnostic(err, "configuration root could not be opened", Location{}) value.Primary = nil - return resolvedFilesystemContext{}, &value + return &value } if err := root.Close(); err != nil { value := importDiagnostic(CodeImportRead, "configuration root could not be closed", nil) - return resolvedFilesystemContext{}, &value + return &value } - return resolved, nil + return nil } func filesystemOptionDiagnostics(source Source, context *FilesystemContext) []Diagnostic {