Files
mailgoserver/internal/webui/csrf_test.go
T

156 lines
6.0 KiB
Go

package webui
import (
"io"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
"regexp"
"strings"
"testing"
)
var csrfTokenInPage = regexp.MustCompile(`window\.__csrfToken\s*=\s*"([0-9a-f]+)"`)
// TestCSRFProtectionAppliesAcrossAdminAndWebmail is a live-HTTP test (real
// httptest.NewServer wrapped exactly like main.go composes it —
// SecurityHeaders(app.CSRFProtect(mux)) — not just httptest.NewRecorder against the
// bare mux) confirming: a forged/missing CSRF token on a state-changing POST is
// rejected for BOTH an admin route and a webmail route, a real page-driven
// submission (token scraped from the actual rendered page, exactly as the injected
// csrf_script.html partial would hand it to a real form) succeeds, and every
// response carries the new security headers.
func TestCSRFProtectionAppliesAcrossAdminAndWebmail(t *testing.T) {
app := newTestApp(t)
srv := httptest.NewServer(SecurityHeaders(app.CSRFProtect(app.Mux())))
defer srv.Close()
// Security headers present on a plain unauthenticated GET too.
headResp, err := http.Get(srv.URL + Prefix + "/login")
if err != nil {
t.Fatal(err)
}
headResp.Body.Close()
if headResp.Header.Get("X-Frame-Options") != "SAMEORIGIN" {
t.Fatalf("expected X-Frame-Options on every response, got headers: %v", headResp.Header)
}
if headResp.Header.Get("Content-Security-Policy") == "" {
t.Fatal("expected a Content-Security-Policy header")
}
adminCookie := loginSession(t, app)
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "csrf-mailbox@example.com", domains[0].ID, "csrf-password-1!")
mailboxCookie := webmailLoginSession(t, app, mailboxID)
jarClient := func(cookie *http.Cookie) *http.Client {
jar, _ := cookiejar.New(nil)
u, _ := url.Parse(srv.URL)
jar.SetCookies(u, []*http.Cookie{cookie})
return &http.Client{Jar: jar}
}
// Regression check: the compose popup (webmail_compose_widget.html) loads
// /webmail/mail/compose in a same-origin <iframe> — X-Frame-Options: DENY or
// frame-ancestors 'none' would silently break that popup (this exact regression
// shipped once already), so explicitly confirm the compose route itself allows
// same-origin framing.
composeResp, err := jarClient(mailboxCookie).Get(srv.URL + MailboxPrefix + "/mail/compose")
if err != nil {
t.Fatal(err)
}
composeResp.Body.Close()
if fo := composeResp.Header.Get("X-Frame-Options"); fo == "DENY" {
t.Fatalf("compose route sets X-Frame-Options: DENY — this breaks the compose popup's own same-origin iframe")
}
if csp := composeResp.Header.Get("Content-Security-Policy"); strings.Contains(csp, "frame-ancestors 'none'") {
t.Fatalf("compose route's CSP sets frame-ancestors 'none' — this breaks the compose popup's own same-origin iframe, got: %s", csp)
}
scrapeCSRFToken := func(client *http.Client, path string) string {
t.Helper()
resp, err := client.Get(srv.URL + path)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
m := csrfTokenInPage.FindSubmatch(body)
if m == nil {
t.Fatalf("no CSRF token found in rendered page %s: %s", path, body)
}
return string(m[1])
}
// --- Admin route: use the always-present, state-changing "logout" POST (changing
// an account setting would need extra setup like enabling TOTP first).
adminClient := jarClient(adminCookie)
adminToken := scrapeCSRFToken(adminClient, Prefix+"/")
forgedResp, err := adminClient.PostForm(srv.URL+Prefix+"/logout", url.Values{"csrf_token": {"forged-not-real"}})
if err != nil {
t.Fatal(err)
}
forgedResp.Body.Close()
if forgedResp.StatusCode != http.StatusForbidden {
t.Fatalf("admin route: expected 403 for a forged CSRF token, got %d", forgedResp.StatusCode)
}
missingResp, err := adminClient.PostForm(srv.URL+Prefix+"/logout", url.Values{})
if err != nil {
t.Fatal(err)
}
missingResp.Body.Close()
if missingResp.StatusCode != http.StatusForbidden {
t.Fatalf("admin route: expected 403 for a missing CSRF token, got %d", missingResp.StatusCode)
}
realResp, err := adminClient.PostForm(srv.URL+Prefix+"/logout", url.Values{"csrf_token": {adminToken}})
if err != nil {
t.Fatal(err)
}
realResp.Body.Close()
if realResp.StatusCode != http.StatusOK && realResp.StatusCode != http.StatusFound {
t.Fatalf("admin route: expected the real-token logout to succeed, got %d", realResp.StatusCode)
}
// --- Webmail route: use the webmail logout POST the same way.
mailboxClient := jarClient(mailboxCookie)
mailboxToken := scrapeCSRFToken(mailboxClient, MailboxPrefix+"/mail/INBOX")
forgedMail, err := mailboxClient.PostForm(srv.URL+MailboxPrefix+"/logout", url.Values{"csrf_token": {"forged-not-real"}})
if err != nil {
t.Fatal(err)
}
forgedMail.Body.Close()
if forgedMail.StatusCode != http.StatusForbidden {
t.Fatalf("webmail route: expected 403 for a forged CSRF token, got %d", forgedMail.StatusCode)
}
realMail, err := mailboxClient.PostForm(srv.URL+MailboxPrefix+"/logout", url.Values{"csrf_token": {mailboxToken}})
if err != nil {
t.Fatal(err)
}
realMail.Body.Close()
if realMail.StatusCode != http.StatusOK && realMail.StatusCode != http.StatusFound {
t.Fatalf("webmail route: expected the real-token logout to succeed, got %d", realMail.StatusCode)
}
// Also confirm the header-based path works (what the fetch() wrapper uses) —
// re-login first since the account above just logged itself out.
mailboxCookie2 := webmailLoginSession(t, app, mailboxID)
mailboxClient2 := jarClient(mailboxCookie2)
mailboxToken2 := scrapeCSRFToken(mailboxClient2, MailboxPrefix+"/mail/INBOX")
req, _ := http.NewRequest(http.MethodPost, srv.URL+MailboxPrefix+"/logout", strings.NewReader(""))
req.Header.Set("X-CSRF-Token", mailboxToken2)
headerResp, err := mailboxClient2.Do(req)
if err != nil {
t.Fatal(err)
}
headerResp.Body.Close()
if headerResp.StatusCode != http.StatusOK && headerResp.StatusCode != http.StatusFound {
t.Fatalf("webmail route: expected the header-tokened logout to succeed, got %d", headerResp.StatusCode)
}
}