MFA fix, added IP blacklist, update webmail client
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user