mavweb: add in-process auth gate for POST /tools

Add a local PasskeySession that handleTools checks before processing
any POST action (enable/disable). If the session hasn't been asserted
within the 5-minute TTL, return 403 Forbidden.

Changes:
- webauthn/session.go: add IsStepUp() convenience method (nil-safe)
- webauthn.go: PasskeyHandle holds a *PasskeySession; AssertFinish
  calls session.Assert() after IPC step-up
- main.go: create stepUpSession, pass to handleTools and
  newPasskeyHandle; handleTools returns 403 if !session.IsStepUp()
- handlers_test.go: update TestEnableTool_NoInProcessAuthGate to
  expect 403; add TestEnableTool_WithAuthGate_RequiresStepUp for
  the happy path with asserted session; update all 10 call sites
This commit is contained in:
kami
2026-07-05 11:55:44 +04:00
parent c225ba37b2
commit 5afff001c3
5 changed files with 79 additions and 31 deletions
+3 -3
View File
@@ -60,11 +60,11 @@ Notes:
| # | Task | Commit | Status |
|---|------|--------|--------|
| 12 | **Recurring reminders** — cron expression in reminder table. New `cron` column, `next_fire_ts` computed from cron. "trash every tuesday" | — | pending |
| 12 | **Recurring reminders** — cron+next_fire_ts cols, RescheduleReminder, dispatcher logic | 1eca17f | done |
| 13 | **Capability model**`scope` column on tools table, migration, UI, tests | 6b80fd0 | done |
| 14 | **Notification batching / digest mode** — morning/evening rollup instead of per-event nudges. Configurable window, accumulated messages in one delivery | — | pending |
| 15 | **Rule trace / explanation engine**`why` query: "why did/didn't you nudge me?" Reads predicate eval log. New `/trace` page or CLI query | — | pending |
| 16 | **Backup/restore automation** — script: `cp` the encrypted DB + re-encrypt-verify + restore flow | — | pending |
| 16 | **Backup/restore automation** — scripts/maven-backup.sh with backup/restore/verify/list | 3f09cdb | done |
Notes:
- Recurring reminders: needs schema migration (#1). Add `cron TEXT` and `next_fire_ts INTEGER` to reminders table.
@@ -82,7 +82,7 @@ Notes:
| 19 | **Rule trace page** — new `/trace` route showing predicate eval results per rule per tick | — | pending |
| 20 | **Command history page** — new `/history` route showing recent facts/commands | f8ba396 | done |
| 21 | **PWA icons** — add proper icon array to `manifest.json` (generate or inline SVG) | — | pending |
| 22 | **Language unification**pick Russian or add `lang` URL param toggle | — | pending |
| 22 | **Language unification**bilingual cheatsheet with RU/EN toggle in nav + ?lang= param | c225ba3 | done |
Notes:
- Current `/tools` has no in-process auth — `handlers_test.go` explicitly pins this behavior with `TestEnableTool_NoInProcessAuthGate`.
+48 -24
View File
@@ -11,6 +11,7 @@ import (
"testing"
"time"
"github.com/kami/maven/internal/auth"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/webauthn"
)
@@ -138,7 +139,7 @@ func TestHandleTools_GET_RendersAndEscapes(t *testing.T) {
enabled: []ipc.Tool{{Name: "svc", Scope: "", Cmd: []string{"systemctl", "restart"}, Destructive: true}},
}
rr := httptest.NewRecorder()
handleTools(rr, httptest.NewRequest(http.MethodGet, "/tools", nil), core)
handleTools(rr, httptest.NewRequest(http.MethodGet, "/tools", nil), core, nil)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
@@ -158,7 +159,7 @@ func TestHandleTools_GET_RendersAndEscapes(t *testing.T) {
func TestHandleTools_NilCore_503(t *testing.T) {
rr := httptest.NewRecorder()
handleTools(rr, httptest.NewRequest(http.MethodGet, "/tools", nil), nil)
handleTools(rr, httptest.NewRequest(http.MethodGet, "/tools", nil), nil, nil)
if rr.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503", rr.Code)
}
@@ -173,6 +174,14 @@ func postForm(action string, vals url.Values) *http.Request {
return r
}
// stepUpSession returns a PasskeySession that has already been asserted,
// so POST /tools calls can pass the in-process auth gate.
func stepUpSession() *webauthn.PasskeySession {
s := webauthn.NewPasskeySession(5 * time.Minute)
s.Assert(context.Background(), auth.Scope{})
return s
}
func TestHandleTools_POST_Enable_HappyPath(t *testing.T) {
core := &fakeCore{}
rr := httptest.NewRecorder()
@@ -180,7 +189,7 @@ func TestHandleTools_POST_Enable_HappyPath(t *testing.T) {
"name": {"svc"},
"cmd": {"systemctl restart nginx"},
"destructive": {"on"},
}), core)
}), core, stepUpSession())
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String())
@@ -199,7 +208,7 @@ func TestHandleTools_POST_Enable_HappyPath(t *testing.T) {
func TestHandleTools_POST_Enable_MissingName_400(t *testing.T) {
core := &fakeCore{}
rr := httptest.NewRecorder()
handleTools(rr, postForm("enable", url.Values{"cmd": {"systemctl restart"}}), core)
handleTools(rr, postForm("enable", url.Values{"cmd": {"systemctl restart"}}), core, stepUpSession())
if rr.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rr.Code)
}
@@ -208,7 +217,7 @@ func TestHandleTools_POST_Enable_MissingName_400(t *testing.T) {
func TestHandleTools_POST_Enable_MissingCmd_400(t *testing.T) {
core := &fakeCore{}
rr := httptest.NewRecorder()
handleTools(rr, postForm("enable", url.Values{"name": {"svc"}}), core)
handleTools(rr, postForm("enable", url.Values{"name": {"svc"}}), core, stepUpSession())
if rr.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rr.Code)
}
@@ -219,7 +228,7 @@ func TestHandleTools_POST_Enable_CoreError_502(t *testing.T) {
rr := httptest.NewRecorder()
handleTools(rr, postForm("enable", url.Values{
"name": {"svc"}, "cmd": {"systemctl restart"},
}), core)
}), core, stepUpSession())
if rr.Code != http.StatusBadGateway {
t.Fatalf("status = %d, want 502", rr.Code)
}
@@ -228,7 +237,7 @@ func TestHandleTools_POST_Enable_CoreError_502(t *testing.T) {
func TestHandleTools_POST_UnknownAction_400(t *testing.T) {
core := &fakeCore{}
rr := httptest.NewRecorder()
handleTools(rr, postForm("frobnicate", url.Values{"name": {"svc"}}), core)
handleTools(rr, postForm("frobnicate", url.Values{"name": {"svc"}}), core, stepUpSession())
if rr.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rr.Code)
}
@@ -239,7 +248,7 @@ func TestHandleTools_POST_UnknownAction_400(t *testing.T) {
func TestHandleTools_POST_Disable_HappyPath(t *testing.T) {
core := &fakeCore{}
rr := httptest.NewRecorder()
handleTools(rr, postForm("disable", url.Values{"name": {"svc"}}), core)
handleTools(rr, postForm("disable", url.Values{"name": {"svc"}}), core, stepUpSession())
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String())
}
@@ -251,7 +260,7 @@ func TestHandleTools_POST_Disable_HappyPath(t *testing.T) {
func TestHandleTools_POST_Disable_MissingName_400(t *testing.T) {
core := &fakeCore{}
rr := httptest.NewRecorder()
handleTools(rr, postForm("disable", url.Values{}), core)
handleTools(rr, postForm("disable", url.Values{}), core, stepUpSession())
if rr.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rr.Code)
}
@@ -260,29 +269,44 @@ func TestHandleTools_POST_Disable_MissingName_400(t *testing.T) {
func TestHandleTools_POST_Disable_CoreError_502(t *testing.T) {
core := &fakeCore{disableErr: ipc.ErrToolNotFound}
rr := httptest.NewRecorder()
handleTools(rr, postForm("disable", url.Values{"name": {"svc"}}), core)
handleTools(rr, postForm("disable", url.Values{"name": {"svc"}}), core, stepUpSession())
if rr.Code != http.StatusBadGateway {
t.Fatalf("status = %d, want 502", rr.Code)
}
}
// TestEnableTool_NoInProcessAuthGate documents CURRENT behavior: handleTools
// performs the boundary-moving EnableTool with no in-process authentication —
// there is no passkey/session check in the Go handler. A POST enable with no
// prior WebAuthn AssertFinish succeeds (reaches core.EnableTool). Authorization
// is delegated entirely to the nginx+wg layer in front of mavweb. Whether that
// is the intended sole gate is a maintainer decision; this test only pins the
// observed behavior so a future auth gate change is a deliberate, visible edit.
// TestEnableTool_NoInProcessAuthGate verifies that a POST /tools without a
// prior WebAuthn assertion is rejected with 403 Forbidden — the in-process
// auth gate requires step-up before any mutation (enable/disable).
func TestEnableTool_NoInProcessAuthGate(t *testing.T) {
core := &fakeCore{}
rr := httptest.NewRecorder()
// No passkey session, no cookie, no header — just the raw POST.
// No passkey session (nil) — no step-up, gate rejects.
handleTools(rr, postForm("enable", url.Values{
"name": {"svc"}, "cmd": {"systemctl restart"},
}), core)
if rr.Code != http.StatusOK || core.gotEnableName != "svc" {
t.Fatalf("expected unauthenticated enable to reach core (status=%d, name=%q); "+
"if this now fails, an in-process auth gate was added", rr.Code, core.gotEnableName)
}), core, nil)
if rr.Code != http.StatusForbidden {
t.Fatalf("status = %d, want %d (expected auth gate to reject)", rr.Code, http.StatusForbidden)
}
if core.gotEnableName != "" {
t.Errorf("core.EnableTool was called with name=%q, but auth gate should have blocked it", core.gotEnableName)
}
}
// TestEnableTool_WithAuthGate_RequiresStepUp verifies that a POST /tools with
// an asserted passkey session proceeds past the auth gate to core.EnableTool.
func TestEnableTool_WithAuthGate_RequiresStepUp(t *testing.T) {
core := &fakeCore{}
rr := httptest.NewRecorder()
sess := stepUpSession()
handleTools(rr, postForm("enable", url.Values{
"name": {"svc"}, "cmd": {"systemctl restart"},
}), core, sess)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String())
}
if core.gotEnableName != "svc" {
t.Errorf("name = %q, want svc", core.gotEnableName)
}
}
@@ -299,7 +323,7 @@ func newTestPasskey(t *testing.T) *PasskeyHandle {
Origin: "https://maven.example",
RPID: "maven.example",
RPName: "maven",
}, nil, f.Name())
}, nil, f.Name(), nil)
if err != nil {
t.Fatal(err)
}
@@ -638,7 +662,7 @@ func TestHandleTools_ListToolsError_502(t *testing.T) {
rr := httptest.NewRecorder()
handleTools(rr, postForm("enable", url.Values{
"name": {"svc"}, "cmd": {"systemctl restart"},
}), core)
}), core, stepUpSession())
if rr.Code != http.StatusBadGateway {
t.Fatalf("status = %d, want 502; body=%s", rr.Code, rr.Body.String())
}
+9 -3
View File
@@ -142,12 +142,14 @@ func main() {
// Passkey registration + assertion are the step-up mechanism for
// AuthStepUp actions (tool enable). Without -webauthn-origin, these
// endpoints return 503 and step-up is unavailable (FloorSession).
stepUpSession := webauthn.NewPasskeySession(5 * time.Minute)
if *pkOrigin != "" && *pkRPID != "" && core != nil {
pk, err := newPasskeyHandle(webauthn.Config{
Origin: *pkOrigin,
RPID: *pkRPID,
RPName: "maven",
}, core, *pkFile)
}, core, *pkFile, stepUpSession)
if err != nil {
log.Fatalf("passkey store: %v", err)
}
@@ -163,7 +165,7 @@ func main() {
// Enabling is the boundary-moving act (maven.md), so it lives ONLY here,
// behind wg+nginx+auth — never the voice/chat path.
mux.HandleFunc("/tools", func(w http.ResponseWriter, r *http.Request) {
handleTools(w, r, core)
handleTools(w, r, core, stepUpSession)
})
srv := &http.Server{Addr: *addr, Handler: mux}
@@ -425,7 +427,7 @@ func handleRevert(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
// whitespace-split — argv with embedded spaces isn't supported (ponytail: no
// shell-word parsing; the box owner controls this input, quote a wrapper script
// if an arg needs spaces).
func handleTools(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
func handleTools(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession) {
if core == nil {
http.Error(w, "tools disabled (no -core)", http.StatusServiceUnavailable)
return
@@ -433,6 +435,10 @@ func handleTools(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
ctx := r.Context()
var msg string
if r.Method == http.MethodPost {
if !session.IsStepUp() {
http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden)
return
}
action := r.FormValue("action")
name := strings.TrimSpace(r.FormValue("name"))
switch action {
+9 -1
View File
@@ -8,6 +8,7 @@ import (
"net/http"
"time"
"github.com/kami/maven/internal/auth"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/webauthn"
)
@@ -29,6 +30,7 @@ type PasskeyHandle struct {
rp *webauthn.RP
assertFn assertIPC // *ipc.Client when connected; nil ⇒ no step-up IPC
store *credentialStore
session *webauthn.PasskeySession
}
type localCred struct {
@@ -36,7 +38,7 @@ type localCred struct {
SignCount int64
}
func newPasskeyHandle(cfg webauthn.Config, core ipc.CoreAPI, storePath string) (*PasskeyHandle, error) {
func newPasskeyHandle(cfg webauthn.Config, core ipc.CoreAPI, storePath string, session *webauthn.PasskeySession) (*PasskeyHandle, error) {
var af assertIPC
if c, ok := core.(assertIPC); ok {
af = c
@@ -49,6 +51,7 @@ func newPasskeyHandle(cfg webauthn.Config, core ipc.CoreAPI, storePath string) (
rp: webauthn.NewRP(cfg),
assertFn: af,
store: store,
session: session,
}, nil
}
@@ -190,6 +193,11 @@ func (h *PasskeyHandle) AssertFinish(w http.ResponseWriter, r *http.Request) {
}
}
// Assert the in-process session so the POST /tools handler sees step-up.
if h.session != nil {
h.session.Assert(r.Context(), auth.Scope{})
}
log.Printf("webauthn: asserted credential %s", credID)
json.NewEncoder(w).Encode(map[string]string{"credential_id": credID})
}
+10
View File
@@ -47,6 +47,16 @@ func (s *PasskeySession) CurrentLayer(_ context.Context, _ auth.Scope) auth.Laye
return auth.Layer2
}
// IsStepUp returns true if the session was recently asserted (within TTL).
func (s *PasskeySession) IsStepUp() bool {
if s == nil {
return false
}
s.mu.Lock()
defer s.mu.Unlock()
return !s.assertedAt.IsZero() && time.Since(s.assertedAt) < s.assertionTTL
}
// Assert records a successful step-up gesture. The session bumps to L3 for
// the assertion TTL. A nil receiver returns ErrStepUpUnsupported.
func (s *PasskeySession) Assert(_ context.Context, _ auth.Scope) error {