Skip to content

Commit 4ba8c3e

Browse files
jkyberneeesclaude
andcommitted
fix(web_search): address code-review findings (answers, SSRF, secret, cold-start)
Code review of the SearXNG integration surfaced four actionable issues; fixes: #2 (confirmed) — answers parsing emitted raw answer-object JSON. SearXNG `answers` are objects ({"answer":"...","url":...,"template":...}), not bare strings, so strings.Trim(rawJSON,'"') left the {...} blob intact. Decode the `answer` field out of each object instead (also drops the fragile Trim). The test mock used a string array, masking this — corrected to objects. #3 (defense-in-depth) — the http.Client had no CheckRedirect. A compromised or misconfigured SearXNG could 3xx the client toward an internal/metadata endpoint (SSRF). Install the same per-hop re-classification guard browser and http_batch use, capped at 10 hops. New TestWebSearch_RedirectToInternalBlocked. #1 (hardening) — the mounted settings.yml hardcoded secret_key "change-me-in-dot-env". Deeper tracing showed SEARXNG_SECRET *does* override the file at app load (searx/settings_defaults.py environ_name), so the env wiring worked — but the placeholder defeated SearXNG's built-in "secret_key is not changed" warning, which only fires for the canonical "ultrasecretkey". Switch the placeholder + the compose env fallback to "ultrasecretkey" so an unset secret is loudly flagged rather than silently weak. Comments/.env.example corrected to describe the real (app-load) override mechanism. #4 (reliability) — `depends_on: [searxng]` only waits for container start, so the first web_search after `compose up` could race SearXNG readiness. Rather than a Docker healthcheck (whose probe tooling I can't verify in the upstream image — a broken probe would deadlock odek startup), make the tool resilient: retry only on ECONNREFUSED (the precise "up but not yet listening" signal), 2 extra attempts with a 1s backoff. Timeouts / genuine-down fail fast. #5 (overlay whole-pointer replace) intentionally deferred — it is consistent with the existing Skills/Memory/Transcription/Vision merge behavior; fixing only web_search would be inconsistent. Full suite green under -race; vet/gofmt clean; compose config validates. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ec540d2 commit 4ba8c3e

5 files changed

Lines changed: 105 additions & 14 deletions

File tree

cmd/odek/web_search_tool.go

Lines changed: 53 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@ package main
22

