Test that logging out needs the form's CSRF token (part of #256)
check / check (push) Waiting to run

A GET /logout, or a POST /logout without the CSRF token, must end no
login session; the generator page must carry a logout form with the
token; and after a logout from that form, a login opens the generator
page while a cookie copied before it is refused, also after a restart.

Model: opus-5-5
This commit is contained in:
2026-10-15 13:55:25 +00:00
parent d1482bf1eb
commit fe3a32c63d
3 changed files with 151 additions and 32 deletions
@@ -185,7 +185,7 @@ func TestHandleLogout_ClearsSessionCookie(t *testing.T) {
}
}
// TestHandleLogout_EndsCopiedSession verifies that after GET /logout a copy
// TestHandleLogout_EndsCopiedSession verifies that after POST /logout a copy
// of the session cookie taken before it shows the login page, not the
// generator page.
func TestHandleLogout_EndsCopiedSession(t *testing.T) {
@@ -196,14 +196,14 @@ func TestHandleLogout_EndsCopiedSession(t *testing.T) {
copied := newSessionCookie(t, h)
req := httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "/logout", nil)
t.Context(), http.MethodPost, "/logout", nil)
req.AddCookie(copied)
rec := httptest.NewRecorder()
h.HandleLogout().ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("GET /logout status = %d, want %d",
t.Fatalf("POST /logout status = %d, want %d",
rec.Code, http.StatusSeeOther)
}
@@ -223,7 +223,7 @@ func TestHandleLogout_EndsCopiedSession(t *testing.T) {
}
// TestHandleLogout_SaveFails_Answers500 verifies that when the logout cannot
// be saved in the database, GET /logout answers 500, leaves the session
// be saved in the database, POST /logout answers 500, leaves the session
// cookie in place and ends no session.
func TestHandleLogout_SaveFails_Answers500(t *testing.T) {
t.Parallel()
@@ -248,17 +248,17 @@ func TestHandleLogout_SaveFails_Answers500(t *testing.T) {
}
req := httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "/logout", nil)
t.Context(), http.MethodPost, "/logout", nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
h.HandleLogout().ServeHTTP(rec, req)
t.Logf("GET /logout: status %d, Set-Cookie %q",
t.Logf("POST /logout: status %d, Set-Cookie %q",
rec.Code, rec.Header().Get("Set-Cookie"))
if rec.Code != http.StatusInternalServerError {
t.Errorf("GET /logout status = %d, want %d",
t.Errorf("POST /logout status = %d, want %d",
rec.Code, http.StatusInternalServerError)
}
+142 -22
View File
@@ -3,6 +3,8 @@ package server
import (
"net/http"
"net/http/httptest"
"net/url"
"regexp"
"strings"
"testing"
@@ -19,6 +21,11 @@ import (
"sneak.berlin/go/pixa/internal/session"
)
// logoutFormPattern extracts the CSRF token rendered into the generator
// page's logout form.
var logoutFormPattern = regexp.MustCompile(
`(?s)action="/logout".*?name="gorilla\.csrf\.Token" value="([^"]+)"`)
// startPixa starts the components pixad's fx app builds, from cfg, and
// returns the server with its routes, as newTestServer does.
func startPixa(t *testing.T, cfg *config.Config) *Server {
@@ -50,6 +57,24 @@ func startPixa(t *testing.T, cfg *config.Config) *Server {
return s
}
// logIn logs in to s with the signing key, as postLogin does, and returns
// the session cookie it sets.
func logIn(t *testing.T, s *Server) *http.Cookie {
t.Helper()
rec := postLogin(t, s, firstClient, "", testSigningKey)
for _, c := range rec.Result().Cookies() {
if c.Name == session.CookieName {
return c
}
}
t.Fatal("logging in set no session cookie")
return nil
}
// opensGeneratorPage reports whether GET / with cookie shows s's generator
// page, which only a valid login session opens.
func opensGeneratorPage(t *testing.T, s *Server, cookie *http.Cookie) bool {
@@ -65,37 +90,132 @@ func opensGeneratorPage(t *testing.T, s *Server, cookie *http.Cookie) bool {
strings.Contains(rec.Body.String(), `action="/generate"`)
}
// postLogout loads the generator page with GET / and sessionCookie and
// submits its logout form with POST /logout, as a browser does.
func postLogout(
t *testing.T, s *Server, sessionCookie *http.Cookie,
) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
req.AddCookie(sessionCookie)
page := httptest.NewRecorder()
s.ServeHTTP(page, req)
match := logoutFormPattern.FindStringSubmatch(page.Body.String())
if match == nil {
t.Fatal("the generator page has no logout form with a CSRF token")
}
form := url.Values{"gorilla.csrf.Token": {match[1]}}
req = httptest.NewRequestWithContext(t.Context(), http.MethodPost,
"/logout", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(sessionCookie)
for _, c := range page.Result().Cookies() {
req.AddCookie(c)
}
rec := httptest.NewRecorder()
s.ServeHTTP(rec, req)
return rec
}
// TestLogoutWithoutCSRFTokenEndsNoSession verifies that a request to /logout
// carrying the session cookie but not the logout form's CSRF token ends no
// session: GET /logout, as an image on another page of the same site makes a
// browser send, answers 405, and POST /logout without the token answers 403.
func TestLogoutWithoutCSRFTokenEndsNoSession(t *testing.T) {
t.Parallel()
tests := []struct {
method string
want int
}{
{http.MethodGet, http.StatusMethodNotAllowed},
{http.MethodPost, http.StatusForbidden},
}
for _, tc := range tests {
t.Run(tc.method, func(t *testing.T) {
t.Parallel()
s := newTestServer(t)
cookie := logIn(t, s)
req := httptest.NewRequestWithContext(
t.Context(), tc.method, "/logout", nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
s.ServeHTTP(rec, req)
t.Logf("%s /logout without the CSRF token: status %d",
tc.method, rec.Code)
if rec.Code != tc.want {
t.Errorf("%s /logout without the CSRF token status = %d, "+
"want %d", tc.method, rec.Code, tc.want)
}
if !opensGeneratorPage(t, s, cookie) {
t.Errorf("%s /logout without the CSRF token ended the "+
"login session", tc.method)
}
})
}
}
// TestLoginAfterLogoutOpensGeneratorPage verifies that after a logout from
// the generator page a copy of the session cookie taken before it is
// refused, and that a login after it opens the generator page.
func TestLoginAfterLogoutOpensGeneratorPage(t *testing.T) {
t.Parallel()
s := newTestServer(t)
copied := logIn(t, s)
if !opensGeneratorPage(t, s, copied) {
t.Fatal("logging in did not open the generator page")
}
logout := postLogout(t, s, copied)
if logout.Code != http.StatusSeeOther {
t.Fatalf("POST /logout status = %d, want %d",
logout.Code, http.StatusSeeOther)
}
if opensGeneratorPage(t, s, copied) {
t.Error("a cookie copied before the logout still opens the " +
"generator page")
}
opens := opensGeneratorPage(t, s, logIn(t, s))
t.Logf("a login after the logout opens the generator page: %v", opens)
if !opens {
t.Error("a login after the logout does not open the generator page")
}
}
// TestLogoutOutlastsRestart verifies that a copy of the session cookie taken
// before /logout is still refused by a pixa started again on the same state
// before a logout is still refused by a pixa started again on the same state
// directory, as pixad is after a restart.
func TestLogoutOutlastsRestart(t *testing.T) {
t.Parallel()
s := newTestServer(t)
login := postLogin(t, s, firstClient, "", testSigningKey)
var copied *http.Cookie
for _, c := range login.Result().Cookies() {
if c.Name == session.CookieName {
copied = c
}
copied := logIn(t, s)
if !opensGeneratorPage(t, s, copied) {
t.Fatal("logging in did not open the generator page")
}
if copied == nil || !opensGeneratorPage(t, s, copied) {
t.Fatal("logging in gave no session cookie that opens the generator page")
}
req := httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "/logout", nil)
req.AddCookie(copied)
logout := httptest.NewRecorder()
s.ServeHTTP(logout, req)
logout := postLogout(t, s, copied)
if logout.Code != http.StatusSeeOther {
t.Fatalf("GET /logout status = %d, want %d",
t.Fatalf("POST /logout status = %d, want %d",
logout.Code, http.StatusSeeOther)
}
@@ -106,7 +226,7 @@ func TestLogoutOutlastsRestart(t *testing.T) {
opens)
if opens {
t.Error("after a restart, a cookie copied before /logout " +
t.Error("after a restart, a cookie copied before the logout " +
"still opens the generator page")
}
}
+2 -3
View File
@@ -124,9 +124,8 @@ func TestLogout_EndsEverySessionMadeBeforeIt(t *testing.T) {
}
// TestLogout_WithoutSession_EndsNoSession verifies that a logout from a
// request without a session cookie ends no session. Another site can make a
// browser request /logout, but the browser leaves the SameSite=Strict
// session cookie out of that request.
// request without a session cookie ends no session, so someone who has not
// logged in cannot end the sessions of those who have.
func TestLogout_WithoutSession_EndsNoSession(t *testing.T) {
t.Parallel()