Решение: allowlist *.log, EvalSymlinks, проверка префикса root; creative phase завершён. Co-authored-by: Cursor <[email protected]>
94 lines
3.7 KiB
Markdown
94 lines
3.7 KiB
Markdown
# Creative: Runtime Logs Path Safety (CP-4)
|
||
|
||
📌 **CREATIVE PHASE START: Filesystem Path Hardening**
|
||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||
|
||
## 1️⃣ PROBLEM
|
||
|
||
**Description:** HTTP API принимает `{filename}` и читает/удаляет файлы под `EVOBGP_RUNTIME_LOGS_DIR`. Без жёсткой политики возможны path traversal, чтение произвольных файлов при symlink attack, доступ к не-log артефактам.
|
||
|
||
**Requirements:**
|
||
- Только файлы внутри configured root directory
|
||
- Только basenames из list API (no subdirs)
|
||
- Имена как у sidecar: `postgres.log`, `evobgp-all.log`, …
|
||
- Fail closed при любой аномалии
|
||
|
||
**Constraints:**
|
||
- Root: `/opt/evobgp/runtime-logs` (prod) или `./runtime-logs` (dev)
|
||
- API disabled если root unset или not `evobgp-all`
|
||
|
||
## 2️⃣ OPTIONS
|
||
|
||
| Option | Описание |
|
||
|--------|----------|
|
||
| **A** | Strict basename allowlist regex + `filepath.Join(root, base)` + prefix check |
|
||
| **B** | Только list-then-operate: handler хранит cache allowed names из ListDir |
|
||
| **C** | Regex only, без EvalSymlinks |
|
||
| **D** | Regex + EvalSymlinks + `os.SameFile` root check |
|
||
|
||
## 3️⃣ ANALYSIS
|
||
|
||
| Criterion | A | B | C | D |
|
||
|-----------|---|---|---|---|
|
||
| Traversal resistance | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
|
||
| Symlink safety | ⭐⭐⭐ | ⭐⭐⭐ | ⭐ | ⭐⭐⭐⭐⭐ |
|
||
| Simplicity | ⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ |
|
||
| No TOCTOU list cache | ⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
|
||
|
||
## 4️⃣ DECISION
|
||
|
||
**Selected: Option D** — regex + resolved path under root.
|
||
|
||
### Filename allowlist
|
||
|
||
```regexp
|
||
^[a-z0-9][a-z0-9_.-]*\.log$
|
||
```
|
||
|
||
- Длина: 3–128
|
||
- Запрещено: `..`, `/`, `\`, null
|
||
- Примеры valid: `evobgp-all.log`, `postgres.log`, `bird2.log`
|
||
- URL path param: только unescaped basename; handler rejects `%2e%2e`
|
||
|
||
### Resolution algorithm (`SafePath(root, filename)`)
|
||
|
||
1. Reject if `filename != filepath.Base(filename)` or fails regex
|
||
2. `candidate := filepath.Join(root, filename)`
|
||
3. `resolved, err := filepath.EvalSymlinks(candidate)` — if not exist for new file, use `filepath.Clean(candidate)` for delete target that exists
|
||
4. `rootAbs := filepath.Clean(root)` (must be absolute after config load)
|
||
5. Require `strings.HasPrefix(resolved+string(os.PathSeparator), rootAbs+string(os.PathSeparator))` OR `resolved == rootAbs` (reject)
|
||
6. Reject if `resolved` is directory
|
||
|
||
### ListDir
|
||
|
||
- `os.ReadDir(root)` only — **no recursion**
|
||
- Skip subdirectories, non-matching names, hidden files (prefix `.`)
|
||
- Return only entries passing regex
|
||
|
||
### Guard (`Enabled()`)
|
||
|
||
```text
|
||
EVOBGP_RUNTIME_LOGS_DIR != ""
|
||
AND filepath.IsAbs(dir) OR dir cleaned to absolute at startup
|
||
AND EVOBGP_SERVICE == "evobgp-all"
|
||
AND os.Stat(root) is directory
|
||
```
|
||
|
||
Otherwise handlers return `503` type `runtime_logs_unavailable`.
|
||
|
||
### Config load
|
||
|
||
- `EVOBGP_RUNTIME_LOGS_DIR` trimmed; default empty (disabled)
|
||
- At bootstrap: `filepath.Abs(dir)`; log warning if not exists (list returns empty, not error)
|
||
|
||
## 5️⃣ IMPLEMENTATION NOTES
|
||
|
||
- Package: `internal/runtimelogs/safe.go` — `ValidateFilename`, `ResolveLogPath`
|
||
- Tests: `../../../etc/passwd`, `foo/../bar.log`, symlink escape (skip on Windows if needed), valid names
|
||
- OpenAPI `filename` path param pattern + description
|
||
- **Never** accept absolute paths or globs from client
|
||
- List response `name` field = basename only; UI passes same string back
|
||
|
||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||
📌 **CREATIVE PHASE END: Path Safety**
|