From a4b47337679552fd04a612699a359e7632aa490f Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 6 Aug 2026 19:48:29 +0400 Subject: [PATCH] the correction gesture is step-up gated after all (V-630) Trace ids are sequential integers and the label table is the one thing the routing heads will be fitted on, so an ungated POST let anyone past the transport gate mislabel turns the owner never touched. The cost argument for leaving it open does not hold: he tapped to send the turn he is correcting, so the session is already up when the buttons appear. --- cmd/mavweb/chat.go | 14 +++++++++----- cmd/mavweb/correct_test.go | 28 +++++++++++++++++++++------- cmd/mavweb/main.go | 8 +------- 3 files changed, 31 insertions(+), 19 deletions(-) diff --git a/cmd/mavweb/chat.go b/cmd/mavweb/chat.go index e78a9ff..6091f5a 100644 --- a/cmd/mavweb/chat.go +++ b/cmd/mavweb/chat.go @@ -131,11 +131,12 @@ func handleChatAPI(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, ses // optionally the intent it should have been. An unstated target is accepted, // because a turn marked wrong with no target is still a usable negative. // -// Not step-up gated, unlike POST /api/chat. Writing a label reaches no router, -// no model and no act path; it writes one row nothing executes from. Gating it -// would price the gesture out of being used, which is the one thing that makes -// it worthless. -func handleCorrectAPI(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { +// Step-up gated like POST /api/chat, and that costs the gesture nothing: he +// tapped to send the turn he is now correcting, so the session is already up. +// It is gated because trace ids are sequential integers and this writes the one +// table the routing heads (V-546) will be fitted on. A caller who can guess an +// id could otherwise mislabel turns he never corrected. +func handleCorrectAPI(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 @@ -143,6 +144,9 @@ func handleCorrectAPI(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) if !requireCore(w, core, "correct") { return } + if !stepUpGate(w, session, requireStepUp) { + return + } id, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("trace_id")), 10, 64) if err != nil || id <= 0 { http.Error(w, "trace_id required", http.StatusBadRequest) diff --git a/cmd/mavweb/correct_test.go b/cmd/mavweb/correct_test.go index 20ecd59..ef33f2d 100644 --- a/cmd/mavweb/correct_test.go +++ b/cmd/mavweb/correct_test.go @@ -38,7 +38,7 @@ func TestCorrectAPIWithTarget(t *testing.T) { rr := httptest.NewRecorder() handleCorrectAPI(rr, postCorrect(url.Values{ "trace_id": {"42"}, "should_be": {"fact"}, "q": {"поужинал"}, "rep": {"поняла"}, - }), core) + }), core, stepUpSession(), false) if rr.Code != http.StatusSeeOther { t.Fatalf("status %d, want 303; body=%s", rr.Code, rr.Body.String()) @@ -58,7 +58,7 @@ func TestCorrectAPIWithTarget(t *testing.T) { func TestCorrectAPIWithNoTarget(t *testing.T) { core := &correctCore{} rr := httptest.NewRecorder() - handleCorrectAPI(rr, postCorrect(url.Values{"trace_id": {"7"}}), core) + handleCorrectAPI(rr, postCorrect(url.Values{"trace_id": {"7"}}), core, stepUpSession(), false) if rr.Code != http.StatusSeeOther { t.Fatalf("status %d, want 303", rr.Code) @@ -76,7 +76,7 @@ func TestCorrectAPIWithNoTarget(t *testing.T) { func TestCorrectAPIRejectsUnknownTarget(t *testing.T) { core := &correctCore{} rr := httptest.NewRecorder() - handleCorrectAPI(rr, postCorrect(url.Values{"trace_id": {"7"}, "should_be": {"погода"}}), core) + handleCorrectAPI(rr, postCorrect(url.Values{"trace_id": {"7"}, "should_be": {"погода"}}), core, stepUpSession(), false) if rr.Code != http.StatusBadRequest { t.Fatalf("status %d, want 400", rr.Code) @@ -90,7 +90,7 @@ func TestCorrectAPINeedsTraceID(t *testing.T) { for _, form := range []url.Values{{}, {"trace_id": {"0"}}, {"trace_id": {"nope"}}} { core := &correctCore{} rr := httptest.NewRecorder() - handleCorrectAPI(rr, postCorrect(form), core) + handleCorrectAPI(rr, postCorrect(form), core, stepUpSession(), false) if rr.Code != http.StatusBadRequest { t.Errorf("form %v: status %d, want 400", form, rr.Code) } @@ -105,7 +105,7 @@ func TestCorrectAPINeedsTraceID(t *testing.T) { func TestCorrectAPIReportsFailure(t *testing.T) { core := &correctCore{err: errors.New("disk is full")} rr := httptest.NewRecorder() - handleCorrectAPI(rr, postCorrect(url.Values{"trace_id": {"9"}, "should_be": {"note"}}), core) + handleCorrectAPI(rr, postCorrect(url.Values{"trace_id": {"9"}, "should_be": {"note"}}), core, stepUpSession(), false) if rr.Code != http.StatusBadGateway { t.Fatalf("status %d, want 502", rr.Code) } @@ -115,7 +115,7 @@ func TestCorrectAPIReportsFailure(t *testing.T) { func TestCorrectAPIExpiredTurn(t *testing.T) { core := &correctCore{err: ipc.ErrNoSuchTrace} rr := httptest.NewRecorder() - handleCorrectAPI(rr, postCorrect(url.Values{"trace_id": {"9"}, "should_be": {"note"}}), core) + handleCorrectAPI(rr, postCorrect(url.Values{"trace_id": {"9"}, "should_be": {"note"}}), core, stepUpSession(), false) if rr.Code != http.StatusNotFound { t.Fatalf("status %d, want 404", rr.Code) } @@ -123,7 +123,7 @@ func TestCorrectAPIExpiredTurn(t *testing.T) { func TestCorrectAPIPostOnly(t *testing.T) { rr := httptest.NewRecorder() - handleCorrectAPI(rr, httptest.NewRequest(http.MethodGet, "/api/correct", nil), &correctCore{}) + handleCorrectAPI(rr, httptest.NewRequest(http.MethodGet, "/api/correct", nil), &correctCore{}, stepUpSession(), false) if rr.Code != http.StatusMethodNotAllowed { t.Fatalf("status %d, want 405", rr.Code) } @@ -144,3 +144,17 @@ func TestCorrectionTargetsAreTheSeven(t *testing.T) { t.Error("empty is not a target: it is the absence of one, handled separately") } } + +// Trace ids are sequential, so a caller who cannot assert step-up must not be +// able to label a turn the owner never corrected. +func TestCorrectAPINeedsStepUp(t *testing.T) { + core := &correctCore{} + rr := httptest.NewRecorder() + handleCorrectAPI(rr, postCorrect(url.Values{"trace_id": {"9"}, "should_be": {"note"}}), core, nil, true) + if rr.Code != http.StatusForbidden { + t.Fatalf("status %d, want 403", rr.Code) + } + if core.called { + t.Error("wrote a label with no step-up") + } +} diff --git a/cmd/mavweb/main.go b/cmd/mavweb/main.go index 15fa0d9..22578cd 100644 --- a/cmd/mavweb/main.go +++ b/cmd/mavweb/main.go @@ -210,13 +210,7 @@ func main() { mux.HandleFunc("/routines", gatedPage(handleRoutines)) mux.HandleFunc("/api/chat", gatedPage(handleChatAPI)) mux.HandleFunc("/api/revert", gatedPage(handleRevert)) - // POST /api/correct is deliberately NOT on the step-up list (V-630). It - // reaches no router, no model and no act path: it writes one label row that - // nothing executes from. A correction that costs a passkey tap is a - // correction the owner does not make, and then the table stays empty. - mux.HandleFunc("/api/correct", func(w http.ResponseWriter, r *http.Request) { - handleCorrectAPI(w, r, core) - }) + mux.HandleFunc("/api/correct", gatedPage(handleCorrectAPI)) mux.HandleFunc("/models", func(w http.ResponseWriter, r *http.Request) { handleModels(w, r, core, swapConn, stepUpSession, *requireStepUp) })