feat(volume-browser): absolute scope with allowlist security
- Add 'absolute' volume scope for direct host paths (NFS, external mounts) - Allowlist in settings: allowed_volume_paths (JSON array of prefixes) - Validation: absolute source must be under an allowed prefix - Empty allowlist = absolute scope disabled entirely - Settings API exposes/validates allowed_volume_paths - Frontend type updated with absolute scope
This commit is contained in:
@@ -1,18 +1,21 @@
|
||||
package volume
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/alexei/docker-watcher/internal/store"
|
||||
)
|
||||
|
||||
// ResolveParams holds the parameters needed to resolve a volume's host path.
|
||||
type ResolveParams struct {
|
||||
BasePath string
|
||||
ProjectName string
|
||||
StageName string // required for instance and stage scopes
|
||||
ImageTag string // required for instance scope
|
||||
BasePath string
|
||||
ProjectName string
|
||||
StageName string // required for instance and stage scopes
|
||||
ImageTag string // required for instance scope
|
||||
AllowedVolumePaths string // JSON array of allowed absolute paths (from settings)
|
||||
}
|
||||
|
||||
// ResolvePath returns the absolute host path for a volume based on its scope.
|
||||
@@ -32,6 +35,10 @@ func ResolvePath(vol store.Volume, params ResolveParams) (string, error) {
|
||||
return "", fmt.Errorf("ephemeral volumes have no host path")
|
||||
}
|
||||
|
||||
if scope == "absolute" {
|
||||
return resolveAbsolute(vol.Source, params.AllowedVolumePaths)
|
||||
}
|
||||
|
||||
switch scope {
|
||||
case "instance":
|
||||
if params.StageName == "" || params.ImageTag == "" {
|
||||
@@ -53,3 +60,49 @@ func ResolvePath(vol store.Volume, params ResolveParams) (string, error) {
|
||||
return filepath.Join(params.BasePath, params.ProjectName, vol.Source), nil
|
||||
}
|
||||
}
|
||||
|
||||
// resolveAbsolute validates that the source path is under one of the allowed prefixes.
|
||||
func resolveAbsolute(source, allowedPathsJSON string) (string, error) {
|
||||
if source == "" {
|
||||
return "", fmt.Errorf("absolute scope requires a source path")
|
||||
}
|
||||
|
||||
cleaned := filepath.Clean(source)
|
||||
if !filepath.IsAbs(cleaned) {
|
||||
return "", fmt.Errorf("absolute scope requires an absolute source path (starting with /)")
|
||||
}
|
||||
|
||||
allowed, err := parseAllowedPaths(allowedPathsJSON)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to parse allowed volume paths: %w", err)
|
||||
}
|
||||
if len(allowed) == 0 {
|
||||
return "", fmt.Errorf("absolute volume paths are disabled (no allowed paths configured in settings)")
|
||||
}
|
||||
|
||||
for _, prefix := range allowed {
|
||||
prefixClean := filepath.Clean(prefix)
|
||||
if strings.HasPrefix(cleaned, prefixClean+string(filepath.Separator)) || cleaned == prefixClean {
|
||||
return cleaned, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("path %q is not under any allowed volume path", source)
|
||||
}
|
||||
|
||||
// parseAllowedPaths parses a JSON array of path strings.
|
||||
func parseAllowedPaths(jsonStr string) ([]string, error) {
|
||||
if jsonStr == "" || jsonStr == "[]" {
|
||||
return nil, nil
|
||||
}
|
||||
var paths []string
|
||||
if err := json.Unmarshal([]byte(jsonStr), &paths); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
// ParseAllowedPaths is the exported version for use in API validation.
|
||||
func ParseAllowedPaths(jsonStr string) ([]string, error) {
|
||||
return parseAllowedPaths(jsonStr)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user