Skip to content

Commit 30771c0

Browse files
committed
fix(security): VProtocol auto-repairs for low findings #69, #77, #78, #79, #81
- cmd/odek/serve.go: server-side cap on WebSocket prompt size (1 MiB) and validation of model ID length/characters (#69, #81) - cmd/odek/subagent.go: parse and cap --timeout (<=3600s) and --max-iter (<=100) (#79) - internal/config/loader.go: refuse to load ~/.odek/secrets.env when it is group/world-readable (#78) - internal/telegram/health.go: warn when health server binds to a non-loopback address (#77) - Add regression tests for all fixes; full suite, race detector, go vet and golangci-lint pass
1 parent ef6360e commit 30771c0

8 files changed

Lines changed: 312 additions & 15 deletions

File tree

cmd/odek/serve.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"os"
1515
"os/signal"
1616
"path/filepath"
17+
"regexp"
1718
"strconv"
1819
"strings"
1920
"sync"
@@ -39,6 +40,18 @@ var uiFS embed.FS
3940
// multi-gigabyte frame.
4041
const maxWSMessageBytes = 8 * 1024 * 1024 // 8 MiB
4142

43+
// maxPromptBytes caps the size of the user prompt accepted through the Web UI.
44+
// Combined with the WebSocket frame cap, this prevents a local client from
45+
// bloating the session file or exhausting the LLM context budget.
46+
const maxPromptBytes = 1 * 1024 * 1024 // 1 MiB
47+
48+
// maxModelIDBytes caps the length of a model ID supplied by the Web UI.
49+
const maxModelIDBytes = 128
50+
51+
// modelIDPattern restricts model IDs to printable ASCII characters commonly
52+
// used by model providers (alphanumeric, punctuation, and path separators).
53+
var modelIDPattern = regexp.MustCompile(`^[A-Za-z0-9_.:/@-]+$`)
54+
4255
// maxWSConnections caps the number of concurrent WebSocket clients. Once the
4356
// limit is reached, further upgrade attempts are rejected with HTTP 503. This
4457
// prevents a local attacker from spawning unlimited connections, each of which
@@ -830,6 +843,24 @@ func handlePrompt(
830843
prompt := msg.Content
831844
sessionID := msg.SessionID
832845

846+
// Server-side cap on prompt size (finding #69). A client can already send
847+
// up to the WebSocket frame cap; reject anything above a reasonable prompt
848+
// limit before storing it in the session or forwarding it to the LLM.
849+
if len(prompt) > maxPromptBytes {
850+
writeWSError(conn, "prompt exceeds maximum size")
851+
return currSess
852+
}
853+
854+
// Server-side validation of model IDs from the UI (finding #81). Model IDs
855+
// are passed through to the LLM client and logged; cap length and reject
856+
// control / unusual characters to prevent oversized payloads or injection.
857+
if msg.Model != "" {
858+
if len(msg.Model) > maxModelIDBytes || !modelIDPattern.MatchString(msg.Model) {
859+
writeWSError(conn, "invalid model ID")
860+
return currSess
861+
}
862+
}
863+
833864
// Resolve @ references
834865
refs := resource.ParseRefs(prompt)
835866
resolvedRefs := make(map[string]string)

cmd/odek/serve_test.go

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2234,3 +2234,115 @@ func TestServe_CSRF_TokenRequired(t *testing.T) {
22342234
conn = dialTestWS(t, addr)
22352235
conn.Close()
22362236
}
2237+
2238+
// TestServe_E2E_PromptSizeCap verifies that prompts above maxPromptBytes are
2239+
// rejected server-side before they are stored in the session or forwarded to
2240+
// the LLM (finding #69).
2241+
func TestServe_E2E_PromptSizeCap(t *testing.T) {
2242+
llmSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2243+
if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/models") {
2244+
w.Header().Set("Content-Type", "application/json")
2245+
json.NewEncoder(w).Encode(map[string]any{"data": []map[string]any{}})
2246+
return
2247+
}
2248+
t.Error("LLM must not be called for an oversized prompt")
2249+
w.WriteHeader(http.StatusOK)
2250+
}))
2251+
defer llmSrv.Close()
2252+
2253+
envCleanup := setTestEnv(t, llmSrv.URL)
2254+
defer envCleanup()
2255+
2256+
store := newTestSessionStore(t)
2257+
ln, mux := buildServeMux(t, store)
2258+
defer ln.Close()
2259+
2260+
errCh := make(chan error, 1)
2261+
go func() { errCh <- serveOnListener(ln, mux) }()
2262+
waitForHTTP(t, ln.Addr().String())
2263+
2264+
conn := dialTestWS(t, ln.Addr().String())
2265+
defer conn.Close()
2266+
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
2267+
2268+
msg := map[string]any{
2269+
"type": "prompt",
2270+
"content": strings.Repeat("x", maxPromptBytes+1),
2271+
}
2272+
payload, _ := json.Marshal(msg)
2273+
if err := golangws.Message.Send(conn, string(payload)); err != nil {
2274+
t.Fatalf("Send: %v", err)
2275+
}
2276+
2277+
var raw []byte
2278+
if err := golangws.Message.Receive(conn, &raw); err != nil {
2279+
t.Fatalf("expected error event, got receive error: %v", err)
2280+
}
2281+
var evt map[string]any
2282+
if err := json.Unmarshal(raw, &evt); err != nil {
2283+
t.Fatalf("unmarshal event: %v", err)
2284+
}
2285+
if evt["type"] != "error" {
2286+
t.Fatalf("expected error event for oversized prompt, got %v", evt["type"])
2287+
}
2288+
}
2289+
2290+
// TestServe_E2E_InvalidModelIDRejected verifies that model IDs from the Web UI
2291+
// are length- and character-validated before use (finding #81).
2292+
func TestServe_E2E_InvalidModelIDRejected(t *testing.T) {
2293+
llmSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2294+
if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/models") {
2295+
w.Header().Set("Content-Type", "application/json")
2296+
json.NewEncoder(w).Encode(map[string]any{"data": []map[string]any{}})
2297+
return
2298+
}
2299+
t.Error("LLM must not be called for an invalid model ID")
2300+
w.WriteHeader(http.StatusOK)
2301+
}))
2302+
defer llmSrv.Close()
2303+
2304+
envCleanup := setTestEnv(t, llmSrv.URL)
2305+
defer envCleanup()
2306+
2307+
store := newTestSessionStore(t)
2308+
ln, mux := buildServeMux(t, store)
2309+
defer ln.Close()
2310+
2311+
errCh := make(chan error, 1)
2312+
go func() { errCh <- serveOnListener(ln, mux) }()
2313+
waitForHTTP(t, ln.Addr().String())
2314+
2315+
conn := dialTestWS(t, ln.Addr().String())
2316+
defer conn.Close()
2317+
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
2318+
2319+
cases := []string{
2320+
strings.Repeat("x", maxModelIDBytes+1),
2321+
"model\nwith-newline",
2322+
"model\x00with-null",
2323+
"model<script>",
2324+
}
2325+
for _, model := range cases {
2326+
msg := map[string]any{
2327+
"type": "prompt",
2328+
"content": "hello",
2329+
"model": model,
2330+
}
2331+
payload, _ := json.Marshal(msg)
2332+
if err := golangws.Message.Send(conn, string(payload)); err != nil {
2333+
t.Fatalf("Send: %v", err)
2334+
}
2335+
2336+
var raw []byte
2337+
if err := golangws.Message.Receive(conn, &raw); err != nil {
2338+
t.Fatalf("expected error event, got receive error: %v", err)
2339+
}
2340+
var evt map[string]any
2341+
if err := json.Unmarshal(raw, &evt); err != nil {
2342+
t.Fatalf("unmarshal event: %v", err)
2343+
}
2344+
if evt["type"] != "error" {
2345+
t.Fatalf("expected error event for model %q, got %v", model, evt["type"])
2346+
}
2347+
}
2348+
}

