156 lines
6.0 KiB
Go
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)
|
|
}
|
|
}
|