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