33
import (
44
"encoding/json"
5+
"errors"
56
"fmt"
67
"io"
78
"net/http"
89
"net/url"
910
"strings"
11+
"syscall"
1012
"time"
1113

1214
"github.com/BackendStack21/odek"
@@ -18,6 +20,15 @@ import (
1820
// metasearch JSON payload is small; this guards against a misbehaving backend).
1921
const maxSearXNGBody = 4 << 20 // 4 MiB
2022

23+
// Cold-start retry: how many extra attempts (and the delay between them) to
24+
// make when SearXNG refuses the connection — covers the compose startup race
25+
// where the container is up but the app isn't yet listening. Vars (not consts)
26+
// so tests can shrink the delay.
27+
var (
28+
searxngConnectRetries = 2
29+
searxngRetryDelay = time.Second
30+
)
31+
2132
// ═════════════════════════════════════════════════════════════════════════
2233
// web_search Tool (SearXNG JSON API backend)
2334
// ═════════════════════════════════════════════════════════════════════════
@@ -33,11 +44,30 @@ func newWebSearchTool(dc danger.DangerousConfig, cfg config.WebSearchConfig) *we
3344
if timeout <= 0 {
3445
timeout = 15
3546
}
36-
return &webSearchTool{
37-
dangerousConfig: dc,
38-
cfg: cfg,
39-
client: &http.Client{Timeout: time.Duration(timeout) * time.Second},
47+
t := &webSearchTool{dangerousConfig: dc, cfg: cfg}
48+
t.client = &http.Client{
49+
Timeout: time.Duration(timeout) * time.Second,
50+
CheckRedirect: t.checkRedirect,
4051
}
52+
return t
53+
}
54+
55+
// checkRedirect re-classifies every redirect hop. The configured base_url is
56+
// trusted, but a compromised, buggy, or misconfigured SearXNG could 3xx the
57+
// client toward an internal/metadata endpoint (SSRF). Re-classifying each hop —
58+
// the same guard browser/http_batch install — closes that. Installing
59+
// CheckRedirect disables Go's implicit 10-hop cap, so we re-impose it.
60+
func (t *webSearchTool) checkRedirect(req *http.Request, via []*http.Request) error {
61+
if len(via) >= 10 {
62+
return fmt.Errorf("stopped after 10 redirects")
63+
}
64+
target := req.URL.String()
65+
if err := t.dangerousConfig.CheckOperation(danger.ToolOperation{
66+
Name: "web_search", Resource: target, Risk: danger.ClassifyURL(target),
67+
}, nil); err != nil {
68+
return fmt.Errorf("redirect to %s blocked: %w", target, err)
69+
}
70+
return nil
4171
}
4272

4373
func (t *webSearchTool) Name() string { return "web_search" }
@@ -82,7 +112,11 @@ type searxngResponse struct {
82112
Content string `json:"content"`
83113
Engine string `json:"engine"`
84114
} `json:"results"`
85-
Answers []json.RawMessage `json:"answers"`
115+
// SearXNG answers are objects ({"answer": "...", "url": ..., "template": ...}),
116+
// not bare strings — decode the answer text out of each.
117+
Answers []struct {
118+
Answer string `json:"answer"`
119+
} `json:"answers"`
86120
Infoboxes []json.RawMessage `json:"infoboxes"`
87121
Suggestions []string `json:"suggestions"`
88122
}
@@ -159,8 +193,8 @@ func (t *webSearchTool) Call(argsJSON string) (result string, err error) {
159193
}
160194
out.Count = len(out.Results)
161195
for _, a := range resp.Answers {
162-
if s := strings.TrimSpace(string(a)); s != "" && s != "null" {
163-
out.Answers = append(out.Answers, strings.Trim(s, `"`))
196+
if s := strings.TrimSpace(a.Answer); s != "" {
197+
out.Answers = append(out.Answers, s)
164198
}
165199
}
166200

@@ -198,7 +232,18 @@ func (t *webSearchTool) query(query, category string) (*searxngResponse, error)
198232
}
199233
req.Header.Set("Accept", "application/json")
200234

201-
httpResp, err := t.client.Do(req)
235+
// Retry only on connection-refused — the precise signal that the SearXNG
236+
// sidecar is up as a container but not yet accepting connections (the
237+
// startup race when both come up together under compose). Other errors
238+
// (timeouts, DNS, genuine "down") fail fast on the first attempt.
239+
var httpResp *http.Response
240+
for attempt := 0; ; attempt++ {
241+
httpResp, err = t.client.Do(req)
242+
if err == nil || attempt >= searxngConnectRetries || !errors.Is(err, syscall.ECONNREFUSED) {
243+
break
244+
}
245+
time.Sleep(searxngRetryDelay)
246+
}
202247
if err != nil {
203248
return nil, fmt.Errorf("cannot reach SearXNG at %s — is the service running? (%v)", t.cfg.BaseURL, err)
204249
}

cmd/odek/web_search_tool_test.go

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"net/http/httptest"
77
"strings"
88
"testing"
9+
"time"
910

1011
"github.com/BackendStack21/odek/internal/config"
1112
"github.com/BackendStack21/odek/internal/danger"
@@ -41,7 +42,10 @@ func mockSearXNG(t *testing.T, results int) (*httptest.Server, *string) {
4142
})
4243
}
4344
resp["results"] = rs
44-
resp["answers"] = []string{"42"}
45+
// SearXNG answers are objects with an "answer" field, not bare strings.
46+
resp["answers"] = []map[string]any{
47+
{"answer": "42", "url": nil, "template": "answer/legacy.html"},
48+
}
4549
_ = json.NewEncoder(w).Encode(resp)
4650
}))
4751
t.Cleanup(srv.Close)
@@ -146,6 +150,12 @@ func TestWebSearch_JSONDisabled403(t *testing.T) {
146150
}
147151

148152
func TestWebSearch_BackendUnreachable(t *testing.T) {
153+
// Connection-refused triggers the cold-start retry; shrink the delay so the
154+
// test exercises the retry path without the 1s production backoff.
155+
orig := searxngRetryDelay
156+
searxngRetryDelay = time.Millisecond
157+
t.Cleanup(func() { searxngRetryDelay = orig })
158+
149159
// Point at a closed port on localhost.
150160
tool := newWebSearchTool(allowAllDanger(), config.WebSearchConfig{BaseURL: "http://127.0.0.1:1"})
151161
raw, _ := tool.Call(`{"query":"x"}`)
@@ -155,6 +165,31 @@ func TestWebSearch_BackendUnreachable(t *testing.T) {
155165
}
156166
}
157167

