From 4793d77fa96ca7e23d4c7a6a1a0e1b0269d06c23 Mon Sep 17 00:00:00 2001 From: kami Date: Mon, 6 Jul 2026 12:04:11 +0400 Subject: [PATCH] mavweb: gate /api/revert behind passkey step-up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RevertFact voids the latest fact for a key — a store mutation — but /api/revert had no step-up gate, while POST /tools required L3. Close the inconsistency: thread the same *webauthn.PasskeySession into handleRevert and reject with 403 when a configured session isn't asserted. nil session (WebAuthn unconfigured) keeps prior behavior — transport-level auth only. Tests: un-asserted session → 403 and RevertFact not called; asserted → 200. The RevertFact mock now records its key so the gate assertion is meaningful. Co-Authored-By: Claude Opus 4.8 --- cmd/mavweb/handlers_test.go | 41 ++++++++++++++++++++++++++++++------- cmd/mavweb/main.go | 18 ++++++++++++---- 2 files changed, 48 insertions(+), 11 deletions(-) diff --git a/cmd/mavweb/handlers_test.go b/cmd/mavweb/handlers_test.go index 0fb447a..30458c9 100644 --- a/cmd/mavweb/handlers_test.go +++ b/cmd/mavweb/handlers_test.go @@ -137,7 +137,8 @@ func (f *fakeCore) RecentNotes(_ context.Context, _ int) ([]ipc.Note, error) { return f.notes, nil } -func (f *fakeCore) RevertFact(_ context.Context, _ string) (int64, error) { +func (f *fakeCore) RevertFact(_ context.Context, key string) (int64, error) { + f.revertKey = key if f.revertErr != nil { return 0, f.revertErr } @@ -756,7 +757,7 @@ func TestHandleRevert(t *testing.T) { t.Run("GET returns 405", func(t *testing.T) { rr := httptest.NewRecorder() - handleRevert(rr, httptest.NewRequest(http.MethodGet, "/api/revert", nil), &fakeCore{}) + handleRevert(rr, httptest.NewRequest(http.MethodGet, "/api/revert", nil), &fakeCore{}, nil) if rr.Code != http.StatusMethodNotAllowed { t.Errorf("status = %d, want 405", rr.Code) } @@ -764,7 +765,7 @@ func TestHandleRevert(t *testing.T) { t.Run("nil core returns 503", func(t *testing.T) { rr := httptest.NewRecorder() - handleRevert(rr, httptest.NewRequest(http.MethodPost, "/api/revert", nil), nil) + handleRevert(rr, httptest.NewRequest(http.MethodPost, "/api/revert", nil), nil, nil) if rr.Code != http.StatusServiceUnavailable { t.Errorf("status = %d, want 503", rr.Code) } @@ -775,7 +776,7 @@ func TestHandleRevert(t *testing.T) { rr := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/revert", strings.NewReader("key=")) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - handleRevert(rr, req, core) + handleRevert(rr, req, core, nil) if rr.Code != http.StatusBadRequest { t.Errorf("status = %d, want 400", rr.Code) } @@ -786,7 +787,7 @@ func TestHandleRevert(t *testing.T) { rr := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/revert", strings.NewReader("key=missing")) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - handleRevert(rr, req, core) + handleRevert(rr, req, core, nil) if rr.Code != http.StatusNotFound { t.Errorf("status = %d, want 404", rr.Code) } @@ -797,7 +798,7 @@ func TestHandleRevert(t *testing.T) { rr := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/revert", strings.NewReader("key=somekey")) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - handleRevert(rr, req, core) + handleRevert(rr, req, core, nil) if rr.Code != http.StatusBadGateway { t.Errorf("status = %d, want 502", rr.Code) } @@ -808,7 +809,7 @@ func TestHandleRevert(t *testing.T) { rr := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/revert", strings.NewReader("key=test-key")) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - handleRevert(rr, req, core) + handleRevert(rr, req, core, nil) if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rr.Code) } @@ -823,6 +824,32 @@ func TestHandleRevert(t *testing.T) { t.Errorf("body missing new_id:42: %s", body) } }) + + t.Run("configured but un-asserted session returns 403", func(t *testing.T) { + core := &fakeCore{revertNewID: 7} + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/revert", strings.NewReader("key=k")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + // non-nil, never asserted ⇒ IsStepUp() false ⇒ gate closes. + handleRevert(rr, req, core, webauthn.NewPasskeySession(5*time.Minute)) + if rr.Code != http.StatusForbidden { + t.Errorf("status = %d, want 403", rr.Code) + } + if core.revertKey != "" { + t.Error("RevertFact called despite closed step-up gate") + } + }) + + t.Run("asserted session passes the gate", func(t *testing.T) { + core := &fakeCore{revertNewID: 9} + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/revert", strings.NewReader("key=k")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + handleRevert(rr, req, core, stepUpSession()) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rr.Code) + } + }) } // --- handleTools ListTools error --- diff --git a/cmd/mavweb/main.go b/cmd/mavweb/main.go index 55c21fb..9d8725f 100644 --- a/cmd/mavweb/main.go +++ b/cmd/mavweb/main.go @@ -158,9 +158,6 @@ func main() { mux.HandleFunc("/notifications", func(w http.ResponseWriter, r *http.Request) { handleNotifications(w, r, core) }) - mux.HandleFunc("/api/revert", func(w http.ResponseWriter, r *http.Request) { - handleRevert(w, r, core) - }) // ----- passkey (WebAuthn) endpoints ----- // Wired when both -core and a configured origin are present. The origin // must match the browser's view of mavweb (e.g. https://maven.kvmx.ru). @@ -199,6 +196,13 @@ func main() { handleTools(w, r, core, stepUpSession) }) + // /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). + mux.HandleFunc("/api/revert", func(w http.ResponseWriter, r *http.Request) { + handleRevert(w, r, core, stepUpSession) + }) + srv := &http.Server{Addr: *addr, Handler: mux} go func() { @@ -485,7 +489,7 @@ func handleTrace(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { } } -func handleRevert(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { +func handleRevert(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession) { if r.Method != http.MethodPost { http.Error(w, "POST only", http.StatusMethodNotAllowed) return @@ -494,6 +498,12 @@ func handleRevert(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { http.Error(w, "revert disabled (no -core)", http.StatusServiceUnavailable) return } + // nil session ⇒ WebAuthn not configured; step-up gate not applicable + // (asserting would be impossible, not just unmet) — matches handleTools. + if session != nil && !session.IsStepUp() { + http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden) + return + } key := strings.TrimSpace(r.FormValue("key")) if key == "" { http.Error(w, "key required", http.StatusBadRequest)