c2edc586ef
Phase 14.B. The cookie already satisfied everything the __Host- prefix requires — Secure, Path=/, no Domain — but as a promise the server makes, not one the browser enforces. With the prefix the browser refuses to store a same-named cookie carrying a Domain attribute, which is the second lever the same-site neighbour from 14.A has: set selfpost_session for the parent domain, and the browser sends two cookies of that name, oldest first. r.Cookie returned that first one, so the admin logged in successfully and landed back on the login form, for as long as the planted cookie lived. The name has to stay conditional: __Host- is only valid on a Secure cookie, so with PANEL_COOKIE_SECURE=false the browser would discard the Set-Cookie and the dev instance would fail to log in with no visible reason. Hence the test on that branch specifically, not just the production one. requireAuth now reads r.Cookies() and refuses a request carrying more than one cookie of the name, with a log line naming the cause. That is the only place the overwrite becomes visible at all, and unlike the prefix it also works in the dev shape. Sign-out clears both names, so the upgrade does not leave the old cookie behind; it does sign the administrator out once, which costs nothing given sessions live in memory and die on restart anyway. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
113 lines
4.3 KiB
Go
113 lines
4.3 KiB
Go
package web
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// The __Host- prefix is only valid on a Secure cookie: getting this condition
|
|
// backwards would make the development instance fail to log in at all, and
|
|
// silently — the browser discards the Set-Cookie and the panel just shows the
|
|
// login form again.
|
|
func TestSessionCookieNameFollowsCookieSecure(t *testing.T) {
|
|
secure := &Server{cfg: Config{CookieSecure: true}}
|
|
if got := secure.sessionCookie(); got != "__Host-selfpost_session" {
|
|
t.Errorf("with TLS the cookie is named %q, want the __Host- prefixed name", got)
|
|
}
|
|
plain := &Server{cfg: Config{CookieSecure: false}}
|
|
if got := plain.sessionCookie(); got != "selfpost_session" {
|
|
t.Errorf("without TLS the cookie is named %q, want the bare name", got)
|
|
}
|
|
}
|
|
|
|
// A neighbouring host on the same registrable domain can set a cookie by the
|
|
// same name; the browser then sends both, oldest first. Picking one at random
|
|
// would leave the administrator in a login loop with no explanation, so the
|
|
// request counts as signed out instead.
|
|
func TestSessionTokenRejectsDuplicates(t *testing.T) {
|
|
s := &Server{cfg: Config{CookieSecure: false}}
|
|
r := httptest.NewRequest(http.MethodGet, "http://panel.example.com/domains", nil)
|
|
r.AddCookie(&http.Cookie{Name: "selfpost_session", Value: "planted-by-a-neighbour"})
|
|
r.AddCookie(&http.Cookie{Name: "selfpost_session", Value: "the-real-session"})
|
|
|
|
if token, ok := s.sessionToken(r); ok {
|
|
t.Fatalf("duplicate cookies accepted, token = %q", token)
|
|
}
|
|
}
|
|
|
|
func TestSessionTokenReadsOneCookie(t *testing.T) {
|
|
s := &Server{cfg: Config{CookieSecure: true}}
|
|
r := httptest.NewRequest(http.MethodGet, "http://panel.example.com/domains", nil)
|
|
r.AddCookie(&http.Cookie{Name: "__Host-selfpost_session", Value: "the-real-session"})
|
|
|
|
token, ok := s.sessionToken(r)
|
|
if !ok || token != "the-real-session" {
|
|
t.Fatalf("sessionToken = %q, %t; want the cookie's value", token, ok)
|
|
}
|
|
}
|
|
|
|
// A cookie under the other deployment's name is not this deployment's session:
|
|
// after an upgrade the pre-14 cookie must not be honoured as if it were the
|
|
// prefixed one.
|
|
func TestSessionTokenIgnoresTheOtherName(t *testing.T) {
|
|
s := &Server{cfg: Config{CookieSecure: true}}
|
|
r := httptest.NewRequest(http.MethodGet, "http://panel.example.com/domains", nil)
|
|
r.AddCookie(&http.Cookie{Name: "selfpost_session", Value: "left-over-from-an-older-build"})
|
|
|
|
if _, ok := s.sessionToken(r); ok {
|
|
t.Fatal("the unprefixed cookie was accepted on a TLS deployment")
|
|
}
|
|
}
|
|
|
|
func TestRequireAuthRejectsDuplicateCookies(t *testing.T) {
|
|
s := &Server{cfg: Config{CookieSecure: false}, sessions: newSessionStore()}
|
|
token := s.sessions.Create("admin")
|
|
|
|
reached := false
|
|
h := s.requireAuth(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reached = true }))
|
|
|
|
r := httptest.NewRequest(http.MethodGet, "http://panel.example.com/domains", nil)
|
|
r.AddCookie(&http.Cookie{Name: "selfpost_session", Value: "planted-by-a-neighbour"})
|
|
r.AddCookie(&http.Cookie{Name: "selfpost_session", Value: token})
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, r)
|
|
|
|
if reached {
|
|
t.Fatal("the handler ran even though the session cookie was shadowed")
|
|
}
|
|
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/login" {
|
|
t.Fatalf("status = %d, Location = %q; want a redirect to /login", rec.Code, rec.Header().Get("Location"))
|
|
}
|
|
}
|
|
|
|
// Signing out has to expire the cookie under both names, or the cookie left
|
|
// over from a pre-__Host- build stays in the browser for the rest of its life.
|
|
func TestLogoutClearsBothCookieNames(t *testing.T) {
|
|
s := &Server{cfg: Config{CookieSecure: true}, sessions: newSessionStore()}
|
|
token := s.sessions.Create("admin")
|
|
|
|
r := httptest.NewRequest(http.MethodPost, "http://panel.example.com/logout", nil)
|
|
r.Host = "panel.example.com"
|
|
r.AddCookie(&http.Cookie{Name: "__Host-selfpost_session", Value: token})
|
|
rec := httptest.NewRecorder()
|
|
s.handleLogout(rec, r)
|
|
|
|
if _, ok := s.sessions.Lookup(token); ok {
|
|
t.Error("the session survived sign-out")
|
|
}
|
|
set := rec.Header().Values("Set-Cookie")
|
|
for _, name := range []string{"selfpost_session=", "__Host-selfpost_session="} {
|
|
var found bool
|
|
for _, c := range set {
|
|
if strings.HasPrefix(c, name) && strings.Contains(c, "Max-Age=0") {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("sign-out does not expire a cookie named %q: %v", strings.TrimSuffix(name, "="), set)
|
|
}
|
|
}
|
|
}
|