Skip to content

Commit 56f065d

Browse files
committed
fix(security): Telegram low findings #62, #75, #76
- #62: cap Telegram plan files at 1 MiB for ReadPlan/MostRecentPlan; list previews read only first 8 KiB - #75: verify outbound media final component with atomic O_NOFOLLOW open + fstat on Unix to close symlink TOCTOU race - #76: create Telegram log files with 0600 and chmod existing files - added regression tests and updated SECURITY.md / AGENTS.md
1 parent d12e3b8 commit 56f065d

9 files changed

Lines changed: 152 additions & 18 deletions

File tree

AGENTS.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,9 @@ Layered prompt-injection / approval-fatigue defenses. Full reference: [docs/SECU
152152
- **Telegram singleton flock lock** (`cmd/odek/telegram.go` + `internal/flock`) — the Telegram bot now uses an advisory `flock` on `~/.odek/telegram.lock` instead of a PID file probed with signals. This removes the non-Linux path where a planted PID could cause odek to kill an arbitrary process.
153153
- **Telegram photo caption wrapping** (`cmd/odek/telegram.go`) — photo captions cross the Telegram trust boundary, so they are wrapped as untrusted content both when passed to the local vision model and when injected into the main agent's user message.
154154
- **`send_message` callback prefix restriction** (`internal/tool/send_message.go` + `cmd/odek/telegram.go`) — the `send_message` tool rejects any button whose `callback_data` starts with a reserved internal prefix (`apr:`, `den:`, `trs:`, `clarify:`, `skill_save:`, `skill_skip:`); only user-facing `cb:` callbacks are allowed. The Telegram sender closure validates again as defense-in-depth, preventing a forged approval or skill button.
155-
- **Telegram outbound media path allowlist** (`internal/telegram/media_path.go` + `internal/telegram/handler.go` + `internal/tool/send_message.go` + `cmd/odek/telegram.go`) — paths supplied to `MEDIA:...` prefixes or `send_message(file=...)` are resolved to an absolute path and verified against an allowlist (cwd, `~/.odek/media/`, system temp dir). `os.Lstat` rejects symlink final components and `filepath.EvalSymlinks` ensures the resolved path does not escape the allowlist, preventing prompt-injection-driven exfiltration of arbitrary files.
155+
- **Telegram outbound media path allowlist** (`internal/telegram/media_path.go` + `internal/telegram/handler.go` + `internal/tool/send_message.go` + `cmd/odek/telegram.go`) — paths supplied to `MEDIA:...` prefixes or `send_message(file=...)` are resolved to an absolute path and verified against an allowlist (cwd, `~/.odek/media/`, system temp dir). On Unix the final component is opened with `O_NOFOLLOW` and `fstat`'d to avoid a symlink TOCTOU race; `filepath.EvalSymlinks` ensures the resolved path does not escape the allowlist, preventing prompt-injection-driven exfiltration of arbitrary files.
156+
- **Telegram plan file size cap** (`internal/telegram/plan.go`) — plan files larger than 1 MiB are rejected by `ReadPlan` and `MostRecentPlan`, and `ListPlans` only reads the first 8 KiB for preview. This prevents a prompt-injected agent from causing an OOM via a multi-hundred-megabyte plan file.
157+
- **Telegram log file permissions** (`internal/telegram/log.go`) — Telegram log files are created with `0600`, and `os.Chmod` hardens existing files. Chat IDs and task snippets are no longer world-readable by default.
156158
- **Session ID entropy + session-scoped auth tokens** (`internal/session/session.go`, `cmd/odek/serve.go`) — session IDs now carry 128 bits of randomness (16 bytes / 32 hex chars); each session stores a 256-bit `AuthToken` required by `GET/DELETE/POST /api/sessions/<id>`, `POST /api/cancel`, and WebSocket session-resume messages via `X-Session-Token` header, `session_token` cookie, or `auth_token` WS field. Per-IP rate limiting (60/min) on session lookups adds a brute-force backstop.
157159
- **Skill/episode untrusted wrapper** (`internal/loop/loop.go` + `odek.go`) — skill context and retrieved session-episode context are passed through the caller-provided untrusted wrapper (the same nonce'd `<untrusted_content_*>` boundary used for tool output) before being injected into the model's system context. This prevents a compromised or tainted skill/episode from being treated as trusted system instructions.
158160
- **`env` / `printenv` environment-dump gate** (`internal/danger/classifier.go`) — bare `env` and `printenv` invocations are classified as `system_write` because they can leak process-environment secrets that the redaction scanner does not recognise. `env VAR=value <cmd>` still classifies `<cmd>` normally.

docs/SECURITY.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -353,7 +353,7 @@ When the agent emits `MEDIA:photo:/path`, `MEDIA:voice:/path`, `MEDIA:document:/
353353
- `~/.odek/media/`, and
354354
- the system temporary directory.
355355

356-
The path is resolved to an absolute, cleaned form with `filepath.Abs`, symlinks are resolved with `filepath.EvalSymlinks`, and the final component is checked with `os.Lstat`. If the final component is a symlink, or if the resolved path escapes the allowlist, the upload is rejected. This closes the arbitrary-file-read/exfiltration vector where a prompt-injected agent asks the bot to send files such as `/home/user/.ssh/id_rsa`.
356+
The path is resolved to an absolute, cleaned form with `filepath.Abs`, symlinks are resolved with `filepath.EvalSymlinks`, and the final component is verified with an atomic `O_NOFOLLOW` open + `fstat` (Unix). If the final component is a symlink, or if the resolved path escapes the allowlist, the upload is rejected. This closes the arbitrary-file-read/exfiltration vector where a prompt-injected agent asks the bot to send files such as `/home/user/.ssh/id_rsa`.
357357

358358
### 24. Session ID entropy + session-scoped auth tokens
359359

@@ -448,6 +448,14 @@ Telegram's message and caption limits are defined in UTF-16 code units, but `int
448448

449449
The `send_message` tool lets the agent send arbitrary text messages to Telegram using `ParseModeMarkdownV2`. Because the LLM may echo or reformat attacker-controllable content, the text is now escaped with `telegram.EscapeMarkdown` before sending. This prevents a prompt-injected payload from using Telegram's Markdown syntax to hide malicious links, fake buttons, or instruction-like formatting inside an otherwise ordinary-looking message.
450450

451+
### 39a. Telegram plan file size cap
452+
453+
Plan files live in `~/.odek/plans/` and are loaded by `/plan_view` and injected into context by `/plan_resume`. A prompt-injected agent could write a multi-hundred-megabyte plan, causing the next plan operation to OOM. `ReadPlan` and `MostRecentPlan` now reject files larger than 1 MiB, and `ListPlans` reads only the first 8 KiB for preview.
454+
455+
### 39b. Telegram log file permissions
456+
457+
Telegram log files were created with world-readable `0644` permissions, exposing chat IDs and task snippets to other local users. `NewFileLogger` now creates log files with `0600` and `os.Chmod`'s existing files to the same mode.
458+
451459
### 40. `/api/resources` result limit cap
452460

453461
The `/api/resources?q=...&limit=N` autocomplete endpoint previously accepted any positive `limit` value. It is now capped to 100 results both in the HTTP handler and in `Registry.Search`. This prevents a prompt-injected or attacker-forged request from forcing an unbounded directory walk and returning a multi-megabyte JSON response.

internal/telegram/log.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,15 @@ type fileLogger struct {
5656
func NewFileLogger(level LogLevel, path string) Logger {
5757
fl := &fileLogger{level: level, mu: &sync.Mutex{}}
5858
if path != "" {
59-
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
59+
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
6060
if err != nil {
6161
// Fall back to stderr if we can't open the file.
6262
fmt.Fprintf(os.Stderr, "telegram: failed to open log file %s: %v\n", path, err)
6363
fl.file = os.Stderr
6464
} else {
65+
// Harden existing files that were created with 0644 in earlier
66+
// versions; ignore chmod errors (e.g. read-only FS).
67+
_ = os.Chmod(path, 0600)
6568
fl.file = f
6669
}
6770
} else {

internal/telegram/log_test.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,15 @@ func TestNewFileLogger_filePath(t *testing.T) {
5050
if _, err := os.Stat(path); os.IsNotExist(err) {
5151
t.Error("log file was not created")
5252
}
53+
54+
// Verify permissions are not group/other-readable.
55+
info, err := os.Stat(path)
56+
if err != nil {
57+
t.Fatalf("stat log file: %v", err)
58+
}
59+
if info.Mode().Perm()&0077 != 0 {
60+
t.Errorf("log file permissions = %04o, want no group/other bits", info.Mode().Perm())
61+
}
5362
}
5463

5564
func TestNewFileLogger_invalidPath(t *testing.T) {

internal/telegram/media_path.go

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -42,19 +42,16 @@ func ResolveMediaPath(path string) (string, error) {
4242
abs = filepath.Clean(abs)
4343

4444
// The final component must not be a symlink and must be a regular file.
45-
info, err := os.Lstat(abs)
46-
if err != nil {
47-
return "", fmt.Errorf("media path: lstat: %w", err)
48-
}
49-
if info.Mode()&os.ModeSymlink != 0 {
50-
return "", fmt.Errorf("media path: symlinks are not allowed: %s", abs)
51-
}
52-
if !info.Mode().IsRegular() {
53-
return "", fmt.Errorf("media path: not a regular file: %s", abs)
45+
// On Unix this is done with an atomic O_NOFOLLOW open + fstat to prevent
46+
// a TOCTOU race where a directory is swapped for a symlink between lstat
47+
// and the subsequent read.
48+
if err := verifyRegularFile(abs); err != nil {
49+
return "", err
5450
}
5551

56-
// Resolve all symlinks in the path. Any symlink that escapes the allowlist
57-
// is caught by the containment check below.
52+
// Resolve any symlinks in the path (but not the final component, which we
53+
// already verified is a regular file). Any symlink that escapes the
54+
// allowlist is caught by the containment check below.
5855
resolved, err := filepath.EvalSymlinks(abs)
5956
if err != nil {
6057
return "", fmt.Errorf("media path: resolve symlinks: %w", err)
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
//go:build !unix
2+
3+
package telegram
4+
5+
import (
6+
"fmt"
7+
"os"
8+
)
9+
10+
// verifyRegularFile is the non-Unix fallback. It uses Lstat instead of an
11+
// atomic O_NOFOLLOW open; the Unix implementation provides stronger TOCTOU
12+
// protection.
13+
func verifyRegularFile(path string) error {
14+
info, err := os.Lstat(path)
15+
if err != nil {
16+
return fmt.Errorf("media path: lstat: %w", err)
17+
}
18+
if info.Mode()&os.ModeSymlink != 0 {
19+
return fmt.Errorf("media path: symlinks are not allowed: %s", path)
20+
}
21+
if !info.Mode().IsRegular() {
22+
return fmt.Errorf("media path: not a regular file: %s", path)
23+
}
24+
return nil
25+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
//go:build unix
2+
3+
package telegram
4+
5+
import (
6+
"fmt"
7+
8+
"golang.org/x/sys/unix"
9+
)
10+
11+
// verifyRegularFile verifies that path points to a regular file and is not a
12+
// symlink. It opens the path with O_NOFOLLOW and fstat's the resulting fd so
13+
// the check is atomic: an attacker cannot swap a regular file for a symlink
14+
// between an lstat and a later open (TOCTOU).
15+
func verifyRegularFile(path string) error {
16+
fd, err := unix.Open(path, unix.O_RDONLY|unix.O_NOFOLLOW|unix.O_NONBLOCK, 0)
17+
if err != nil {
18+
if err == unix.ELOOP {
19+
return fmt.Errorf("media path: symlinks are not allowed: %s", path)
20+
}
21+
return fmt.Errorf("media path: open: %w", err)
22+
}
23+
defer unix.Close(fd)
24+
25+
var st unix.Stat_t
26+
if err := unix.Fstat(fd, &st); err != nil {
27+
return fmt.Errorf("media path: fstat: %w", err)
28+
}
29+
if st.Mode&unix.S_IFMT != unix.S_IFREG {
30+
return fmt.Errorf("media path: not a regular file: %s", path)
31+
}
32+
return nil
33+
}

internal/telegram/plan.go

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package telegram
22

33
import (
44
"fmt"
5+
"io"
56
"os"
67
"path/filepath"
78
"sort"
@@ -10,6 +11,12 @@ import (
1011
"unicode"
1112
)
1213

14+
// maxPlanBytes caps the size of a plan file that odek will read into memory
15+
// or inject into a session context. A prompt-injected agent could otherwise
16+
// write a multi-hundred-megabyte plan and OOM the next /plan_view or
17+
// /plan_resume.
18+
const maxPlanBytes = 1 * 1024 * 1024 // 1 MiB
19+
1320
// ── Plan Manager ───────────────────────────────────────────────────────
1421
//
1522
// Plans are stored as .md files in ~/.odek/plans/. Each plan is a
@@ -53,6 +60,38 @@ func ensurePlansDir() (string, error) {
5360
return dir, nil
5461
}
5562

63+
// readPlanFile reads a plan file after verifying it is within the size cap.
64+
func readPlanFile(path string) ([]byte, error) {
65+
info, err := os.Stat(path)
66+
if err != nil {
67+
return nil, err
68+
}
69+
if info.Size() > maxPlanBytes {
70+
return nil, fmt.Errorf("plan file %q is too large (%d bytes, max %d)", filepath.Base(path), info.Size(), maxPlanBytes)
71+
}
72+
return os.ReadFile(path)
73+
}
74+
75+
// readPlanPreview reads the first few kilobytes of a plan file for the plan
76+
// list preview. It does not load arbitrarily large files just to extract the
77+
// first line.
78+
func readPlanPreview(path string, maxLen int) (string, error) {
79+
f, err := os.Open(path)
80+
if err != nil {
81+
return "", err
82+
}
83+
defer f.Close()
84+
85+
// 8 KiB is plenty for a first-line preview.
86+
const previewBufBytes = 8 * 1024
87+
buf := make([]byte, previewBufBytes)
88+
n, err := io.ReadFull(f, buf)
89+
if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF {
90+
return "", err
91+
}
92+
return firstLine(string(buf[:n]), maxLen), nil
93+
}
94+
5695
// Slugify converts a description into a filesystem-safe slug.
5796
// Rules: lowercase, max 60 chars, only [a-z0-9] and hyphens,
5897
// multiple hyphens collapsed, no leading/trailing hyphens.
@@ -131,8 +170,8 @@ func ListPlans(limit int) ([]PlanInfo, error) {
131170
slug := strings.TrimSuffix(e.Name(), ".md")
132171
path := filepath.Join(dir, e.Name())
133172
preview := ""
134-
if data, err := os.ReadFile(path); err == nil {
135-
preview = firstLine(string(data), 80)
173+
if p, err := readPlanPreview(path, 80); err == nil {
174+
preview = p
136175
}
137176
infos = append(infos, PlanInfo{
138177
Slug: slug,
@@ -204,7 +243,7 @@ func ReadPlan(slugPrefix string) (string, string, error) {
204243
return "", "", fmt.Errorf("no plan matching %q found — use /plans to list", slugPrefix)
205244
}
206245

207-
data, err := os.ReadFile(filepath.Join(dir, match+".md"))
246+
data, err := readPlanFile(filepath.Join(dir, match+".md"))
208247
if err != nil {
209248
return "", "", fmt.Errorf("read plan %q: %w", match, err)
210249
}
@@ -276,7 +315,7 @@ func MostRecentPlan() (string, string, error) {
276315
return "", "", fmt.Errorf("no plans found — create one with /plan <description>")
277316
}
278317

279-
data, err := os.ReadFile(infos[0].Path)
318+
data, err := readPlanFile(infos[0].Path)
280319
if err != nil {
281320
return "", "", fmt.Errorf("read plan: %w", err)
282321
}

internal/telegram/plan_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,24 @@ func TestReadPlan_NoMatch(t *testing.T) {
181181
}
182182
}
183183

184+
func TestReadPlan_OversizeRejected(t *testing.T) {
185+
tmp := t.TempDir()
186+
t.Setenv("HOME", tmp)
187+
188+
dir := filepath.Join(tmp, ".odek", "plans")
189+
os.MkdirAll(dir, 0755)
190+
path := filepath.Join(dir, "huge-plan.md")
191+
os.WriteFile(path, []byte(strings.Repeat("x", maxPlanBytes+1)), 0644)
192+
193+
_, _, err := ReadPlan("huge-plan")
194+
if err == nil {
195+
t.Fatal("expected error for oversized plan")
196+
}
197+
if !strings.Contains(err.Error(), "too large") {
198+
t.Errorf("error = %q, want 'too large'", err)
199+
}
200+
}
201+
184202
func TestDeletePlan(t *testing.T) {
185203
tmp := t.TempDir()
186204
t.Setenv("HOME", tmp)

0 commit comments

Comments
 (0)