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) })