e221476ff5
A sibling subdomain can no longer toss a session cookie the hub reads first. Operators are logged out once; plain-HTTP browser login no longer holds a session; Basic auth for scripts is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
76 lines
3.0 KiB
Go
76 lines
3.0 KiB
Go
package web
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// R-136: the operator session cookie is `__Host-hub_session` — Secure, Path=/, no Domain — even when the
|
|
// login itself arrived over plain HTTP (the browser would reject a non-Secure __Host- cookie; a sibling
|
|
// subdomain can never set one). The old `hub_session` name no longer opens a session (the one-time
|
|
// logout), and plain-HTTP Basic auth for scripts keeps working.
|
|
// RED-PROOF: set SessionCookieName back to "hub_session" → the name/Secure assertions fail.
|
|
func TestR136_LoginSetsHostPrefixedCookie(t *testing.T) {
|
|
s, _ := serverWithPassword(t, "op-pass")
|
|
h := s.RequireAuth(http.HandlerFunc(s.ServeHTTP))
|
|
|
|
r := httptest.NewRequest(http.MethodPost, "http://hub.local/login", strings.NewReader(url.Values{"password": {"op-pass"}}.Encode()))
|
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, r)
|
|
if w.Code != http.StatusSeeOther {
|
|
t.Fatalf("login = %d", w.Code)
|
|
}
|
|
var sess *http.Cookie
|
|
for _, c := range w.Result().Cookies() {
|
|
if c.Name == SessionCookieName {
|
|
sess = c
|
|
}
|
|
}
|
|
if SessionCookieName != "__Host-hub_session" || sess == nil {
|
|
t.Fatalf("login set no %q cookie (const=%q, got %v)", "__Host-hub_session", SessionCookieName, w.Result().Cookies())
|
|
}
|
|
raw := w.Header().Get("Set-Cookie")
|
|
if !sess.Secure || sess.Path != "/" || sess.Domain != "" || strings.Contains(strings.ToLower(raw), "domain=") || !sess.HttpOnly {
|
|
t.Fatalf("__Host- preconditions broken (plain-HTTP login): %q", raw)
|
|
}
|
|
|
|
// The new cookie opens the session.
|
|
r = httptest.NewRequest(http.MethodGet, "/", nil)
|
|
r.AddCookie(&http.Cookie{Name: SessionCookieName, Value: sess.Value})
|
|
w = httptest.NewRecorder()
|
|
h.ServeHTTP(w, r)
|
|
if w.Code == http.StatusFound && w.Header().Get("Location") == "/login" {
|
|
t.Fatal("the __Host- session cookie did not authenticate")
|
|
}
|
|
|
|
// The same token under the OLD name (a tossed or stale cookie) does not.
|
|
r = httptest.NewRequest(http.MethodGet, "/", nil)
|
|
r.AddCookie(&http.Cookie{Name: "hub_session", Value: sess.Value})
|
|
w = httptest.NewRecorder()
|
|
h.ServeHTTP(w, r)
|
|
if w.Code != http.StatusFound || w.Header().Get("Location") != "/login" {
|
|
t.Fatalf("old hub_session cookie = %d %q, want a redirect to /login", w.Code, w.Header().Get("Location"))
|
|
}
|
|
|
|
// Plain-HTTP Basic auth (scripts, no cookie) still reads pages and, with the CLI header, writes.
|
|
r = httptest.NewRequest(http.MethodGet, "http://hub.local/", nil)
|
|
r.SetBasicAuth("", "op-pass")
|
|
w = httptest.NewRecorder()
|
|
h.ServeHTTP(w, r)
|
|
if w.Code == http.StatusFound || w.Code == http.StatusUnauthorized {
|
|
t.Fatalf("plain-HTTP Basic auth GET = %d, want through", w.Code)
|
|
}
|
|
if !s.validateCSRF(func() *http.Request {
|
|
r := httptest.NewRequest(http.MethodPost, "http://hub.local/configuration", nil)
|
|
r.SetBasicAuth("", "op-pass")
|
|
r.Header.Set(OperatorCLIHeader, "cli")
|
|
return r
|
|
}()) {
|
|
t.Fatal("plain-HTTP Basic auth + CLI header no longer passes the write gate")
|
|
}
|
|
}
|