internal/server/csrf_test.go
Rama main · réplica de lectura del repositorio original.
// SPDX-License-Identifier: Apache-2.0
package server
import (
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"github.com/X4Applegate/caddyui/internal/auth"
appdb "github.com/X4Applegate/caddyui/internal/db"
)
// v2.29.0: CSRF protection.
func newCSRFTestServer(t *testing.T) *Server {
t.Helper()
conn, err := appdb.Open(filepath.Join(t.TempDir(), "caddyui.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = conn.Close() })
return &Server{DB: conn}
}
func TestCSRFTokenIsSessionBoundAndStable(t *testing.T) {
s := newCSRFTestServer(t)
a1 := s.csrfTokenFor("session-alpha")
a2 := s.csrfTokenFor("session-alpha")
b := s.csrfTokenFor("session-beta")
if a1 == "" {
t.Fatal("token for a real session must not be empty")
}
if a1 != a2 {
t.Error("token must be stable for the same session")
}
if a1 == b {
t.Error("different sessions must produce different tokens")
}
if s.csrfTokenFor("") != "" {
t.Error("empty session must yield an empty token, not a constant")
}
if strings.Contains(a1, "session-alpha") {
t.Error("token must not leak the session value it derives from")
}
}
// The secret must survive a restart, otherwise every live session's token is
// invalidated on deploy and users get a 403 on their next save.
func TestCSRFSecretPersistsAcrossServerInstances(t *testing.T) {
dir := t.TempDir()
conn, err := appdb.Open(filepath.Join(dir, "caddyui.db"))
if err != nil {
t.Fatal(err)
}
defer conn.Close()
first := (&Server{DB: conn}).csrfTokenFor("session-alpha")
second := (&Server{DB: conn}).csrfTokenFor("session-alpha")
if first != second {
t.Errorf("token changed across Server instances (%s vs %s) — secret was not persisted", first, second)
}
}
func TestCSRFTokenMatchesRejectsEmptyAndMismatch(t *testing.T) {
cases := []struct {
expected, got string
want bool
}{
{"abc", "abc", true},
{"abc", "abd", false},
{"", "", false},
{"abc", "", false},
{"", "abc", false},
{"abc", "ab", false},
}
for _, c := range cases {
if got := csrfTokenMatches(c.expected, c.got); got != c.want {
t.Errorf("csrfTokenMatches(%q,%q) = %v, want %v", c.expected, c.got, got, c.want)
}
}
}
func TestCSRFInjectForms(t *testing.T) {
field := []byte(`<input type="hidden" name="csrf_token" value="TOK">`)
tests := []struct {
name string
page string
count int
}{
{"simple post form", `<form method="post" action="/x">a</form>`, 1},
{"uppercase tag and method", `<FORM METHOD="POST" action="/x">a</FORM>`, 1},
{"single quoted method", `<form method='post'>a</form>`, 1},
{"get form untouched", `<form method="get" action="/s">a</form>`, 0},
{"method-less form untouched", `<form action="/s">a</form>`, 0},
{"two post forms", `<form method="post">a</form><form method="post">b</form>`, 2},
{"mixed", `<form method="get">a</form><form method="post">b</form>`, 1},
{
// A '>' inside an attribute value must not be mistaken for the end
// of the tag — otherwise the field lands in the middle of markup.
"angle bracket inside attribute",
`<form method="post" onsubmit="return a>b" class="x">a</form>`,
1,
},
{
"multi-line opening tag",
"<form method=\"post\" action=\"/bulk\"\n id=\"bulk-form\"\n class=\"inline\">a</form>",
1,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := string(csrfInjectForms([]byte(tc.page), field))
if n := strings.Count(got, `name="csrf_token"`); n != tc.count {
t.Fatalf("injected %d times, want %d\ngot: %s", n, tc.count, got)
}
if tc.count > 0 {
// The field must land immediately after the opening tag, i.e.
// inside the form element where it will actually be submitted.
if !strings.Contains(got, `>`+string(field)) {
t.Errorf("field not placed directly after the opening tag: %s", got)
}
}
})
}
}
func TestCSRFInjectFormsNoTokenLeavesPageUntouched(t *testing.T) {
page := `<form method="post">a</form>`
if got := string(csrfInjectForms([]byte(page), nil)); got != page {
t.Errorf("page altered with an empty field: %s", got)
}
}
func TestCSRFSafeMethods(t *testing.T) {
for _, m := range []string{http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodTrace} {
if !csrfSafeMethod(m) {
t.Errorf("%s should be safe", m)
}
}
for _, m := range []string{http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete} {
if csrfSafeMethod(m) {
t.Errorf("%s must not be treated as safe", m)
}
}
}
// The middleware is the actual control. These exercise it end to end.
func TestRequireCSRFMiddleware(t *testing.T) {
s := newCSRFTestServer(t)
const session = "session-alpha"
token := s.csrfTokenFor(session)
reached := false
h := s.requireCSRF(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reached = true
w.WriteHeader(http.StatusOK)
}))
withSession := func(r *http.Request) *http.Request {
r.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: session})
return r
}
formPost := func(body string) *http.Request {
r := httptest.NewRequest(http.MethodPost, "/proxy-hosts", strings.NewReader(body))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
return withSession(r)
}
t.Run("GET passes without a token", func(t *testing.T) {
reached = false
w := httptest.NewRecorder()
h.ServeHTTP(w, withSession(httptest.NewRequest(http.MethodGet, "/", nil)))
if !reached || w.Code != http.StatusOK {
t.Fatalf("safe method blocked: code=%d reached=%v", w.Code, reached)
}
})
t.Run("POST without a token is rejected", func(t *testing.T) {
reached = false
w := httptest.NewRecorder()
h.ServeHTTP(w, formPost("domains=evil.example.com"))
if w.Code != http.StatusForbidden {
t.Fatalf("code = %d, want 403", w.Code)
}
if reached {
t.Fatal("handler ran despite a missing token")
}
})
t.Run("POST with a wrong token is rejected", func(t *testing.T) {
reached = false
w := httptest.NewRecorder()
h.ServeHTTP(w, formPost("csrf_token=deadbeef&domains=evil.example.com"))
if w.Code != http.StatusForbidden || reached {
t.Fatalf("forged token accepted: code=%d reached=%v", w.Code, reached)
}
})
t.Run("POST with another session's token is rejected", func(t *testing.T) {
reached = false
w := httptest.NewRecorder()
h.ServeHTTP(w, formPost("csrf_token="+s.csrfTokenFor("session-beta")))
if w.Code != http.StatusForbidden || reached {
t.Fatalf("cross-session token accepted: code=%d reached=%v", w.Code, reached)
}
})
t.Run("POST with the form field passes", func(t *testing.T) {
reached = false
w := httptest.NewRecorder()
h.ServeHTTP(w, formPost("csrf_token="+token+"&domains=ok.example.com"))
if w.Code != http.StatusOK || !reached {
t.Fatalf("valid form token rejected: code=%d reached=%v", w.Code, reached)
}
})
t.Run("POST with the header passes", func(t *testing.T) {
reached = false
r := withSession(httptest.NewRequest(http.MethodPost, "/api/x", strings.NewReader(`{"a":1}`)))
r.Header.Set("Content-Type", "application/json")
r.Header.Set(csrfHeaderName, token)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusOK || !reached {
t.Fatalf("valid header token rejected: code=%d reached=%v", w.Code, reached)
}
})
t.Run("no session cookie falls through to requireAuth", func(t *testing.T) {
reached = false
r := httptest.NewRequest(http.MethodPost, "/proxy-hosts", strings.NewReader(""))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusOK || !reached {
t.Fatalf("anonymous request should be left to requireAuth: code=%d", w.Code)
}
})
}
// A JSON handler must still see its body after the middleware has looked for a
// token. Reading the form off a JSON request would drain the body and leave the
// handler with nothing.
func TestRequireCSRFLeavesJSONBodyIntact(t *testing.T) {
s := newCSRFTestServer(t)
const session = "session-alpha"
var seen string
h := s.requireCSRF(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
buf := make([]byte, 64)
n, _ := r.Body.Read(buf)
seen = string(buf[:n])
}))
r := httptest.NewRequest(http.MethodPost, "/api/ai/chat", strings.NewReader(`{"msg":"hi"}`))
r.Header.Set("Content-Type", "application/json")
r.Header.Set(csrfHeaderName, s.csrfTokenFor(session))
r.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: session})
h.ServeHTTP(httptest.NewRecorder(), r)
if seen != `{"msg":"hi"}` {
t.Errorf("handler saw body %q, want the original JSON", seen)
}
}