fix: address final review findings for master/agent phase 1 (#6)

- inventory: resolve the image reference via ContainerInspect when a moved
  tag makes ContainerList report the image ID
- store: allow reusing a revoked agent's name (partial unique index) and
  mark the revoked agent's compose/container services removed
- web: publish a services event on revoke; no-store on the one-time token page
- webauth: reject empty OIDC state; accept the CSRF form token from the body only
- agent conn: bound the WebSocket handshake to 30s
- hub: log agent_replaced when a connection replaces an older one
This commit is contained in:
2026-10-12 15:16:29 +02:00
parent 4f092eaf79
commit 0e9e34db06
12 changed files with 211 additions and 35 deletions
+2 -2
View File
@@ -71,7 +71,7 @@ func (a *Auth) Callback(w http.ResponseWriter, r *http.Request) {
return
}
state, verifier, ok := strings.Cut(flow.Value, ".")
if !ok || subtle.ConstantTimeCompare([]byte(r.URL.Query().Get("state")), []byte(state)) != 1 {
if !ok || state == "" || subtle.ConstantTimeCompare([]byte(r.URL.Query().Get("state")), []byte(state)) != 1 {
http.Error(w, "invalid login state", http.StatusBadRequest)
return
}
@@ -150,7 +150,7 @@ func (a *Auth) Require(next http.Handler) http.Handler {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
token := r.Header.Get("X-CSRF-Token")
if token == "" {
token = r.FormValue("csrf_token")
token = r.PostFormValue("csrf_token") // body only: a token in the URL leaks via logs and Referer
}
if subtle.ConstantTimeCompare([]byte(token), []byte(sess.CSRFToken)) != 1 {
http.Error(w, "invalid CSRF token", http.StatusForbidden)
+27 -5
View File
@@ -84,7 +84,9 @@ func startLogin(t *testing.T, a *Auth) (*http.Cookie, string) {
func callback(a *Auth, flow *http.Cookie, state string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, "/auth/callback?code=c&state="+url.QueryEscape(state), nil)
req.AddCookie(flow)
if flow != nil {
req.AddCookie(flow)
}
rec := httptest.NewRecorder()
a.Callback(rec, req)
return rec
@@ -98,11 +100,25 @@ func TestLogin_SetsFlowCookieAndRedirects(t *testing.T) {
assert.NotEmpty(t, state)
}
func TestCallback_StateMismatchRejected(t *testing.T) {
a := newAuth(&fakeProvider{}, newFakeStore())
func TestCallback_InvalidStateRejected(t *testing.T) {
a := newAuth(&fakeProvider{claims: Claims{Subject: "u", Groups: []string{"updater-admins"}}}, newFakeStore())
flow, _ := startLogin(t, a)
rec := callback(a, flow, "forged")
assert.Equal(t, http.StatusBadRequest, rec.Code)
emptyStateFlow := &http.Cookie{Name: flowCookieName, Value: ".verifier"}
cases := []struct {
name string
flow *http.Cookie
state string
}{
{"state mismatch", flow, "forged"},
{"empty state in cookie and query", emptyStateFlow, ""},
{"missing flow cookie", nil, ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
rec := callback(a, c.flow, c.state)
assert.Equal(t, http.StatusBadRequest, rec.Code)
})
}
}
func TestCallback_UserOutsideGroupForbidden(t *testing.T) {
@@ -197,6 +213,12 @@ func TestRequire(t *testing.T) {
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "live"})
return r
}(), http.StatusNoContent, [2]string{}},
{"POST with CSRF only in query string", func() *http.Request {
r := httptest.NewRequest(http.MethodPost, "/agents?csrf_token=tok", strings.NewReader("name=x"))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "live"})
return r
}(), http.StatusForbidden, [2]string{}},
{"GET with session", protectedRequest(http.MethodGet, "/", "live"), http.StatusNoContent, [2]string{}},
}
for _, c := range cases {