cmd/odek/subagent.go

Lines changed: 39 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -174,20 +174,21 @@ func parseSubagentConfig(data string) subagentConfig {
174174
// 1 = task error (status: "error" with message)
175175
// 2 = timeout (killed by parent/context)
176176
// 3 = internal setup error
177-
func subagentCmd(args []string) error {
178-
// Parse flags
179-
var cfg struct {
180-
goal string
181-
context string
182-
taskFile string
183-
timeout int
184-
maxIter int
185-
quiet bool
186-
stream bool
187-
parentSession string
188-
}
189-
190-
// Simple flag parser (matches existing pattern in parseRunFlags)
177+
// subagentFlags holds the parsed flags for `odek subagent`.
178+
type subagentFlags struct {
179+
goal string
180+
context string
181+
taskFile string
182+
timeout int
183+
maxIter int
184+
quiet bool
185+
stream bool
186+
parentSession string
187+
}
188+
189+
// parseSubagentFlags parses and validates sub-agent CLI flags.
190+
func parseSubagentFlags(args []string) (subagentFlags, error) {
191+
var cfg subagentFlags
191192
i := 0
192193
for i < len(args) {
193194
switch args[i] {
@@ -226,11 +227,34 @@ func subagentCmd(args []string) error {
226227
cfg.parentSession = args[i]
227228
}
228229
default:
229-
return fmt.Errorf("unknown flag %q", args[i])
230+
return cfg, fmt.Errorf("unknown flag %q", args[i])
230231
}
231232
i++
232233
}
233234

235+
// Clamp runaway limits (finding #79). Values <= 0 fall through to the
236+
// defaults in subagentCmd; explicitly huge values are capped to prevent a
237+
// single sub-agent invocation from running forever.
238+
const (
239+
maxSubagentTimeout = 3600 // 1 hour
240+
maxSubagentIter = 100
241+
)
242+
if cfg.timeout > maxSubagentTimeout {
243+
cfg.timeout = maxSubagentTimeout
244+
}
245+
if cfg.maxIter > maxSubagentIter {
246+
cfg.maxIter = maxSubagentIter
247+
}
248+
249+
return cfg, nil
250+
}
251+
252+
func subagentCmd(args []string) error {
253+
cfg, err := parseSubagentFlags(args)
254+
if err != nil {
255+
return err
256+
}
257+
234258
// Validate: --goal XOR --task
235259
hasGoal := cfg.goal != ""
236260
hasTaskFile := cfg.taskFile != ""

cmd/odek/subagent_contract_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1076,6 +1076,32 @@ func skipIfNoBinary(t *testing.T) {
10761076
}
10771077
}
10781078

1079+
// TestParseSubagentFlags_CapsRunawayLimits verifies that --timeout and
1080+
// --max-iter are capped at sensible maximums (finding #79).
1081+
func TestParseSubagentFlags_CapsRunawayLimits(t *testing.T) {
1082+
cfg, err := parseSubagentFlags([]string{
1083+
"--goal", "test",
1084+
"--timeout", "999999",
1085+
"--max-iter", "99999",
1086+
})
1087+
if err != nil {
1088+
t.Fatalf("parseSubagentFlags error: %v", err)
1089+
}
1090+
if cfg.timeout != 3600 {
1091+
t.Errorf("timeout cap = %d, want 3600", cfg.timeout)
1092+
}
1093+
if cfg.maxIter != 100 {
1094+
t.Errorf("max-iter cap = %d, want 100", cfg.maxIter)
1095+
}
1096+
}
1097+
1098+
func TestParseSubagentFlags_UnknownFlag(t *testing.T) {
1099+
_, err := parseSubagentFlags([]string{"--unknown"})
1100+
if err == nil {
1101+
t.Fatal("expected error for unknown flag")
1102+
}
1103+
}
1104+
10791105
func isFlagParseError(err error) bool {
10801106
if err == nil {
10811107
return false

internal/config/loader.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1598,6 +1598,16 @@ func loadSecretsEnv() {
15981598
}
15991599
defer f.Close()
16001600

1601+
// Refuse to load secrets from a file that is readable by anyone other than
1602+
// the owner. A world/group-readable secrets.env leaks API keys and tokens
1603+
// to other local users (finding #78).
1604+
if info, err := f.Stat(); err == nil {
1605+
if perm := info.Mode().Perm(); perm&0077 != 0 {
1606+
fmt.Fprintf(os.Stderr, "odek: WARNING: %s is group/world-readable (%04o); refusing to load secrets\n", path, perm)
1607+
return
1608+
}
1609+
}
1610+
16011611
scanner := bufio.NewScanner(f)
16021612
for scanner.Scan() {
16031613
line := strings.TrimSpace(scanner.Text())

internal/config/loader_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1375,3 +1375,42 @@ func TestLoadFile_CapsSizeViaLimitReader(t *testing.T) {
13751375
t.Fatalf("loadFile should reject oversized file read via LimitReader, got Model=%q", cfg.Model)
13761376
}
13771377
}
1378+
1379+
// TestLoadConfig_SecretsEnvPermissionCheck verifies that secrets.env is only
1380+
// loaded when it is owner-readable (finding #78).
1381+
func TestLoadConfig_SecretsEnvPermissionCheck(t *testing.T) {
1382+
home := t.TempDir()
1383+
t.Setenv("HOME", home)
1384+
odekDir := filepath.Join(home, ".odek")
1385+
if err := os.MkdirAll(odekDir, 0755); err != nil {
1386+
t.Fatal(err)
1387+
}
1388+
path := filepath.Join(odekDir, "secrets.env")
1389+
1390+
// World/group-readable secrets.env must be ignored.
1391+
if err := os.WriteFile(path, []byte("ODEK_TEST_SECRET=world-readable\n"), 0644); err != nil {
1392+
t.Fatal(err)
1393+
}
1394+
t.Setenv("ODEK_TEST_SECRET", "")
1395+
LoadConfig(CLIFlags{})
1396+
if os.Getenv("ODEK_TEST_SECRET") == "world-readable" {
1397+
t.Error("world-readable secrets.env was loaded")
1398+
}
1399+
1400+
// Owner-only readable secrets.env must be loaded.
1401+
if err := os.Remove(path); err != nil {
1402+
t.Fatal(err)
1403+
}
1404+
if err := os.WriteFile(path, []byte("ODEK_TEST_SECRET=owner-only\n"), 0600); err != nil {
1405+
t.Fatal(err)
1406+
}
1407+
// Ensure the file really is 0600 even under a permissive umask.
1408+
if err := os.Chmod(path, 0600); err != nil {
1409+
t.Fatal(err)
1410+
}
1411+
t.Setenv("ODEK_TEST_SECRET", "")
1412+
LoadConfig(CLIFlags{})
1413+
if os.Getenv("ODEK_TEST_SECRET") != "owner-only" {
1414+
t.Errorf("owner-only secrets.env not loaded, got %q", os.Getenv("ODEK_TEST_SECRET"))
1415+
}
1416+
}

internal/telegram/health.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,15 @@ func (hs *HealthServer) Start(ctx context.Context) error {
8585
}
8686
hs.addr = ln.Addr().String() // store actual bound address (e.g. :0 → :34567)
8787

88+
// Warn when binding to a non-loopback address. The health endpoint exposes
89+
// bot liveness/uptime; under the single-user model it should not be
90+
// reachable from the network (finding #77).
91+
if host, _, err := net.SplitHostPort(hs.addr); err == nil {
92+
if host == "" || (host != "127.0.0.1" && host != "::1" && host != "localhost") {
93+
hs.log.Warn("health server binding to non-loopback address", "addr", hs.addr)
94+
}
95+
}
96+
8897
srv := &http.Server{Addr: hs.addr, Handler: hs}
8998
hs.log.Info("health server started", "addr", ln.Addr().String())
9099

internal/telegram/health_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,3 +142,49 @@ func (m *mockResponseWriter) Write(b []byte) (int, error) {
142142
func (m *mockResponseWriter) WriteHeader(statusCode int) {
143143
m.statusCode = statusCode
144144
}
145+
146+
// captureLogger records Warn calls for testing.
147+
type captureLogger struct {
148+
warnings []string
149+
}
150+
151+
func (c *captureLogger) Debug(_ string, _ ...any) {}
152+
func (c *captureLogger) Info(_ string, _ ...any) {}
153+
func (c *captureLogger) Warn(msg string, _ ...any) { c.warnings = append(c.warnings, msg) }
154+
func (c *captureLogger) Error(_ string, _ ...any) {}
155+
func (c *captureLogger) With(_ ...any) Logger { return c }
156+
157+
func TestHealthServer_NonLoopbackAddressWarns(t *testing.T) {
158+
log := &captureLogger{}
159+
hs := NewHealthServer(":0")
160+
hs.SetLogger(log)
161+
162+
ctx, cancel := context.WithCancel(context.Background())
163+
errCh := make(chan error, 1)
164+
go func() {
165+
errCh <- hs.Start(ctx)
166+
}()
167+
168+
time.Sleep(50 * time.Millisecond)
169+
cancel()
170+
171+
select {
172+
case err := <-errCh:
173+
if err != nil && !strings.Contains(err.Error(), "server closed") {
174+
t.Errorf("unexpected shutdown error: %v", err)
175+
}
176+
case <-time.After(2 * time.Second):
177+
t.Fatal("timeout waiting for health server shutdown")
178+
}
179+
180+
found := false
181+
for _, w := range log.warnings {
182+
if strings.Contains(w, "non-loopback") {
183+
found = true
184+
break
185+
}
186+
}
187+
if !found {
188+
t.Errorf("expected non-loopback binding warning, got %v", log.warnings)
189+
}
190+
}

0 commit comments

Comments
 (0)