diff --git a/cmd/mavweb/handlers_test.go b/cmd/mavweb/handlers_test.go index 1b92a25..6eef3fd 100644 --- a/cmd/mavweb/handlers_test.go +++ b/cmd/mavweb/handlers_test.go @@ -63,6 +63,18 @@ type fakeCore struct { // for handleTrace tests tickTrace ipc.TickTrace traceErr error + + // for handleChatAPI tests + chatText string + chatErr error +} + +func (f *fakeCore) Chat(_ context.Context, text string) (string, error) { + f.chatText = text + if f.chatErr != nil { + return "", f.chatErr + } + return "поняла", nil } func (f *fakeCore) EnableTool(_ context.Context, name string, cmd []string, destructive bool, scope string, _ time.Time) error { @@ -1045,3 +1057,64 @@ func TestHandleRoutines_NilCore_503(t *testing.T) { t.Fatalf("status = %d, want 503", rr.Code) } } + +// --- handleChatAPI step-up gate (Vikunja #317) --- +// +// POST /api/chat reaches the router, the LLM and the act path, so it carries +// the same gate as POST /tools and POST /api/revert. + +func postChat(text string) *http.Request { + req := httptest.NewRequest(http.MethodPost, "/api/chat", strings.NewReader("text="+url.QueryEscape(text))) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + return req +} + +func TestHandleChatAPI_RequireStepUp_FailsClosed(t *testing.T) { + core := &fakeCore{} + rr := httptest.NewRecorder() + handleChatAPI(rr, postChat("выключи свет"), core, nil, true) + if rr.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403; body=%s", rr.Code, rr.Body.String()) + } + if core.chatText != "" { + t.Errorf("core.Chat called with %q, but -require-stepup should deny", core.chatText) + } +} + +func TestHandleChatAPI_UnassertedSession_Denied(t *testing.T) { + core := &fakeCore{} + rr := httptest.NewRecorder() + handleChatAPI(rr, postChat("выключи свет"), core, webauthn.NewPasskeySession(5*time.Minute), false) + if rr.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403", rr.Code) + } + if core.chatText != "" { + t.Errorf("core.Chat called with %q despite an unasserted session", core.chatText) + } +} + +func TestHandleChatAPI_AssertedSession_PassesGate(t *testing.T) { + core := &fakeCore{} + rr := httptest.NewRecorder() + handleChatAPI(rr, postChat("привет"), core, stepUpSession(), true) + if rr.Code != http.StatusSeeOther { + t.Fatalf("status = %d, want 303; body=%s", rr.Code, rr.Body.String()) + } + if core.chatText != "привет" { + t.Errorf("core.Chat text = %q, want %q", core.chatText, "привет") + } +} + +// Default deploy: WebAuthn unconfigured and -require-stepup off ⇒ chat keeps +// working, resting on the transport-level auth in front of mavweb. +func TestHandleChatAPI_FailOpenByDefault(t *testing.T) { + core := &fakeCore{} + rr := httptest.NewRecorder() + handleChatAPI(rr, postChat("привет"), core, nil, false) + if rr.Code != http.StatusSeeOther { + t.Fatalf("status = %d, want 303", rr.Code) + } + if core.chatText != "привет" { + t.Errorf("core.Chat text = %q, want %q", core.chatText, "привет") + } +} diff --git a/cmd/mavweb/main.go b/cmd/mavweb/main.go index cf6109d..a05fba5 100644 --- a/cmd/mavweb/main.go +++ b/cmd/mavweb/main.go @@ -329,7 +329,7 @@ func main() { coreSock := flag.String("core", "", "mavend IPC socket path for presence-signal ingest (empty = disabled)") pkOrigin := flag.String("webauthn-origin", "", "WebAuthn origin URL (e.g. https://maven.kvmx.ru)") pkRPID := flag.String("webauthn-rpid", "", "WebAuthn RP ID (e.g. maven.kvmx.ru)") - requireStepUp := flag.Bool("require-stepup", false, "fail closed on step-up-gated actions (/tools POST, /api/revert) when WebAuthn step-up cannot be asserted; default false preserves the historical fail-open behaviour") + requireStepUp := flag.Bool("require-stepup", false, "fail closed on step-up-gated actions (POST /tools, /routines, /api/revert, /api/chat) when WebAuthn step-up cannot be asserted; default false preserves the historical fail-open behaviour") pkFile := flag.String("passkey-file", "./passkeys.json", "path to WebAuthn credential store (JSON)") nexusURL := flag.String("nexus", "", "Nexus base URL for the /ecosystem panel (empty = not configured)") praxisURL := flag.String("praxis", "", "Praxis base URL for the /ecosystem panel (empty = not configured)") @@ -434,9 +434,9 @@ func main() { } if stepUpSession == nil { if *requireStepUp { - log.Printf("SECURITY: step-up verification is DISABLED (-webauthn-origin/-webauthn-rpid unset) and -require-stepup is set: POST /tools (tool enable/disable/dismiss — defines and executes arbitrary argv) and POST /api/revert will be DENIED (403). Set -webauthn-origin and -webauthn-rpid to enable passkey step-up.") + log.Printf("SECURITY: step-up verification is DISABLED (-webauthn-origin/-webauthn-rpid unset) and -require-stepup is set: POST /tools (tool enable/disable/dismiss — defines and executes arbitrary argv), POST /routines (accepting schedules recurring firing), POST /api/revert and POST /api/chat (reaches the router, the LLM and the act path) will be DENIED (403). Set -webauthn-origin and -webauthn-rpid to enable passkey step-up.") } else { - log.Printf("SECURITY WARNING: step-up verification is DISABLED because -webauthn-origin/-webauthn-rpid are unset. UNGUARDED SURFACES: POST /tools (defines arbitrary argv via name+cmd, which internal/tool then EXECUTES) and POST /api/revert (voids the latest fact for a key). These are protected only by whatever transport-level auth sits in front of mavweb (wg+nginx+auth) — do NOT expose -addr on a public interface. Set -webauthn-origin and -webauthn-rpid to require passkey step-up, or pass -require-stepup to fail closed instead.") + log.Printf("SECURITY WARNING: step-up verification is DISABLED because -webauthn-origin/-webauthn-rpid are unset. UNGUARDED SURFACES: POST /tools (defines arbitrary argv via name+cmd, which internal/tool then EXECUTES), POST /routines (accepting schedules recurring firing), POST /api/revert (voids the latest fact for a key) and POST /api/chat (reaches the router, the LLM and, through applyAction, the act path). These are protected only by whatever transport-level auth sits in front of mavweb (wg+nginx+auth) — do NOT expose -addr on a public interface. Set -webauthn-origin and -webauthn-rpid to require passkey step-up, or pass -require-stepup to fail closed instead.") } } @@ -454,14 +454,26 @@ func main() { handleRoutines(w, r, core, stepUpSession, *requireStepUp) }) - // /api/revert voids the latest fact for a key — a store mutation, so it - // sits behind the same passkey step-up as tool enable (nil session ⇒ - // WebAuthn unconfigured ⇒ transport-level auth only, same as /tools). + // State-changing routes on this server, and their gate (Vikunja #317): + // + // POST /tools step-up — defines argv that internal/tool executes + // POST /routines step-up — accepting schedules recurring firing + // POST /api/revert step-up — voids the latest fact for a key + // POST /api/chat step-up — reaches the router, LLM and the act path + // POST /api/signal none — appends a presence fact, no argv, no act + // POST /api/ptt, /ws none — proxy audio to mavend's voice port, which + // is itself only reachable inside the deploy + // + // "step-up" means stepUpOK: asserted passkey when WebAuthn is configured, + // otherwise fail-open unless -require-stepup, which denies. + // + // GET /chat only renders the page and echoes back the q/r query params the + // POST redirect set — nothing to gate. mux.HandleFunc("/chat", func(w http.ResponseWriter, r *http.Request) { handleChatPage(w, r, core) }) mux.HandleFunc("/api/chat", func(w http.ResponseWriter, r *http.Request) { - handleChatAPI(w, r, core) + handleChatAPI(w, r, core, stepUpSession, *requireStepUp) }) mux.HandleFunc("/api/revert", func(w http.ResponseWriter, r *http.Request) { handleRevert(w, r, core, stepUpSession, *requireStepUp) @@ -1258,7 +1270,14 @@ func handleChatPage(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { } // handleChatAPI processes a chat message POST and redirects back to /chat. -func handleChatAPI(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { +// +// State-changing, and the widest surface on this server: the text reaches the +// router, the LLM, and through mavend's applyAction the whole action path +// including `act` — so it is gated on the same step-up as POST /tools and +// POST /api/revert (Vikunja #317). With WebAuthn unconfigured the gate is +// fail-open exactly like the others (see stepUpOK); with -require-stepup it +// denies, which is the point of that flag. +func handleChatAPI(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool) { if r.Method != http.MethodPost { http.Error(w, "POST only", http.StatusMethodNotAllowed) return @@ -1267,6 +1286,10 @@ func handleChatAPI(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { http.Error(w, "chat disabled (no -core)", http.StatusServiceUnavailable) return } + if !stepUpOK(session, requireStepUp) { + http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden) + return + } text := strings.TrimSpace(r.FormValue("text")) if text == "" { http.Redirect(w, r, "/chat", http.StatusSeeOther)