diff --git a/AGENTS.md b/AGENTS.md index 702861d..363433e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -117,6 +117,7 @@ Layered prompt-injection / approval-fatigue defenses. Full reference: [docs/SECU - **WebSocket CSRF token** (`cmd/odek/serve.go`) — `odek serve` issues a random 256-bit token at startup and requires it on `/ws` via cookie, `X-Odek-Ws-Token` header, or `odek.` subprotocol. The token is no longer embedded in every `GET /` response; it is only delivered when the request includes the correct `?token=` query parameter (Jupyter-style), and a non-loopback bind prints a loud warning. The localhost origin check remains as defense-in-depth. - **SSRF / DNS-rebinding dial guard** (`cmd/odek/ssrf_guard.go` + `internal/danger/classifier.go`) — `browser`, `http_batch`, and `web_search` resolve hostnames at dial time and refuse internal IPs (loopback, RFC1918, RFC4193, link-local, RFC 6598 CGNAT `100.64.0.0/10`, RFC 2544 benchmark `198.18.0.0/15`, and unspecified), then pin the dial to the validated IP. Operator-configured backends (e.g. `web_search.base_url`) are added to an allowlist so container-internal services such as SearXNG remain reachable. - **SSRF guard proxy refusal** (`cmd/odek/ssrf_guard.go`) — when `HTTP(S)_PROXY` is set, the transport would dial the proxy instead of the target and the dial guard would only validate the proxy address. `ssrfGuardedTransport` detects an active proxy, logs a warning, and refuses the request so SSRF protection is not silently voided. +- **SSRF error wrapping** (`cmd/odek/ssrf_guard.go`, `cmd/odek/browser_tool.go`, `cmd/odek/perf_tools.go`) — the SSRF refusal message no longer includes the resolved internal IP, and `browser`/`http_batch` network/TLS errors are wrapped as untrusted content so attacker-controlled text in x509 errors cannot reach the model unwrapped. - **REST API CSRF protection** (`cmd/odek/serve.go::requireLocalOrigin`) — state-changing HTTP endpoints (POST/PUT/PATCH/DELETE) require a localhost origin or no Origin header, and static responses set `X-Frame-Options: DENY` + `Content-Security-Policy: frame-ancestors 'none'` to block clickjacking. Every `/api/*` endpoint additionally requires the per-instance `odek_ws_token` (cookie or `X-Odek-Ws-Token` header) and rejects non-loopback `Host` headers, closing DNS-rebinding reads of `/api/sessions`, `/api/resources`, and `/api/models`. - **Browser history cap** (`cmd/odek/browser_tool.go`) — navigation history is capped at 50 snapshots to prevent memory DoS from repeated `browser_navigate` calls. - **Browser element cap** (`cmd/odek/browser_tool.go`) — the number of interactive elements extracted per page is capped at 500 so a hostile page cannot OOM the agent with thousands of links or buttons. diff --git a/cmd/odek/browser_tool.go b/cmd/odek/browser_tool.go index 09213cb..8474357 100644 --- a/cmd/odek/browser_tool.go +++ b/cmd/odek/browser_tool.go @@ -236,7 +236,10 @@ func (t *browserTool) doNavigate(rawURL string) (string, error) { resp, err := t.client.Do(req) if err != nil { - return jsonError(fmt.Sprintf("cannot fetch %q: %v", rawURL, err)) + // Wrap network/TLS errors as untrusted: x509 errors can contain + // attacker-controlled SAN text, and dial errors can expose internal IPs. + msg := fmt.Sprintf("cannot fetch %q: %v", rawURL, err) + return jsonError(wrapUntrusted(t.toolCtx(), rawURL, msg)) } defer resp.Body.Close() diff --git a/cmd/odek/browser_tool_test.go b/cmd/odek/browser_tool_test.go index 6bd71b5..25063b6 100644 --- a/cmd/odek/browser_tool_test.go +++ b/cmd/odek/browser_tool_test.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + "fmt" "net/http" "net/http/httptest" "strings" @@ -452,6 +453,41 @@ func TestBrowser_ExtractsInteractiveElements(t *testing.T) { } } +// TestBrowser_Navigate_ErrorWrapped verifies that network/TLS errors from the +// HTTP client are wrapped as untrusted content so attacker-controlled text +// (e.g. x509 SANs) cannot reach the model outside the untrusted boundary. +func TestBrowser_Navigate_ErrorWrapped(t *testing.T) { + allow := "allow" + b := &browserTool{ + state: &browserState{nextRef: 1}, + dangerousConfig: danger.DangerousConfig{NonInteractive: &allow}, + } + b.client = &http.Client{ + Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) { + return nil, fmt.Errorf("x509: certificate is valid for attacker-controlled.example.com, not example.com") + }), + } + + result := callJSON(t, b, `{"action":"navigate","url":"https://example.com/"}`) + var r struct { + Error string `json:"error"` + } + mustUnmarshal(t, result, &r) + if r.Error == "" { + t.Fatal("expected error") + } + if !strings.HasPrefix(r.Error, "