Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions config.yaml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
8 changes: 7 additions & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
Expand Down
83 changes: 83 additions & 0 deletions internal/policy/policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package policy

import (
"fmt"
"os"
"path/filepath"
"strings"
"sync"
Expand Down Expand Up @@ -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}
}

Expand Down Expand Up @@ -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() {
Expand Down
89 changes: 89 additions & 0 deletions internal/policy/policy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 29 additions & 2 deletions internal/proxy/intercept.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
26 changes: 26 additions & 0 deletions internal/ui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand All @@ -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)
}

Expand Down
3 changes: 3 additions & 0 deletions internal/ui/frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
/>
</div>
Expand Down
Loading
Loading