diff --git a/config.yaml b/config.yaml index bf72e15..0ab0b9b 100644 --- a/config.yaml +++ b/config.yaml @@ -1,6 +1,11 @@ listen_address: "127.0.0.1:8080" policy: + # Block requests that reference files outside these directories. + # If empty, no directory scope is enforced. + allowed_directories: + # - "~/Projects/my-app" + deny_file_patterns: - "*.env" - "*.pem" diff --git a/internal/config/config.go b/internal/config/config.go index 409e0c3..a8b8885 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -23,7 +23,8 @@ type InterceptConfig struct { } type PolicyConfig struct { - DenyFilePatterns []string `yaml:"deny_file_patterns"` + DenyFilePatterns []string `yaml:"deny_file_patterns"` + AllowedDirectories []string `yaml:"allowed_directories"` } type LogConfig struct { @@ -62,6 +63,11 @@ func Load(path string) (*Config, error) { cfg.Intercept.CACert = expandHome(cfg.Intercept.CACert) cfg.Intercept.CAKey = expandHome(cfg.Intercept.CAKey) + // Expand allowed directories + for i, dir := range cfg.Policy.AllowedDirectories { + cfg.Policy.AllowedDirectories[i] = expandHome(dir) + } + // Apply logging defaults if cfg.Logging.File == "" { cfg.Logging.File = defaultLogPath() diff --git a/internal/policy/policy.go b/internal/policy/policy.go index f3b92a8..bc32d54 100644 --- a/internal/policy/policy.go +++ b/internal/policy/policy.go @@ -2,6 +2,7 @@ package policy import ( "fmt" + "os" "path/filepath" "strings" "sync" @@ -30,6 +31,13 @@ func (e *Engine) IsBypassed() bool { } func NewEngine(cfg config.PolicyConfig) *Engine { + // Clean and resolve allowed directories at construction time + dirs := make([]string, 0, len(cfg.AllowedDirectories)) + for _, d := range cfg.AllowedDirectories { + cleaned := filepath.Clean(d) + dirs = append(dirs, cleaned) + } + cfg.AllowedDirectories = dirs return &Engine{cfg: cfg} } @@ -70,6 +78,81 @@ func (e *Engine) RemoveDenyPattern(pattern string) { e.cfg.DenyFilePatterns = filtered } +// GetAllowedDirectories returns the current allowed directories. +func (e *Engine) GetAllowedDirectories() []string { + e.mu.RLock() + defer e.mu.RUnlock() + out := make([]string, len(e.cfg.AllowedDirectories)) + copy(out, e.cfg.AllowedDirectories) + return out +} + +// SetAllowedDirectories replaces the allowed directories list. +func (e *Engine) SetAllowedDirectories(dirs []string) { + e.mu.Lock() + defer e.mu.Unlock() + cleaned := make([]string, len(dirs)) + for i, d := range dirs { + cleaned[i] = filepath.Clean(d) + } + e.cfg.AllowedDirectories = cleaned +} + +// EvaluateScope checks if any detected file paths fall outside the allowed directories. +// If no allowed directories are configured, all paths are allowed. +func (e *Engine) EvaluateScope(paths []string) Decision { + if e.bypassed.Load() { + return Decision{Allowed: true, Reason: "policy bypassed (paused)"} + } + + e.mu.RLock() + allowedDirs := e.cfg.AllowedDirectories + e.mu.RUnlock() + + if len(allowedDirs) == 0 { + return Decision{Allowed: true, Reason: "no directory scope configured"} + } + + for _, p := range paths { + if !isInScope(p, allowedDirs) { + return Decision{ + Allowed: false, + Reason: fmt.Sprintf("file %q is outside allowed directories", p), + } + } + } + return Decision{Allowed: true, Reason: "all files within allowed directories"} +} + +// isInScope checks whether a file path falls within any of the allowed directories. +func isInScope(filePath string, allowedDirs []string) bool { + // Resolve the path to absolute for comparison + resolved := resolvePath(filePath) + + for _, dir := range allowedDirs { + // A file is in scope if its resolved path starts with the allowed dir + separator + dirWithSep := dir + string(filepath.Separator) + if resolved == dir || strings.HasPrefix(resolved, dirWithSep) { + return true + } + } + return false +} + +// resolvePath attempts to resolve a file path to an absolute, cleaned path. +// For relative paths, it resolves against the current working directory. +func resolvePath(p string) string { + p = filepath.Clean(p) + if filepath.IsAbs(p) { + return p + } + // Resolve relative paths against cwd + if cwd, err := os.Getwd(); err == nil { + return filepath.Join(cwd, p) + } + return p +} + // EvaluateFiles checks if any detected file paths match deny_file_patterns. func (e *Engine) EvaluateFiles(paths []string) Decision { if e.bypassed.Load() { diff --git a/internal/policy/policy_test.go b/internal/policy/policy_test.go index 26505bf..923a973 100644 --- a/internal/policy/policy_test.go +++ b/internal/policy/policy_test.go @@ -68,6 +68,95 @@ func TestEvaluateFiles_Bypassed(t *testing.T) { } } +func TestEvaluateScope_InScope(t *testing.T) { + engine := NewEngine(config.PolicyConfig{ + AllowedDirectories: []string{"/home/user/project"}, + }) + + tests := []struct { + name string + paths []string + allowed bool + }{ + {"file in project", []string{"/home/user/project/main.go"}, true}, + {"nested file in project", []string{"/home/user/project/src/app.go"}, true}, + {"file outside project", []string{"/etc/passwd"}, false}, + {"home dir file", []string{"/home/user/.ssh/id_rsa"}, false}, + {"sibling project", []string{"/home/user/other-project/main.go"}, false}, + {"parent traversal", []string{"/home/user/project/../.ssh/id_rsa"}, false}, + {"mix in and out of scope", []string{"/home/user/project/main.go", "/etc/passwd"}, false}, + {"empty list", []string{}, true}, + {"exact dir match", []string{"/home/user/project"}, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + decision := engine.EvaluateScope(tt.paths) + if decision.Allowed != tt.allowed { + t.Errorf("EvaluateScope(%v) = allowed:%v, want allowed:%v (reason: %s)", + tt.paths, decision.Allowed, tt.allowed, decision.Reason) + } + }) + } +} + +func TestEvaluateScope_MultipleAllowedDirs(t *testing.T) { + engine := NewEngine(config.PolicyConfig{ + AllowedDirectories: []string{"/home/user/project-a", "/home/user/project-b"}, + }) + + tests := []struct { + name string + paths []string + allowed bool + }{ + {"file in project-a", []string{"/home/user/project-a/main.go"}, true}, + {"file in project-b", []string{"/home/user/project-b/main.go"}, true}, + {"file in neither", []string{"/home/user/project-c/main.go"}, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + decision := engine.EvaluateScope(tt.paths) + if decision.Allowed != tt.allowed { + t.Errorf("EvaluateScope(%v) = allowed:%v, want allowed:%v (reason: %s)", + tt.paths, decision.Allowed, tt.allowed, decision.Reason) + } + }) + } +} + +func TestEvaluateScope_NoDirectoriesConfigured(t *testing.T) { + engine := NewEngine(config.PolicyConfig{}) + decision := engine.EvaluateScope([]string{"/anywhere/file.go"}) + if !decision.Allowed { + t.Error("expected allowed when no directories configured") + } +} + +func TestEvaluateScope_Bypassed(t *testing.T) { + engine := NewEngine(config.PolicyConfig{ + AllowedDirectories: []string{"/home/user/project"}, + }) + engine.SetBypassed(true) + decision := engine.EvaluateScope([]string{"/etc/passwd"}) + if !decision.Allowed { + t.Error("expected allowed when policy bypassed") + } +} + +func TestEvaluateScope_ParentTraversal(t *testing.T) { + engine := NewEngine(config.PolicyConfig{ + AllowedDirectories: []string{"/home/user/project"}, + }) + + // ../.. traversal should be caught after path cleaning + decision := engine.EvaluateScope([]string{"/home/user/project/../../etc/passwd"}) + if decision.Allowed { + t.Error("expected blocked for parent traversal escaping allowed directory") + } +} + func TestMatchFilePattern(t *testing.T) { tests := []struct { path string diff --git a/internal/proxy/intercept.go b/internal/proxy/intercept.go index e10fec3..694ed6a 100644 --- a/internal/proxy/intercept.go +++ b/internal/proxy/intercept.go @@ -105,12 +105,39 @@ func (i *Interceptor) Intercept(clientConn net.Conn, upstreamConn net.Conn, host "files", len(files), ) - // Check file policy — block if denied paths := make([]string, len(files)) for idx, f := range files { paths[idx] = f.Path } - decision := i.policy.EvaluateFiles(paths) + + // Check directory scope — block if any file is outside allowed directories + decision := i.policy.EvaluateScope(paths) + if !decision.Allowed { + slog.Warn("request blocked by directory scope policy", + "session", sess.ID, + "url", exchange.URL, + "reason", decision.Reason, + ) + exchange.StatusCode = 403 + exchange.Blocked = true + exchange.BlockReason = decision.Reason + if i.logBody { + exchange.RequestBody = truncateBody(bodyStr, i.maxBody) + } + resp403 := &http.Response{ + StatusCode: 403, + ProtoMajor: 1, + ProtoMinor: 1, + Header: http.Header{"Content-Type": {"text/plain"}}, + Body: io.NopCloser(strings.NewReader("blocked by egressor: " + decision.Reason)), + } + resp403.Write(clientTLS) + sess.Exchanges = append(sess.Exchanges, exchange) + return nil + } + + // Check file deny patterns — block if matched + decision = i.policy.EvaluateFiles(paths) if !decision.Allowed { slog.Warn("request blocked by file policy", "session", sess.ID, diff --git a/internal/ui/app.go b/internal/ui/app.go index d42da84..f31ad45 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -84,6 +84,31 @@ func (a *App) RemoveDenyPattern(pattern string) { a.engine.RemoveDenyPattern(pattern) } +func (a *App) GetAllowedDirectories() []string { + return a.engine.GetAllowedDirectories() +} + +func (a *App) SetAllowedDirectories(dirs []string) { + a.engine.SetAllowedDirectories(dirs) +} + +func (a *App) AddAllowedDirectory(dir string) { + dirs := a.engine.GetAllowedDirectories() + dirs = append(dirs, dir) + a.engine.SetAllowedDirectories(dirs) +} + +func (a *App) RemoveAllowedDirectory(dir string) { + dirs := a.engine.GetAllowedDirectories() + filtered := dirs[:0] + for _, d := range dirs { + if d != dir { + filtered = append(filtered, d) + } + } + a.engine.SetAllowedDirectories(filtered) +} + func (a *App) IsPolicyBypassed() bool { return a.engine.IsBypassed() } @@ -99,6 +124,7 @@ func (a *App) SetPolicyBypassed(bypassed bool) { func (a *App) SaveConfig() error { a.cfg.Policy.DenyFilePatterns = a.engine.GetDenyPatterns() + a.cfg.Policy.AllowedDirectories = a.engine.GetAllowedDirectories() return config.Save(a.cfgPath, a.cfg) } diff --git a/internal/ui/frontend/src/App.tsx b/internal/ui/frontend/src/App.tsx index 6e5860e..6e05e8d 100644 --- a/internal/ui/frontend/src/App.tsx +++ b/internal/ui/frontend/src/App.tsx @@ -77,6 +77,9 @@ function App() { patterns={policy.patterns} onAdd={policy.addPattern} onRemove={policy.removePattern} + allowedDirs={policy.allowedDirs} + onAddDir={policy.addDirectory} + onRemoveDir={policy.removeDirectory} onSave={policy.save} /> diff --git a/internal/ui/frontend/src/components/PolicyEditor.tsx b/internal/ui/frontend/src/components/PolicyEditor.tsx index d91c6a9..1434b1e 100644 --- a/internal/ui/frontend/src/components/PolicyEditor.tsx +++ b/internal/ui/frontend/src/components/PolicyEditor.tsx @@ -4,18 +4,30 @@ interface Props { patterns: string[]; onAdd: (pattern: string) => void; onRemove: (pattern: string) => void; + allowedDirs: string[]; + onAddDir: (dir: string) => void; + onRemoveDir: (dir: string) => void; onSave: () => void; } -export function PolicyEditor({ patterns, onAdd, onRemove, onSave }: Props) { - const [input, setInput] = useState(''); +export function PolicyEditor({ patterns, onAdd, onRemove, allowedDirs, onAddDir, onRemoveDir, onSave }: Props) { + const [patternInput, setPatternInput] = useState(''); + const [dirInput, setDirInput] = useState(''); const [saved, setSaved] = useState(false); - const handleAdd = () => { - const trimmed = input.trim(); + const handleAddPattern = () => { + const trimmed = patternInput.trim(); if (trimmed && !patterns.includes(trimmed)) { onAdd(trimmed); - setInput(''); + setPatternInput(''); + } + }; + + const handleAddDir = () => { + const trimmed = dirInput.trim(); + if (trimmed && !allowedDirs.includes(trimmed)) { + onAddDir(trimmed); + setDirInput(''); } }; @@ -26,9 +38,9 @@ export function PolicyEditor({ patterns, onAdd, onRemove, onSave }: Props) { }; return ( -
+ Files outside these directories will be blocked. Leave empty to allow all. +
++ Files matching these patterns will be blocked even within allowed directories. +
+