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:
2026-04-01 23:31:27 +03:00
parent 0491849f0f
commit 582e7e39e3
8 changed files with 165 additions and 22 deletions
+25 -4
View File
@@ -5,11 +5,13 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"net/http" "net/http"
"path/filepath"
"strings" "strings"
"github.com/alexei/docker-watcher/internal/crypto" "github.com/alexei/docker-watcher/internal/crypto"
"github.com/alexei/docker-watcher/internal/npm" "github.com/alexei/docker-watcher/internal/npm"
"github.com/alexei/docker-watcher/internal/store" "github.com/alexei/docker-watcher/internal/store"
"github.com/alexei/docker-watcher/internal/volume"
"github.com/alexei/docker-watcher/internal/webhook" "github.com/alexei/docker-watcher/internal/webhook"
) )
@@ -24,8 +26,9 @@ type settingsRequest struct {
NpmEmail string `json:"npm_email"` NpmEmail string `json:"npm_email"`
NpmPassword string `json:"npm_password"` NpmPassword string `json:"npm_password"`
PollingInterval string `json:"polling_interval"` PollingInterval string `json:"polling_interval"`
SSLCertificateID *int `json:"ssl_certificate_id,omitempty"` SSLCertificateID *int `json:"ssl_certificate_id,omitempty"`
StaleThresholdDays *int `json:"stale_threshold_days,omitempty"` StaleThresholdDays *int `json:"stale_threshold_days,omitempty"`
AllowedVolumePaths *string `json:"allowed_volume_paths,omitempty"`
} }
// getSettings handles GET /api/settings. // getSettings handles GET /api/settings.
@@ -48,8 +51,9 @@ func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) {
"has_npm_password": settings.NpmPassword != "", "has_npm_password": settings.NpmPassword != "",
"polling_interval": settings.PollingInterval, "polling_interval": settings.PollingInterval,
"ssl_certificate_id": settings.SSLCertificateID, "ssl_certificate_id": settings.SSLCertificateID,
"stale_threshold_days": settings.StaleThresholdDays, "stale_threshold_days": settings.StaleThresholdDays,
"updated_at": settings.UpdatedAt, "allowed_volume_paths": settings.AllowedVolumePaths,
"updated_at": settings.UpdatedAt,
}) })
} }
@@ -110,6 +114,23 @@ func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request) {
} }
updated.StaleThresholdDays = *req.StaleThresholdDays updated.StaleThresholdDays = *req.StaleThresholdDays
} }
if req.AllowedVolumePaths != nil {
// Validate it's valid JSON array of strings.
paths, err := volume.ParseAllowedPaths(*req.AllowedVolumePaths)
if err != nil {
respondError(w, http.StatusBadRequest, "allowed_volume_paths must be a JSON array of strings")
return
}
// Validate each path is absolute.
for _, p := range paths {
if !filepath.IsAbs(p) {
respondError(w, http.StatusBadRequest, "each allowed volume path must be absolute")
return
}
}
updated.AllowedVolumePaths = *req.AllowedVolumePaths
_ = paths // validated
}
if err := s.store.UpdateSettings(updated); err != nil { if err := s.store.UpdateSettings(updated); err != nil {
respondError(w, http.StatusInternalServerError, "failed to update settings: "+err.Error()) respondError(w, http.StatusInternalServerError, "failed to update settings: "+err.Error())
+5 -4
View File
@@ -74,10 +74,11 @@ func (s *Server) resolveVolumeRoot(w http.ResponseWriter, r *http.Request) (stri
q := r.URL.Query() q := r.URL.Query()
params := volume.ResolveParams{ params := volume.ResolveParams{
BasePath: settings.BaseVolumePath, BasePath: settings.BaseVolumePath,
ProjectName: proj.Name, ProjectName: proj.Name,
StageName: q.Get("stage"), StageName: q.Get("stage"),
ImageTag: q.Get("tag"), ImageTag: q.Get("tag"),
AllowedVolumePaths: settings.AllowedVolumePaths,
} }
rootPath, err := volume.ResolvePath(vol, params) rootPath, err := volume.ResolvePath(vol, params)
+59 -5
View File
@@ -11,6 +11,7 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/alexei/docker-watcher/internal/store" "github.com/alexei/docker-watcher/internal/store"
"github.com/alexei/docker-watcher/internal/volume"
) )
// safeNamePattern restricts volume names to alphanumeric, dash, underscore, and dot. // safeNamePattern restricts volume names to alphanumeric, dash, underscore, and dot.
@@ -35,12 +36,13 @@ type volumeRequest struct {
var validScopes = map[string]bool{ var validScopes = map[string]bool{
"instance": true, "stage": true, "project": true, "instance": true, "stage": true, "project": true,
"project_named": true, "named": true, "ephemeral": true, "project_named": true, "named": true, "ephemeral": true,
"absolute": true,
} }
// validateVolumeScope validates the scope and name combination. // validateVolumeScope validates the scope, name, and source combination.
func validateVolumeScope(scope, name string) string { func validateVolumeScope(scope, name, source, allowedPathsJSON string) string {
if !validScopes[scope] { if !validScopes[scope] {
return "scope must be one of: instance, stage, project, project_named, named, ephemeral" return "scope must be one of: instance, stage, project, project_named, named, ephemeral, absolute"
} }
if (scope == "project_named" || scope == "named") && strings.TrimSpace(name) == "" { if (scope == "project_named" || scope == "named") && strings.TrimSpace(name) == "" {
return "name is required for " + scope + " scope" return "name is required for " + scope + " scope"
@@ -48,6 +50,34 @@ func validateVolumeScope(scope, name string) string {
if name != "" && !safeNamePattern.MatchString(name) { if name != "" && !safeNamePattern.MatchString(name) {
return "name must start with a letter or digit and contain only letters, digits, dashes, underscores, or dots" return "name must start with a letter or digit and contain only letters, digits, dashes, underscores, or dots"
} }
if scope == "absolute" {
if source == "" {
return "source path is required for absolute scope"
}
if !filepath.IsAbs(source) {
return "absolute scope requires an absolute source path"
}
// Validate against allowlist.
allowed, err := volume.ParseAllowedPaths(allowedPathsJSON)
if err != nil {
return "failed to parse allowed volume paths"
}
if len(allowed) == 0 {
return "absolute volume paths are disabled — configure allowed paths in settings first"
}
matched := false
cleanSource := filepath.Clean(source)
for _, prefix := range allowed {
cleanPrefix := filepath.Clean(prefix)
if strings.HasPrefix(cleanSource, cleanPrefix+string(filepath.Separator)) || cleanSource == cleanPrefix {
matched = true
break
}
}
if !matched {
return "source path is not under any allowed volume path"
}
}
return "" return ""
} }
@@ -99,6 +129,12 @@ func (s *Server) listVolumeScopes(w http.ResponseWriter, r *http.Request) {
NeedsName: false, NeedsName: false,
PathExample: "(tmpfs — no host path)", PathExample: "(tmpfs — no host path)",
}, },
{
Scope: "absolute",
Description: "Direct host path. Must be under an allowed path configured in settings. Use for external mounts like NFS or pre-existing directories.",
NeedsName: false,
PathExample: "/mnt/nfs/data (must match allowed paths)",
},
} }
respondJSON(w, http.StatusOK, scopes) respondJSON(w, http.StatusOK, scopes)
} }
@@ -179,7 +215,15 @@ func (s *Server) createVolume(w http.ResponseWriter, r *http.Request) {
} }
} }
if errMsg := validateVolumeScope(scope, req.Name); errMsg != "" { // Fetch settings for absolute path allowlist validation.
settings, err := s.store.GetSettings()
if err != nil {
slog.Error("failed to get settings for volume validation", "error", err)
respondError(w, http.StatusInternalServerError, "failed to validate volume")
return
}
if errMsg := validateVolumeScope(scope, req.Name, req.Source, settings.AllowedVolumePaths); errMsg != "" {
respondError(w, http.StatusBadRequest, errMsg) respondError(w, http.StatusBadRequest, errMsg)
return return
} }
@@ -235,7 +279,17 @@ func (s *Server) updateVolume(w http.ResponseWriter, r *http.Request) {
updated.Target = req.Target updated.Target = req.Target
} }
if req.Scope != "" { if req.Scope != "" {
if errMsg := validateVolumeScope(req.Scope, req.Name); errMsg != "" { settings, err := s.store.GetSettings()
if err != nil {
slog.Error("failed to get settings for volume validation", "error", err)
respondError(w, http.StatusInternalServerError, "failed to validate volume")
return
}
source := updated.Source
if req.Source != "" {
source = req.Source
}
if errMsg := validateVolumeScope(req.Scope, req.Name, source, settings.AllowedVolumePaths); errMsg != "" {
respondError(w, http.StatusBadRequest, errMsg) respondError(w, http.StatusBadRequest, errMsg)
return return
} }
+3
View File
@@ -57,6 +57,7 @@ type Settings struct {
BaseVolumePath string `json:"base_volume_path"` BaseVolumePath string `json:"base_volume_path"`
SSLCertificateID int `json:"ssl_certificate_id"` SSLCertificateID int `json:"ssl_certificate_id"`
StaleThresholdDays int `json:"stale_threshold_days"` StaleThresholdDays int `json:"stale_threshold_days"`
AllowedVolumePaths string `json:"allowed_volume_paths"` // JSON array of allowed absolute paths
UpdatedAt string `json:"updated_at"` UpdatedAt string `json:"updated_at"`
} }
@@ -120,12 +121,14 @@ const (
VolumeScopeProjectNamed VolumeScope = "project_named" VolumeScopeProjectNamed VolumeScope = "project_named"
VolumeScopeNamed VolumeScope = "named" VolumeScopeNamed VolumeScope = "named"
VolumeScopeEphemeral VolumeScope = "ephemeral" VolumeScopeEphemeral VolumeScope = "ephemeral"
VolumeScopeAbsolute VolumeScope = "absolute"
) )
// ValidVolumeScopes contains all valid scope values for validation. // ValidVolumeScopes contains all valid scope values for validation.
var ValidVolumeScopes = []VolumeScope{ var ValidVolumeScopes = []VolumeScope{
VolumeScopeInstance, VolumeScopeStage, VolumeScopeProject, VolumeScopeInstance, VolumeScopeStage, VolumeScopeProject,
VolumeScopeProjectNamed, VolumeScopeNamed, VolumeScopeEphemeral, VolumeScopeProjectNamed, VolumeScopeNamed, VolumeScopeEphemeral,
VolumeScopeAbsolute,
} }
// IsValidVolumeScope returns true if the given string is a valid scope. // IsValidVolumeScope returns true if the given string is a valid scope.
+12 -4
View File
@@ -9,10 +9,14 @@ func (s *Store) GetSettings() (Settings, error) {
var st Settings var st Settings
err := s.db.QueryRow( err := s.db.QueryRow(
`SELECT domain, server_ip, network, subdomain_pattern, notification_url, `SELECT domain, server_ip, network, subdomain_pattern, notification_url,
npm_url, npm_email, npm_password, webhook_secret, polling_interval, base_volume_path, ssl_certificate_id, stale_threshold_days, updated_at npm_url, npm_email, npm_password, webhook_secret, polling_interval,
base_volume_path, ssl_certificate_id, stale_threshold_days,
allowed_volume_paths, updated_at
FROM settings WHERE id = 1`, FROM settings WHERE id = 1`,
).Scan(&st.Domain, &st.ServerIP, &st.Network, &st.SubdomainPattern, &st.NotificationURL, ).Scan(&st.Domain, &st.ServerIP, &st.Network, &st.SubdomainPattern, &st.NotificationURL,
&st.NpmURL, &st.NpmEmail, &st.NpmPassword, &st.WebhookSecret, &st.PollingInterval, &st.BaseVolumePath, &st.SSLCertificateID, &st.StaleThresholdDays, &st.UpdatedAt) &st.NpmURL, &st.NpmEmail, &st.NpmPassword, &st.WebhookSecret, &st.PollingInterval,
&st.BaseVolumePath, &st.SSLCertificateID, &st.StaleThresholdDays,
&st.AllowedVolumePaths, &st.UpdatedAt)
if err != nil { if err != nil {
return Settings{}, fmt.Errorf("query settings: %w", err) return Settings{}, fmt.Errorf("query settings: %w", err)
} }
@@ -25,10 +29,14 @@ func (s *Store) UpdateSettings(st Settings) error {
_, err := s.db.Exec( _, err := s.db.Exec(
`UPDATE settings SET `UPDATE settings SET
domain=?, server_ip=?, network=?, subdomain_pattern=?, notification_url=?, domain=?, server_ip=?, network=?, subdomain_pattern=?, notification_url=?,
npm_url=?, npm_email=?, npm_password=?, webhook_secret=?, polling_interval=?, base_volume_path=?, ssl_certificate_id=?, stale_threshold_days=?, updated_at=? npm_url=?, npm_email=?, npm_password=?, webhook_secret=?, polling_interval=?,
base_volume_path=?, ssl_certificate_id=?, stale_threshold_days=?,
allowed_volume_paths=?, updated_at=?
WHERE id = 1`, WHERE id = 1`,
st.Domain, st.ServerIP, st.Network, st.SubdomainPattern, st.NotificationURL, st.Domain, st.ServerIP, st.Network, st.SubdomainPattern, st.NotificationURL,
st.NpmURL, st.NpmEmail, st.NpmPassword, st.WebhookSecret, st.PollingInterval, st.BaseVolumePath, st.SSLCertificateID, st.StaleThresholdDays, st.UpdatedAt, st.NpmURL, st.NpmEmail, st.NpmPassword, st.WebhookSecret, st.PollingInterval,
st.BaseVolumePath, st.SSLCertificateID, st.StaleThresholdDays,
st.AllowedVolumePaths, st.UpdatedAt,
) )
if err != nil { if err != nil {
return fmt.Errorf("update settings: %w", err) return fmt.Errorf("update settings: %w", err)
+2
View File
@@ -88,6 +88,8 @@ func (s *Store) runMigrations() error {
// Add name column and rename mode→scope for volume scopes redesign (2026-03-31). // Add name column and rename mode→scope for volume scopes redesign (2026-03-31).
`ALTER TABLE volumes ADD COLUMN name TEXT NOT NULL DEFAULT ''`, `ALTER TABLE volumes ADD COLUMN name TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE volumes ADD COLUMN scope TEXT NOT NULL DEFAULT ''`, `ALTER TABLE volumes ADD COLUMN scope TEXT NOT NULL DEFAULT ''`,
// Add allowed_volume_paths to settings for absolute volume scope allowlist (2026-04-01).
`ALTER TABLE settings ADD COLUMN allowed_volume_paths TEXT NOT NULL DEFAULT '[]'`,
} }
for _, m := range migrations { for _, m := range migrations {
+57 -4
View File
@@ -1,18 +1,21 @@
package volume package volume
import ( import (
"encoding/json"
"fmt" "fmt"
"path/filepath" "path/filepath"
"strings"
"github.com/alexei/docker-watcher/internal/store" "github.com/alexei/docker-watcher/internal/store"
) )
// ResolveParams holds the parameters needed to resolve a volume's host path. // ResolveParams holds the parameters needed to resolve a volume's host path.
type ResolveParams struct { type ResolveParams struct {
BasePath string BasePath string
ProjectName string ProjectName string
StageName string // required for instance and stage scopes StageName string // required for instance and stage scopes
ImageTag string // required for instance scope 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. // 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") return "", fmt.Errorf("ephemeral volumes have no host path")
} }
if scope == "absolute" {
return resolveAbsolute(vol.Source, params.AllowedVolumePaths)
}
switch scope { switch scope {
case "instance": case "instance":
if params.StageName == "" || params.ImageTag == "" { 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 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)
}
+2 -1
View File
@@ -107,6 +107,7 @@ export interface Settings {
base_volume_path: string; base_volume_path: string;
ssl_certificate_id: number; ssl_certificate_id: number;
stale_threshold_days: number; stale_threshold_days: number;
allowed_volume_paths: string;
updated_at: string; updated_at: string;
} }
@@ -162,7 +163,7 @@ export interface EntityPickerItem {
} }
/** Volume scope determines the sharing level. */ /** Volume scope determines the sharing level. */
export type VolumeScope = 'instance' | 'stage' | 'project' | 'project_named' | 'named' | 'ephemeral'; export type VolumeScope = 'instance' | 'stage' | 'project' | 'project_named' | 'named' | 'ephemeral' | 'absolute';
/** Volume mount configuration for a project. */ /** Volume mount configuration for a project. */
export interface Volume { export interface Volume {