168+
func TestWebSearch_RedirectToInternalBlocked(t *testing.T) {
169+
// A compromised/misconfigured SearXNG that 302s toward an internal host must
170+
// be stopped by the CheckRedirect guard, not followed (SSRF defense).
171+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
172+
http.Redirect(w, r, "http://169.254.169.254/latest/meta-data/", http.StatusFound)
173+
}))
174+
t.Cleanup(srv.Close)
175+
176+
// Deny network egress so the redirect-hop re-classification blocks the hop.
177+
dc := danger.DangerousConfig{Classes: map[danger.RiskClass]danger.Action{
178+
danger.NetworkEgress: danger.Allow, // initial query allowed
179+
danger.SystemWrite: danger.Deny, // internal/metadata target denied
180+
}}
181+
tool := newWebSearchTool(dc, config.WebSearchConfig{BaseURL: srv.URL})
182+
183+
raw, _ := tool.Call(`{"query":"x"}`)
184+
out := decodeWebSearch(t, raw)
185+
if out.Error == "" {
186+
t.Fatalf("expected redirect to internal host to be blocked, got results: %+v", out)
187+
}
188+
if !strings.Contains(out.Error, "blocked") && !strings.Contains(out.Error, "cannot reach") {
189+
t.Errorf("expected a redirect-blocked error, got %q", out.Error)
190+
}
191+
}
192+
158193
func TestWebSearch_DeniedByPolicy(t *testing.T) {
159194
// NetworkEgress denied → the tool returns the denial as an error result.
160195
dc := danger.DangerousConfig{Classes: map[danger.RiskClass]danger.Action{

docker/.env.example

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ GIT_COMMITTER_EMAIL=you@example.com
6363

6464
# ── Web search (SearXNG sidecar; see docs/DOCKER_COMPOSE_USER_GUIDE.md) ──
6565
# The compose file runs a private SearXNG instance backing the `web_search`
66-
# tool. Set a strong random secret (e.g. `openssl rand -hex 32`). Required by
67-
# SearXNG; the instance is internal-only (no host port) but set it anyway.
66+
# tool. SearXNG reads this as server.secret_key at app load and it overrides
67+
# settings.yml. Set a strong random secret (e.g. `openssl rand -hex 32`). The
68+
# instance is internal-only (no host port) but set it anyway — left unset,
69+
# SearXNG runs with the "ultrasecretkey" sentinel and logs a warning.
6870
SEARXNG_SECRET=change-me-run-openssl-rand-hex-32

docker/docker-compose.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,11 @@ services:
108108
image: searxng/searxng:2026.6.8-f3fab143b # pinned; bump deliberately
109109
environment:
110110
- SEARXNG_BASE_URL=http://searxng:8080/
111-
- SEARXNG_SECRET=${SEARXNG_SECRET:-change-me-in-dot-env}
111+
# SearXNG reads server.secret_key from this env var at app load, overriding
112+
# settings.yml. Set SEARXNG_SECRET in .env. The fallback is the canonical
113+
# "ultrasecretkey" sentinel so an unset secret triggers SearXNG's own
114+
# "secret_key is not changed" warning rather than a silent weak literal.
115+
- SEARXNG_SECRET=${SEARXNG_SECRET:-ultrasecretkey}
112116
volumes:
113117
- ./searxng/settings.yml:/etc/searxng/settings.yml:ro
114118
# SearXNG needs outbound internet to query upstream engines (Google, Bing,

docker/searxng/settings.yml

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,13 @@ general:
1919
enable_metrics: false
2020

2121
server:
22-
# Must be overridden from the default; injected via $SEARXNG_SECRET in compose.
23-
secret_key: "change-me-in-dot-env"
22+
# SearXNG reads server.secret_key from the SEARXNG_SECRET env var at app load
23+
# (searx/settings_defaults.py: environ_name='SEARXNG_SECRET'), which overrides
24+
# this value — set SEARXNG_SECRET in .env. Keep this as the canonical
25+
# "ultrasecretkey" placeholder so that, if SEARXNG_SECRET is ever left unset,
26+
# SearXNG logs its built-in "secret_key is not changed" warning instead of
27+
# silently running with a custom-but-weak literal.
28+
secret_key: "ultrasecretkey"
2429
bind_address: "0.0.0.0"
2530
port: 8080
2631
# Single trusted consumer on a private network — disable the bot limiter

0 commit comments

Comments
 (0)