mavweb: gate the voice path on step-up like the text path
POST /api/ptt and /ws were listed as ungated on the grounds that mavend's voice port is only reachable inside the deploy. mavweb is the thing proxying into it from outside, so that argument does not hold. Audio posted to /api/ptt runs the same router, the same LLM and the same applyAction that POST /api/chat was gated on, which means speaking a light-switch act reached the act path while typing it did not. Both now take stepUpOK, so they fail open by default and deny under -require-stepup exactly like the other four. Registration moved down next to /api/chat because the gate needs stepUpSession. The route table records the reason and names the session-scoped assertion the hands-free case wants as a separate task. The SECURITY startup lines are one surface per line now. Found in review of #51.
This commit is contained in:
@@ -1172,3 +1172,82 @@ func TestHandleTools_GET_MCPUnavailable(t *testing.T) {
|
||||
t.Error("expected the empty-state copy")
|
||||
}
|
||||
}
|
||||
|
||||
// --- voice-path step-up gate (Vikunja #317) ---
|
||||
//
|
||||
// POST /api/ptt and GET /ws proxy audio into mavend's voice port, which runs
|
||||
// the same router, LLM and act path as POST /api/chat. They used to be
|
||||
// ungated on the grounds that the voice port is only reachable inside the
|
||||
// deploy, but mavweb is the thing proxying into it from outside. Speaking
|
||||
// "выключи свет" is not a smaller act than typing it.
|
||||
|
||||
// unreachableVoice is a closed port: a request that clears the gate fails at
|
||||
// the dial with 503, which is how these tests tell "passed" from "denied".
|
||||
const unreachableVoice = "127.0.0.1:1"
|
||||
|
||||
func pttReq() *http.Request {
|
||||
return httptest.NewRequest(http.MethodPost, "/api/ptt", strings.NewReader("PCM-ish bytes"))
|
||||
}
|
||||
|
||||
func TestHandlePTT_RequireStepUp_FailsClosed(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
handlePTT(rr, pttReq(), unreachableVoice, nil, true)
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403; body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePTT_UnassertedSession_Denied(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
handlePTT(rr, pttReq(), unreachableVoice, webauthn.NewPasskeySession(5*time.Minute), false)
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403; body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePTT_AssertedSession_PassesGate(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
handlePTT(rr, pttReq(), unreachableVoice, stepUpSession(), true)
|
||||
if rr.Code == http.StatusForbidden {
|
||||
t.Fatalf("status = 403 on an asserted session; body=%s", rr.Body.String())
|
||||
}
|
||||
if rr.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d, want 503 from the dial past the gate; body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Default deploy: WebAuthn unconfigured and -require-stepup off ⇒ push-to-talk
|
||||
// keeps working, resting on the transport-level auth in front of mavweb.
|
||||
func TestHandlePTT_FailOpenByDefault(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
handlePTT(rr, pttReq(), unreachableVoice, nil, false)
|
||||
if rr.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d, want 503 from the dial past the gate; body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleWS_RequireStepUp_FailsClosed(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
handleWS(rr, httptest.NewRequest(http.MethodGet, "/ws", nil), unreachableVoice, nil, true)
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403; body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleWS_UnassertedSession_Denied(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
handleWS(rr, httptest.NewRequest(http.MethodGet, "/ws", nil), unreachableVoice, webauthn.NewPasskeySession(5*time.Minute), false)
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403; body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Past the gate the handshake itself fails (httptest's recorder cannot be
|
||||
// hijacked), which is not a 403. That is all this asserts: the gate let it by.
|
||||
func TestHandleWS_AssertedSession_PassesGate(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
handleWS(rr, httptest.NewRequest(http.MethodGet, "/ws", nil), unreachableVoice, stepUpSession(), true)
|
||||
if rr.Code == http.StatusForbidden {
|
||||
t.Fatalf("status = 403 on an asserted session; body=%s", rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
+56
-13
@@ -351,7 +351,7 @@ func main() {
|
||||
coreSock := flag.String("core", "", "mavend IPC socket path for presence-signal ingest (empty = disabled)")
|
||||
pkOrigin := flag.String("webauthn-origin", "", "WebAuthn origin URL (e.g. https://maven.kvmx.ru)")
|
||||
pkRPID := flag.String("webauthn-rpid", "", "WebAuthn RP ID (e.g. maven.kvmx.ru)")
|
||||
requireStepUp := flag.Bool("require-stepup", false, "fail closed on step-up-gated actions (POST /tools, /routines, /api/revert, /api/chat) when WebAuthn step-up cannot be asserted; default false preserves the historical fail-open behaviour")
|
||||
requireStepUp := flag.Bool("require-stepup", false, "fail closed on step-up-gated actions (POST /tools, /routines, /models, /api/revert, /api/chat, /api/ptt and GET /ws) when WebAuthn step-up cannot be asserted; default false preserves the historical fail-open behaviour")
|
||||
pkFile := flag.String("passkey-file", "./passkeys.json", "path to WebAuthn credential store (JSON)")
|
||||
nexusURL := flag.String("nexus", "", "Nexus base URL for the /ecosystem panel (empty = not configured)")
|
||||
praxisURL := flag.String("praxis", "", "Praxis base URL for the /ecosystem panel (empty = not configured)")
|
||||
@@ -387,12 +387,9 @@ func main() {
|
||||
handleVoice(w, r)
|
||||
}))
|
||||
|
||||
mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleWS(w, r, *voiceAddr)
|
||||
})
|
||||
mux.HandleFunc("/api/ptt", func(w http.ResponseWriter, r *http.Request) {
|
||||
handlePTT(w, r, *voiceAddr)
|
||||
})
|
||||
// /ws and /api/ptt are registered further down, next to /api/chat: they
|
||||
// carry the same step-up gate and so need stepUpSession, which is only
|
||||
// built once the passkey endpoints are wired.
|
||||
mux.HandleFunc("/api/ping", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("pong"))
|
||||
})
|
||||
@@ -475,10 +472,29 @@ func main() {
|
||||
mux.HandleFunc("/auth/webauthn/assert/finish", pk.AssertFinish)
|
||||
}
|
||||
if stepUpSession == nil {
|
||||
// One surface per line: these are read in a terminal at the moment
|
||||
// someone is deciding whether the box is safe to expose.
|
||||
surfaces := []string{
|
||||
"POST /tools defines arbitrary argv via name+cmd, which internal/tool then EXECUTES",
|
||||
"POST /routines accepting schedules recurring firing",
|
||||
"POST /models chooses the resident model that routes and words every turn",
|
||||
"POST /api/revert voids the latest fact for a key",
|
||||
"POST /api/chat reaches the router, the LLM and, through applyAction, the act path",
|
||||
"POST /api/ptt the same, from audio",
|
||||
"GET /ws the same, streamed",
|
||||
}
|
||||
if *requireStepUp {
|
||||
log.Printf("SECURITY: step-up verification is DISABLED (-webauthn-origin/-webauthn-rpid unset) and -require-stepup is set: POST /tools (tool enable/disable/dismiss — defines and executes arbitrary argv), POST /routines (accepting schedules recurring firing), POST /api/revert and POST /api/chat (reaches the router, the LLM and the act path) will be DENIED (403). Set -webauthn-origin and -webauthn-rpid to enable passkey step-up.")
|
||||
log.Printf("SECURITY: step-up verification is DISABLED (-webauthn-origin/-webauthn-rpid unset) and -require-stepup is set. These surfaces will be DENIED (403):")
|
||||
} else {
|
||||
log.Printf("SECURITY WARNING: step-up verification is DISABLED because -webauthn-origin/-webauthn-rpid are unset. UNGUARDED SURFACES: POST /tools (defines arbitrary argv via name+cmd, which internal/tool then EXECUTES), POST /routines (accepting schedules recurring firing), POST /api/revert (voids the latest fact for a key) and POST /api/chat (reaches the router, the LLM and, through applyAction, the act path). These are protected only by whatever transport-level auth sits in front of mavweb (wg+nginx+auth) — do NOT expose -addr on a public interface. Set -webauthn-origin and -webauthn-rpid to require passkey step-up, or pass -require-stepup to fail closed instead.")
|
||||
log.Printf("SECURITY WARNING: step-up verification is DISABLED (-webauthn-origin/-webauthn-rpid unset). These surfaces are UNGUARDED:")
|
||||
}
|
||||
for _, s := range surfaces {
|
||||
log.Printf("SECURITY: %s", s)
|
||||
}
|
||||
if *requireStepUp {
|
||||
log.Printf("SECURITY: set -webauthn-origin and -webauthn-rpid to enable passkey step-up.")
|
||||
} else {
|
||||
log.Printf("SECURITY: they rest on the transport-level auth in front of mavweb (wg+nginx+auth). Do NOT expose -addr on a public interface. Set -webauthn-origin and -webauthn-rpid to require passkey step-up, or pass -require-stepup to fail closed instead.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -508,13 +524,26 @@ func main() {
|
||||
// POST /routines step-up — accepting schedules recurring firing
|
||||
// POST /api/revert step-up — voids the latest fact for a key
|
||||
// POST /api/chat step-up — reaches the router, LLM and the act path
|
||||
// POST /api/ptt step-up — audio into runTurn, so the same router,
|
||||
// LLM and act path as /api/chat
|
||||
// GET /ws step-up — same, streamed
|
||||
// POST /api/signal none — appends a presence fact, no argv, no act
|
||||
// POST /api/ptt, /ws none — proxy audio to mavend's voice port, which
|
||||
// is itself only reachable inside the deploy
|
||||
// POST /api/ambient shared secret — notification relay, constant-time
|
||||
// token compare, poster is a phone service
|
||||
// and not a browser, so step-up cannot apply
|
||||
//
|
||||
// "step-up" means stepUpOK: asserted passkey when WebAuthn is configured,
|
||||
// otherwise fail-open unless -require-stepup, which denies.
|
||||
//
|
||||
// /api/ptt and /ws used to be ungated, justified by mavend's voice port
|
||||
// being reachable only inside the deploy. That argument does not hold:
|
||||
// mavweb is the thing proxying into it from outside. Speaking "выключи
|
||||
// свет" is not a smaller act than typing it (Vikunja #317).
|
||||
//
|
||||
// The gate here is per-request, which costs the hands-free case a passkey
|
||||
// assertion per turn whenever WebAuthn is configured. A session-scoped
|
||||
// assertion covering a run of turns is the right shape and is its own task.
|
||||
//
|
||||
// GET /chat only renders the page and echoes back the q/r query params the
|
||||
// POST redirect set — nothing to gate.
|
||||
mux.HandleFunc("/chat", func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -526,6 +555,12 @@ func main() {
|
||||
mux.HandleFunc("/api/revert", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleRevert(w, r, core, stepUpSession, *requireStepUp)
|
||||
})
|
||||
mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleWS(w, r, *voiceAddr, stepUpSession, *requireStepUp)
|
||||
})
|
||||
mux.HandleFunc("/api/ptt", func(w http.ResponseWriter, r *http.Request) {
|
||||
handlePTT(w, r, *voiceAddr, stepUpSession, *requireStepUp)
|
||||
})
|
||||
|
||||
srv := &http.Server{Addr: *addr, Handler: mux}
|
||||
|
||||
@@ -543,7 +578,11 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
func handleWS(w http.ResponseWriter, r *http.Request, voiceAddr string) {
|
||||
func handleWS(w http.ResponseWriter, r *http.Request, voiceAddr string, session *webauthn.PasskeySession, requireStepUp bool) {
|
||||
if !stepUpOK(session, requireStepUp) {
|
||||
http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
||||
OriginPatterns: []string{"*"},
|
||||
})
|
||||
@@ -1449,11 +1488,15 @@ func readOneFrame(r io.Reader) (*voice.Response, *voice.Push, error) {
|
||||
return &voice.Response{ID: raw.ID, Result: raw.Result, Error: raw.Error}, nil, nil
|
||||
}
|
||||
|
||||
func handlePTT(w http.ResponseWriter, r *http.Request, voiceAddr string) {
|
||||
func handlePTT(w http.ResponseWriter, r *http.Request, voiceAddr string, session *webauthn.PasskeySession, requireStepUp bool) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "POST only", 405)
|
||||
return
|
||||
}
|
||||
if !stepUpOK(session, requireStepUp) {
|
||||
http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 400)
|
||||
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
/home/kami/apps/Maven/models/stt
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
/home/kami/apps/Maven/models/tts
|
||||
Reference in New Issue
Block a user