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:
+48
-24
@@ -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())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user