Skip to content

Commit 6bd7c3e

Browse files
authored
fix: require serve token + loopback Host on all /api/* endpoints (M-15) (#72)
1 parent 0053f3f commit 6bd7c3e

7 files changed

Lines changed: 227 additions & 24 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ Layered prompt-injection / approval-fatigue defenses. Full reference: [docs/SECU
116116
- **Danger classifier bypass resistance** (`internal/danger/classifier.go`) — `normalize()` pre-processes: expand `$IFS` / `${IFS}`, extract `$(...)` / `` `...` `` substitutions, strip `command` / `exec` / `builtin` wrappers, collapse unquoted backslashes, basename absolute paths. `awk`/`gawk`, `sed` (`e` command / `-f`), and editors (`vi`/`vim`/`nvim`/`emacs`/`ed`/`ex`) are classified as `code_execution` when given a script or file operand, closing `awk 'BEGIN{system(...)}'`, `sed 's///e'`, and editor `!` shell escapes. `rm -rf ./` / `rm -rf ./..` are normalised to `.` / `..` for wipe-target matching, and `${VAR:--rf}` default-value flag substitutions are treated as fail-closed. Regression suite in `classifier_bypass_test.go`.
117117
- **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.<token>` 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.
118118
- **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.
119-
- **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.
119+
- **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`.
120120
- **Browser history cap** (`cmd/odek/browser_tool.go`) — navigation history is capped at 50 snapshots to prevent memory DoS from repeated `browser_navigate` calls.
121121
- **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.
122122
- **Search path classification** (`cmd/odek/file_tool.go`, `cmd/odek/perf_tools.go`) — `search_files` and `multi_grep` classify every descended directory and every discovered file the same way the search root is classified. Sensitive paths that would require approval (or are denied) are skipped and reported in the `skipped` field rather than returned silently, closing the gap where a broad search root auto-approved sensitive files.

cmd/odek/next_security_vulnerabilities_test.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,99 @@ func TestServe_CSRF_AllowsLocalhostOrigin(t *testing.T) {
211211
}
212212
}
213213

214+
// ── 4b. serve API endpoints must require the serve token and loopback Host ─
215+
216+
func TestServe_API_RequiresServeToken(t *testing.T) {
217+
store := newTestSessionStore(t)
218+
ln, mux := buildServeMux(t, store)
219+
defer ln.Close()
220+
221+
go serveOnListener(ln, mux)
222+
waitForHTTP(t, ln.Addr().String())
223+
224+
resp, err := http.Get("http://" + ln.Addr().String() + "/api/sessions")
225+
if err != nil {
226+
t.Fatalf("GET /api/sessions: %v", err)
227+
}
228+
defer resp.Body.Close()
229+
if resp.StatusCode != http.StatusForbidden {
230+
t.Fatalf("expected 403 without serve token, got %d", resp.StatusCode)
231+
}
232+
}
233+
234+
func TestServe_API_RequiresLocalHost(t *testing.T) {
235+
store := newTestSessionStore(t)
236+
ln, mux := buildServeMux(t, store)
237+
defer ln.Close()
238+
239+
testTokenMu.Lock()
240+
token := testLastToken
241+
testTokenMu.Unlock()
242+
243+
go serveOnListener(ln, mux)
244+
waitForHTTP(t, ln.Addr().String())
245+
246+
req, _ := http.NewRequest(http.MethodGet, "http://"+ln.Addr().String()+"/api/sessions", nil)
247+
req.Header.Set(wsTokenHeaderName, token)
248+
req.Host = "attacker.example.com"
249+
resp, err := http.DefaultClient.Do(req)
250+
if err != nil {
251+
t.Fatalf("GET /api/sessions: %v", err)
252+
}
253+
defer resp.Body.Close()
254+
if resp.StatusCode != http.StatusForbidden {
255+
t.Fatalf("expected 403 for non-loopback Host, got %d", resp.StatusCode)
256+
}
257+
}
258+
259+
func TestServe_API_AcceptsServeTokenHeader(t *testing.T) {
260+
store := newTestSessionStore(t)
261+
ln, mux := buildServeMux(t, store)
262+
defer ln.Close()
263+
264+
testTokenMu.Lock()
265+
token := testLastToken
266+
testTokenMu.Unlock()
267+
268+
go serveOnListener(ln, mux)
269+
waitForHTTP(t, ln.Addr().String())
270+
271+
req, _ := http.NewRequest(http.MethodGet, "http://"+ln.Addr().String()+"/api/sessions", nil)
272+
req.Header.Set(wsTokenHeaderName, token)
273+
resp, err := http.DefaultClient.Do(req)
274+
if err != nil {
275+
t.Fatalf("GET /api/sessions: %v", err)
276+
}
277+
defer resp.Body.Close()
278+
if resp.StatusCode != http.StatusOK {
279+
t.Fatalf("expected 200 with serve token header, got %d", resp.StatusCode)
280+
}
281+
}
282+
283+
func TestServe_API_AcceptsServeTokenCookie(t *testing.T) {
284+
store := newTestSessionStore(t)
285+
ln, mux := buildServeMux(t, store)
286+
defer ln.Close()
287+
288+
testTokenMu.Lock()
289+
token := testLastToken
290+
testTokenMu.Unlock()
291+
292+
go serveOnListener(ln, mux)
293+
waitForHTTP(t, ln.Addr().String())
294+
295+
req, _ := http.NewRequest(http.MethodGet, "http://"+ln.Addr().String()+"/api/sessions", nil)
296+
req.AddCookie(&http.Cookie{Name: wsTokenCookieName, Value: token})
297+
resp, err := http.DefaultClient.Do(req)
298+
if err != nil {
299+
t.Fatalf("GET /api/sessions: %v", err)
300+
}
301+
defer resp.Body.Close()
302+
if resp.StatusCode != http.StatusOK {
303+
t.Fatalf("expected 200 with serve token cookie, got %d", resp.StatusCode)
304+
}
305+
}
306+
214307
func TestServe_StaticSecurityHeaders(t *testing.T) {
215308
req := httptest.NewRequest(http.MethodGet, "/", nil)
216309
rr := httptest.NewRecorder()

cmd/odek/serve.go

Lines changed: 68 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -311,11 +311,17 @@ func serveCmd(args []string) error {
311311
handleWS(store, resourceReg, resolved, systemMessage, conn)
312312
},
313313
})
314-
mux.Handle("/api/resources", requireLocalOrigin(handleResourceSearch(resourceReg)))
315-
mux.Handle("/api/sessions", requireLocalOrigin(handleSessionList(store)))
316-
mux.Handle("/api/sessions/", requireLocalOrigin(handleSessionByID(store)))
317-
mux.Handle("/api/models", requireLocalOrigin(handleModelList(resolved.Model)))
318-
mux.Handle("/api/cancel", requireLocalOrigin(handleCancel(store)))
314+
// All API endpoints require the per-instance CSRF token, a loopback Host,
315+
// and (for state-changing methods) a local Origin. This blocks DNS-rebinding
316+
// and cross-site reads of sessions/resources/models.
317+
apiAuth := func(h http.Handler) http.Handler {
318+
return requireServeToken(wsToken)(requireLocalHost(requireLocalOrigin(h)))
319+
}
320+
mux.Handle("/api/resources", apiAuth(handleResourceSearch(resourceReg)))
321+
mux.Handle("/api/sessions", apiAuth(handleSessionList(store)))
322+
mux.Handle("/api/sessions/", apiAuth(handleSessionByID(store)))
323+
mux.Handle("/api/models", apiAuth(handleModelList(resolved.Model)))
324+
mux.Handle("/api/cancel", apiAuth(handleCancel(store)))
319325

320326
listener, err := net.Listen("tcp", addr)
321327
if err != nil {
@@ -1355,6 +1361,63 @@ func isStateChangingMethod(method string) bool {
13551361
return true
13561362
}
13571363

1364+
// requireLocalHost rejects requests whose Host header does not name a
1365+
// loopback interface. This closes DNS-rebinding attacks that point an external
1366+
// domain at 127.0.0.1 and then drive the local API from a malicious web page.
1367+
func requireLocalHost(next http.Handler) http.Handler {
1368+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1369+
host := r.Host
1370+
if h, _, err := net.SplitHostPort(host); err == nil {
1371+
host = h
1372+
}
1373+
if host != "localhost" && host != "127.0.0.1" && host != "::1" {
1374+
http.Error(w, "Host not allowed", http.StatusForbidden)
1375+
return
1376+
}
1377+
next.ServeHTTP(w, r)
1378+
})
1379+
}
1380+
1381+
// validateServeTokenHTTP verifies the per-instance CSRF token on an HTTP
1382+
// request. It accepts the same cookie and header transports as the WebSocket
1383+
// handshake, but not the WebSocket subprotocol.
1384+
func validateServeTokenHTTP(req *http.Request, token string) error {
1385+
if token == "" {
1386+
return fmt.Errorf("server token not configured")
1387+
}
1388+
1389+
// Cookie (browser same-origin / same-site requests).
1390+
if c, err := req.Cookie(wsTokenCookieName); err == nil && c.Value != "" {
1391+
if subtle.ConstantTimeCompare([]byte(c.Value), []byte(token)) == 1 {
1392+
return nil
1393+
}
1394+
}
1395+
1396+
// Explicit header (non-browser clients).
1397+
if h := req.Header.Get(wsTokenHeaderName); h != "" {
1398+
if subtle.ConstantTimeCompare([]byte(h), []byte(token)) == 1 {
1399+
return nil
1400+
}
1401+
}
1402+
1403+
return fmt.Errorf("missing or invalid server token")
1404+
}
1405+
1406+
// requireServeToken requires the per-instance CSRF token on every request.
1407+
// This blocks DNS-rebinding and cross-site driven reads of API endpoints that
1408+
// were previously unauthenticated on GET.
1409+
func requireServeToken(token string) func(http.Handler) http.Handler {
1410+
return func(next http.Handler) http.Handler {
1411+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1412+
if err := validateServeTokenHTTP(r, token); err != nil {
1413+
http.Error(w, "Forbidden", http.StatusForbidden)
1414+
return
1415+
}
1416+
next.ServeHTTP(w, r)
1417+
})
1418+
}
1419+
}
1420+
13581421
// sessionTokenFromRequest returns the session auth token from the
13591422
// X-Session-Token header or the session_token cookie, in that order.
13601423
func sessionTokenFromRequest(r *http.Request) string {

cmd/odek/serve_api_test.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -694,8 +694,14 @@ func TestServe_E2E_SessionMessagesStoredWithoutSystemInjections(t *testing.T) {
694694
}
695695

696696
// Fetch the stored session and verify no system messages are stored.
697+
// The production API auth middleware requires the per-instance serve token.
698+
testTokenMu.Lock()
699+
serveToken := testLastToken
700+
testTokenMu.Unlock()
701+
697702
req, _ := http.NewRequest(http.MethodGet, "http://"+ln.Addr().String()+"/api/sessions/"+sid, nil)
698703
req.Header.Set("X-Session-Token", authToken)
704+
req.Header.Set(wsTokenHeaderName, serveToken)
699705
resp, err := http.DefaultClient.Do(req)
700706
if err != nil {
701707
t.Fatalf("GET session: %v", err)
@@ -728,7 +734,14 @@ func TestServe_E2E_SessionMessagesStoredWithoutSystemInjections(t *testing.T) {
728734
func buildServeMuxWithSessionByID(t *testing.T, store *session.Store) (net.Listener, *http.ServeMux) {
729735
t.Helper()
730736
ln, mux := buildServeMux(t, store)
731-
mux.HandleFunc("/api/sessions/", handleSessionByID(store))
737+
// Mirror production authentication stack for session-by-ID endpoint.
738+
testTokenMu.Lock()
739+
token := testLastToken
740+
testTokenMu.Unlock()
741+
apiAuth := func(h http.Handler) http.Handler {
742+
return requireServeToken(token)(requireLocalHost(requireLocalOrigin(h)))
743+
}
744+
mux.Handle("/api/sessions/", apiAuth(handleSessionByID(store)))
732745
return ln, mux
733746
}
734747

cmd/odek/serve_test.go

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -915,9 +915,14 @@ func buildServeMux(t *testing.T, store *session.Store) (net.Listener, *http.Serv
915915
handleWS(store, resourceReg, resolved, systemMessage, conn)
916916
},
917917
})
918-
mux.HandleFunc("/api/resources", handleResourceSearch(resourceReg))
919-
mux.HandleFunc("/api/sessions", handleSessionList(store))
920-
mux.HandleFunc("/api/cancel", handleCancel(store))
918+
// Mirror the production API authentication stack so E2E tests exercise the
919+
// real security controls (token + loopback Host + local Origin).
920+
apiAuth := func(h http.Handler) http.Handler {
921+
return requireServeToken(wsToken)(requireLocalHost(requireLocalOrigin(h)))
922+
}
923+
mux.Handle("/api/resources", apiAuth(handleResourceSearch(resourceReg)))
924+
mux.Handle("/api/sessions", apiAuth(handleSessionList(store)))
925+
mux.Handle("/api/cancel", apiAuth(handleCancel(store)))
921926

922927
return ln, mux
923928
}
@@ -2056,7 +2061,9 @@ func readSessionID(t *testing.T, conn *golangws.Conn) string {
20562061
return sid
20572062
}
20582063

2059-
// postCancel makes an authenticated POST /api/cancel request.
2064+
// postCancel makes an authenticated POST /api/cancel request. It sends both
2065+
// the per-instance CSRF token (required by the API auth middleware) and the
2066+
// per-session auth token (required by handleCancel).
20602067
func postCancel(t *testing.T, url, sessionID, token string) *http.Response {
20612068
t.Helper()
20622069
req, err := http.NewRequest(http.MethodPost, url+"/api/cancel?session_id="+sessionID, nil)
@@ -2066,6 +2073,13 @@ func postCancel(t *testing.T, url, sessionID, token string) *http.Response {
20662073
if token != "" {
20672074
req.Header.Set("X-Session-Token", token)
20682075
}
2076+
// Tests that exercise the production mux must supply the serve token.
2077+
testTokenMu.Lock()
2078+
serveToken := testLastToken
2079+
testTokenMu.Unlock()
2080+
if serveToken != "" {
2081+
req.Header.Set(wsTokenHeaderName, serveToken)
2082+
}
20692083
resp, err := http.DefaultClient.Do(req)
20702084
if err != nil {
20712085
t.Fatalf("POST /api/cancel: %v", err)

cmd/odek/ui/app.js

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,17 @@ function getWsToken() {
208208
return meta ? meta.getAttribute('content') : '';
209209
}
210210

211+
// Build API request headers that include the per-instance CSRF token. The
212+
// server requires this token on every /api/* endpoint to block DNS-rebinding
213+
// and cross-site driven reads. Browser same-origin requests also send the
214+
// cookie, but the header defends-in-depth and works when cookies are blocked.
215+
function apiHeaders(extra) {
216+
const token = getWsToken();
217+
const headers = token ? { 'X-Odek-Ws-Token': token } : {};
218+
if (extra) Object.assign(headers, extra);
219+
return headers;
220+
}
221+
211222
function connect() {
212223
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
213224
const token = getWsToken();
@@ -954,7 +965,9 @@ window.executeDeleteSession = async function() {
954965
let token = getSessionToken(sid);
955966
if (!token) {
956967
try {
957-
const bootstrap = await fetch('/api/sessions/' + encodeURIComponent(sid));
968+
const bootstrap = await fetch('/api/sessions/' + encodeURIComponent(sid), {
969+
headers: apiHeaders()
970+
});
958971
if (bootstrap.ok) {
959972
const bs = await bootstrap.json();
960973
token = bootstrap.headers.get('X-Session-Token') || bs.auth_token;
@@ -965,7 +978,7 @@ window.executeDeleteSession = async function() {
965978

966979
fetch('/api/sessions/' + encodeURIComponent(sid), {
967980
method: 'DELETE',
968-
headers: token ? { 'X-Session-Token': token } : {}
981+
headers: apiHeaders(token ? { 'X-Session-Token': token } : {})
969982
})
970983
.then(() => {
971984
clearSessionToken(sid);
@@ -995,7 +1008,7 @@ async function fetchModels() {
9951008
const picker = document.getElementById('model-picker');
9961009
try {
9971010
picker.disabled = true;
998-
const resp = await fetch('/api/models');
1011+
const resp = await fetch('/api/models', { headers: apiHeaders() });
9991012
if (!resp.ok) { picker.innerHTML = '<option value="">Models unavailable</option>'; return; }
10001013
const models = await resp.json();
10011014
availableModels = models;
@@ -1098,7 +1111,9 @@ window.renameSession = async function(sid, el) {
10981111
let token = getSessionToken(sid);
10991112
if (!token) {
11001113
try {
1101-
const bootstrap = await fetch('/api/sessions/' + encodeURIComponent(sid));
1114+
const bootstrap = await fetch('/api/sessions/' + encodeURIComponent(sid), {
1115+
headers: apiHeaders()
1116+
});
11021117
if (bootstrap.ok) {
11031118
const bs = await bootstrap.json();
11041119
token = bootstrap.headers.get('X-Session-Token') || bs.auth_token;
@@ -1109,10 +1124,10 @@ window.renameSession = async function(sid, el) {
11091124

11101125
fetch('/api/sessions/' + encodeURIComponent(sid), {
11111126
method: 'POST',
1112-
headers: {
1127+
headers: apiHeaders({
11131128
'Content-Type': 'application/json',
11141129
...(token ? { 'X-Session-Token': token } : {})
1115-
},
1130+
}),
11161131
body: JSON.stringify({ name: newName })
11171132
})
11181133
.then(resp => {
@@ -1630,7 +1645,9 @@ async function checkCompletion() {
16301645
compQuery = query;
16311646

16321647
try {
1633-
const resp = await fetch('/api/resources?q=' + encodeURIComponent(query) + '&limit=8');
1648+
const resp = await fetch('/api/resources?q=' + encodeURIComponent(query) + '&limit=8', {
1649+
headers: apiHeaders()
1650+
});
16341651
const results = await resp.json();
16351652
if (!results || results.length === 0) {
16361653
completionEl.classList.remove('visible');
@@ -1714,8 +1731,9 @@ sessionListEl.addEventListener('click', (e) => {
17141731
async function loadAndRenderSession(sid) {
17151732
try {
17161733
let token = getSessionToken(sid);
1717-
const headers = token ? { 'X-Session-Token': token } : {};
1718-
const resp = await fetch('/api/sessions/' + encodeURIComponent(sid), { headers });
1734+
const resp = await fetch('/api/sessions/' + encodeURIComponent(sid), {
1735+
headers: apiHeaders(token ? { 'X-Session-Token': token } : {})
1736+
});
17191737
if (!resp.ok) { showToast('Failed to load session'); return; }
17201738
const sess = await resp.json();
17211739

@@ -1801,7 +1819,7 @@ sidebarSearch.addEventListener('input', () => {
18011819

18021820
async function loadSessions() {
18031821
try {
1804-
const resp = await fetch('/api/sessions');
1822+
const resp = await fetch('/api/sessions', { headers: apiHeaders() });
18051823
const sessions = await resp.json();
18061824
if (!sessions || !Array.isArray(sessions)) return;
18071825
allSessions = sessions;
@@ -1893,7 +1911,7 @@ window.cancelAgent = function() {
18931911
}
18941912
fetch('/api/cancel?session_id=' + encodeURIComponent(sessionId), {
18951913
method: 'POST',
1896-
headers: { 'X-Session-Token': getSessionToken(sessionId) || '' }
1914+
headers: apiHeaders({ 'X-Session-Token': getSessionToken(sessionId) || '' })
18971915
}).catch(function(){});
18981916
hideCancel();
18991917
addSystemMessage('⏹ Canceled');

docs/SECURITY.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -239,9 +239,11 @@ On Windows, where you cannot `unlink` an open file, a 0600 temp file is used and
239239

240240
- injected into the served `index.html` as `<meta name="odek-ws-token" content="...">`,
241241
- delivered as an `HttpOnly` `SameSite=Strict` cookie named `odek_ws_token`, and
242-
- required by the `/ws` handshake via the cookie, an `X-Odek-Ws-Token` header, or a WebSocket subprotocol of the form `odek.<token>`.
242+
- required by the `/ws` handshake and by every `/api/*` endpoint via the cookie, an `X-Odek-Ws-Token` header, or a WebSocket subprotocol of the form `odek.<token>`.
243243

244-
The origin allowlist (`localhost`, `127.0.0.1`, `[::1]`, and empty Origin for non-browser clients) remains as defense-in-depth, but the token is the primary protection against cross-port localhost CSRF: a malicious page served by another local port cannot obtain the token and therefore cannot open an agent-controlling WebSocket.
244+
The origin allowlist (`localhost`, `127.0.0.1`, `[::1]`, and empty Origin for non-browser clients) remains as defense-in-depth, but the token is the primary protection against cross-port localhost CSRF: a malicious page served by another local port cannot obtain the token and therefore cannot open an agent-controlling WebSocket or read from `/api/sessions`, `/api/resources`, `/api/models`, etc.
245+
246+
On top of the token, all `/api/*` handlers validate the `Host` header and reject any request whose host is not `localhost`, `127.0.0.1`, or `[::1]`. This closes DNS-rebinding attacks that point an external domain at the loopback interface and then drive the local API from a malicious web page.
245247

246248
### 9a. Web UI file attachments
247249

0 commit comments

Comments
 (0)