mavweb: gate /api/revert behind passkey step-up

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 <noreply@anthropic.com>
This commit is contained in:
kami
2026-07-06 12:04:11 +04:00
parent af0a0d07cd
commit 4793d77fa9
2 changed files with 48 additions and 11 deletions
+34 -7
View File
@@ -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 ---
+14 -4
View File
@@ -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)