# E-1c Configurable Timeouts + Path Sanitization

**Date:** 2026-03-29
**Branch:** culurien

---

## Summary

Two items completed:
1. **F-E1-3**: 6 hardcoded operational timeout values made configurable via DB settings
2. **Security**: Binary path traversal defense-in-depth for downloads handler

## F-E1-3: Configurable Timeout Values

### The 6 Hardcoded Values (Before)

| Location | Value | Purpose |
|----------|-------|---------|
| main.go:429 | `2 * time.Minute` | Offline agent check interval |
| main.go:436 | `10 * time.Minute` | Offline threshold (mark agent offline) |
| main.go:445 | `24 * time.Hour` | Token cleanup interval |
| timeout.go:28 | `2 * time.Hour` | Sent command timeout |
| timeout.go:29 | `30 * time.Minute` | Pending command timeout |
| timeout.go:40 | `5 * time.Minute` | Timeout check interval |

### Migration 030: Seed Operational Settings

Created `030_add_operational_settings.up.sql` which inserts 6 rows into `security_settings` under category `operational`:

| Key | Default | Range | Description |
|-----|---------|-------|-------------|
| `offline_check_interval_seconds` | 120 | 30-3600 | How often to check for offline agents |
| `offline_threshold_minutes` | 10 | 2-60 | Minutes before agent marked offline |
| `token_cleanup_interval_hours` | 24 | 1-168 | Hours between token cleanup runs |
| `sent_command_timeout_hours` | 2 | 1-24 | Hours before sent commands time out |
| `pending_command_timeout_minutes` | 30 | 5-120 | Minutes before pending commands time out |
| `timeout_check_interval_minutes` | 5 | 1-30 | How often timeout service checks |

Migration uses `ON CONFLICT (category, key) DO NOTHING` for idempotency (ETHOS #4). Leverages existing `UNIQUE(category, key)` constraint from migration 020.

### Fallback Strategy

A helper function `getOperationalSetting()` reads from the DB with fallback:
1. If `SecuritySettingsService` is nil (DB not ready): return default
2. If `GetSetting()` errors: return default
3. If value is not a valid positive number: return default
4. Otherwise: return the DB value

This means the server starts correctly even if migration 030 hasn't run yet.

### TimeoutService Constructor Change (Option A)

Changed `NewTimeoutService` to accept 3 additional duration parameters:

```go
func NewTimeoutService(cq, uq, sentTimeout, pendingTimeout, checkInterval time.Duration)
```

Zero values are replaced with defaults (2h, 30m, 5m). The check interval is now a configurable field instead of a hardcoded `5 * time.Minute` in `Start()`.

### Startup Log

```
[INFO] [server] [config] operational_timeouts_loaded offline_check=2m0s offline_threshold=10m0s token_cleanup=24h0m0s sent_cmd_timeout=2h0m0s pending_cmd_timeout=30m0s timeout_check=5m0s
```

## Security: Binary Path Sanitization

### The Risk (Flagged in E-1ab Part 4b)

`pkg.BinaryPath` from the database was passed directly to `c.File()`. If the database were compromised, arbitrary file read was possible via path traversal.

### The Fix

Before serving, the handler now:
1. Resolves `BinaryPath` to an absolute path via `filepath.Abs()`
2. Resolves the allowed directory from `config.BinaryStoragePath` (env: `REDFLAG_BINARY_STORAGE_PATH`, default: `./binaries`)
3. Checks that the resolved path has the allowed directory as a prefix
4. If not: returns 403 and logs `[ERROR] [server] [downloads] path_traversal_attempt`
5. If yes: serves the file from the resolved absolute path

### Config Addition

| Env Var | Default | Purpose |
|---------|---------|---------|
| `REDFLAG_BINARY_STORAGE_PATH` | `./binaries` | Allowed directory for binary downloads |

Added to `Config` struct in `config.go`.

## Files Changed

| File | Change |
|------|--------|
| `migrations/030_add_operational_settings.up.sql` | NEW: Seeds 6 operational settings |
| `migrations/030_add_operational_settings.down.sql` | NEW: Removes operational settings |
| `cmd/server/main.go` | Reads settings from DB, passes to TimeoutService, uses in goroutines, `getOperationalSetting()` helper |
| `internal/services/timeout.go` | Constructor accepts durations + checkInterval field, `Start()` uses configurable interval |
| `internal/config/config.go` | Added `BinaryStoragePath` field |
| `internal/api/handlers/downloads.go` | Path sanitization before `c.File()` |

## Tests Added

| File | Tests |
|------|-------|
| `services/timeout_config_test.go` | `TestTimeoutServiceUsesConfiguredValues`, `TestTimeoutServiceFallsBackToDefaults`, `TestGetOperationalSettingFallsBackToDefault` |
| `handlers/downloads_security_test.go` | `TestDownloadsRejectsPathTraversal`, `TestDownloadsAcceptsSafePath`, `TestDownloadsRejectsSymlinkEscape` |

## Test Results

```
Server: 103 passed, 0 failed (7 packages)
Agent:   60 passed, 0 failed (10 packages)
Total:  163 tests, 0 failures
```
