Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
@@ -0,0 +1,278 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/alexedwards/scs/v2"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
|
||||
)
|
||||
|
||||
func testServerCSRF() *Server {
|
||||
sm := scs.New()
|
||||
sm.Cookie.Name = "descrybe_session"
|
||||
return &Server{
|
||||
Config: config.Config{
|
||||
CSRFCookieName: "descrybe_csrf",
|
||||
SessionSecure: false,
|
||||
},
|
||||
Sessions: sm,
|
||||
Auth: &auth.Service{},
|
||||
}
|
||||
}
|
||||
|
||||
func testServerCSRFSecure(secure bool, appEnv string) *Server {
|
||||
s := testServerCSRF()
|
||||
s.Config.SessionSecure = secure
|
||||
s.Config.AppEnv = appEnv
|
||||
return s
|
||||
}
|
||||
|
||||
func findCSRFCookie(cookies []*http.Cookie) *http.Cookie {
|
||||
for _, c := range cookies {
|
||||
if c.Name == "descrybe_csrf" && c.Value != "" {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestCSRFAllowsSafeMethodsWithoutHeader(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := testServerCSRF()
|
||||
h := s.CSRF(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("GET status = %d, want 204", rec.Code)
|
||||
}
|
||||
found := false
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
if c.Name == "descrybe_csrf" && c.Value != "" && !c.HttpOnly {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected non-HttpOnly CSRF cookie on first GET")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRFRejectsPOSTWithoutToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := testServerCSRF()
|
||||
h := s.CSRF(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
for _, path := range []string{
|
||||
"/api/auth/login",
|
||||
"/api/auth/forgot-password",
|
||||
"/api/auth/reset-password",
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodPost, path, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("%s POST without CSRF status = %d, want 403", path, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRFAcceptsMatchingHeader(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := testServerCSRF()
|
||||
h := s.CSRF(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = io.WriteString(w, "ok")
|
||||
}))
|
||||
|
||||
getReq := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||
getRec := httptest.NewRecorder()
|
||||
h.ServeHTTP(getRec, getReq)
|
||||
var token string
|
||||
for _, c := range getRec.Result().Cookies() {
|
||||
if c.Name == "descrybe_csrf" {
|
||||
token = c.Value
|
||||
}
|
||||
}
|
||||
if token == "" {
|
||||
t.Fatal("missing CSRF cookie from GET")
|
||||
}
|
||||
|
||||
postReq := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
|
||||
postReq.AddCookie(&http.Cookie{Name: "descrybe_csrf", Value: token})
|
||||
postReq.Header.Set("X-CSRF-Token", token)
|
||||
postRec := httptest.NewRecorder()
|
||||
h.ServeHTTP(postRec, postReq)
|
||||
if postRec.Code != http.StatusOK {
|
||||
t.Fatalf("POST with CSRF status = %d, want 200", postRec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRFRejectsMismatchedHeader(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := testServerCSRF()
|
||||
h := s.CSRF(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "descrybe_csrf", Value: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"})
|
||||
req.Header.Set("X-CSRF-Token", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("mismatched CSRF status = %d, want 403", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRFCookieAttributesDev(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := testServerCSRFSecure(false, "development")
|
||||
h := s.CSRF(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
c := findCSRFCookie(rec.Result().Cookies())
|
||||
if c == nil {
|
||||
t.Fatal("expected CSRF cookie")
|
||||
}
|
||||
if c.HttpOnly {
|
||||
t.Fatal("CSRF cookie must not be HttpOnly (double-submit)")
|
||||
}
|
||||
if c.Secure {
|
||||
t.Fatal("development without SessionSecure should not set Secure")
|
||||
}
|
||||
if c.SameSite != http.SameSiteLaxMode {
|
||||
t.Fatalf("SameSite = %v, want Lax", c.SameSite)
|
||||
}
|
||||
if c.Path != "/" {
|
||||
t.Fatalf("Path = %q, want /", c.Path)
|
||||
}
|
||||
if c.MaxAge != 7*24*60*60 {
|
||||
t.Fatalf("MaxAge = %d, want 7d", c.MaxAge)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRFCookieSecureWhenSessionSecure(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := testServerCSRFSecure(true, "development")
|
||||
h := s.CSRF(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
c := findCSRFCookie(rec.Result().Cookies())
|
||||
if c == nil {
|
||||
t.Fatal("expected CSRF cookie")
|
||||
}
|
||||
if !c.Secure {
|
||||
t.Fatal("SessionSecure=true should set Secure")
|
||||
}
|
||||
if c.HttpOnly {
|
||||
t.Fatal("CSRF cookie must not be HttpOnly")
|
||||
}
|
||||
if c.SameSite != http.SameSiteLaxMode {
|
||||
t.Fatalf("SameSite = %v, want Lax", c.SameSite)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRFCookieSecureWhenProductionAppEnv(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Defense in depth: APP_ENV=production forces Secure even if SessionSecure was left false.
|
||||
s := testServerCSRFSecure(false, "production")
|
||||
h := s.CSRF(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
c := findCSRFCookie(rec.Result().Cookies())
|
||||
if c == nil {
|
||||
t.Fatal("expected CSRF cookie")
|
||||
}
|
||||
if !c.Secure {
|
||||
t.Fatal("APP_ENV=production must set Secure via CookieSecure")
|
||||
}
|
||||
}
|
||||
|
||||
// Client-mint path: SPA sets descrybe_csrf locally; middleware must accept matching header+cookie
|
||||
// without a prior server-issued Set-Cookie on this request.
|
||||
func TestCSRFAcceptsClientMintedCookie(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := testServerCSRF()
|
||||
h := s.CSRF(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
|
||||
const token = "0123456789abcdef0123456789abcdef"
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "descrybe_csrf", Value: token})
|
||||
req.Header.Set("X-CSRF-Token", token)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("client-minted CSRF status = %d, want 204", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRFExemptPathSegmentsOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
path string
|
||||
exempt bool
|
||||
}{
|
||||
{"/api/v1", true},
|
||||
{"/api/v1/products", true},
|
||||
{"/api/v10", false},
|
||||
{"/api/v1legacy", false},
|
||||
{"/api/public", true},
|
||||
{"/api/public/plans", true},
|
||||
{"/api/publicish", false},
|
||||
{"/api/webhooks", true},
|
||||
{"/api/webhooks/stripe", true},
|
||||
{"/api/webhooksx", false},
|
||||
{"/api/auth/login", false},
|
||||
{"/api/auth/forgot-password", false},
|
||||
{"/api/auth/reset-password", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := csrfExemptPath(tc.path); got != tc.exempt {
|
||||
t.Fatalf("csrfExemptPath(%q) = %v, want %v", tc.path, got, tc.exempt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRFRequiresTokenOnV1LookalikePath(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := testServerCSRF()
|
||||
h := s.CSRF(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v10/mutate", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403 (lookalike must not skip CSRF)", rec.Code)
|
||||
}
|
||||
|
||||
req2 := httptest.NewRequest(http.MethodPost, "/api/v1/products", nil)
|
||||
rec2 := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec2, req2)
|
||||
if rec2.Code != http.StatusNoContent {
|
||||
t.Fatalf("v1 exempt status = %d, want 204", rec2.Code)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user