mirror of
https://github.com/yusing/godoxy.git
synced 2026-03-21 16:49:03 +01:00
Implement Signed Double Submit Cookie pattern to prevent CSRF attacks. Adds CSRF token generation, validation, and middleware for API endpoints. Safe methods (GET/HEAD/OPTIONS) automatically receive CSRF cookies, while unsafe methods require X-CSRF-Token header matching the cookie value with valid HMAC signature. Includes same-origin exemption for login/callback endpoints to support browser-based authentication flows.
281 lines
7.8 KiB
Go
281 lines
7.8 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"github.com/yusing/godoxy/internal/auth"
|
|
autocert "github.com/yusing/godoxy/internal/autocert/types"
|
|
"github.com/yusing/godoxy/internal/common"
|
|
"github.com/yusing/goutils/task"
|
|
)
|
|
|
|
func TestAuthCheckIssuesCSRFCookie(t *testing.T) {
|
|
handler := newAuthenticatedHandler(t)
|
|
|
|
req := httptest.NewRequest(http.MethodHead, "/api/v1/auth/check", nil)
|
|
req.Host = "app.example.com"
|
|
rec := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(rec, req)
|
|
|
|
assert.Equal(t, http.StatusFound, rec.Code)
|
|
|
|
csrfCookie := findCookie(rec.Result().Cookies(), auth.CSRFCookieName)
|
|
require.NotNil(t, csrfCookie)
|
|
assert.NotEmpty(t, csrfCookie.Value)
|
|
assert.Empty(t, csrfCookie.Domain)
|
|
assert.Equal(t, "/", csrfCookie.Path)
|
|
assert.Equal(t, http.SameSiteStrictMode, csrfCookie.SameSite)
|
|
}
|
|
|
|
func TestUserPassCallbackAllowsSameOriginFormPostWithoutCSRFCookie(t *testing.T) {
|
|
handler := newAuthenticatedHandler(t)
|
|
|
|
req := newJSONRequest(t, http.MethodPost, "/api/v1/auth/callback", map[string]string{
|
|
"username": common.APIUser,
|
|
"password": common.APIPassword,
|
|
})
|
|
req.Host = "app.example.com"
|
|
req.Header.Set("Origin", "https://app.example.com")
|
|
rec := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(rec, req)
|
|
|
|
assert.Equal(t, http.StatusOK, rec.Code)
|
|
tokenCookie := findCookie(rec.Result().Cookies(), "godoxy_token")
|
|
require.NotNil(t, tokenCookie)
|
|
assert.NotEmpty(t, tokenCookie.Value)
|
|
csrfCookie := findCookie(rec.Result().Cookies(), auth.CSRFCookieName)
|
|
require.NotNil(t, csrfCookie)
|
|
assert.NotEmpty(t, csrfCookie.Value)
|
|
}
|
|
|
|
func TestUserPassCallbackRejectsCrossOriginPostWithoutCSRFCookie(t *testing.T) {
|
|
handler := newAuthenticatedHandler(t)
|
|
|
|
req := newJSONRequest(t, http.MethodPost, "/api/v1/auth/callback", map[string]string{
|
|
"username": common.APIUser,
|
|
"password": common.APIPassword,
|
|
})
|
|
req.Host = "app.example.com"
|
|
req.Header.Set("Origin", "https://evil.example.com")
|
|
rec := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(rec, req)
|
|
|
|
assert.Equal(t, http.StatusForbidden, rec.Code)
|
|
csrfCookie := findCookie(rec.Result().Cookies(), auth.CSRFCookieName)
|
|
require.NotNil(t, csrfCookie)
|
|
assert.NotEmpty(t, csrfCookie.Value)
|
|
}
|
|
|
|
func TestUserPassCallbackAcceptsValidCSRFCookie(t *testing.T) {
|
|
handler := newAuthenticatedHandler(t)
|
|
csrfCookie := issueCSRFCookie(t, handler)
|
|
|
|
req := newJSONRequest(t, http.MethodPost, "/api/v1/auth/callback", map[string]string{
|
|
"username": common.APIUser,
|
|
"password": common.APIPassword,
|
|
})
|
|
req.Host = "app.example.com"
|
|
req.AddCookie(csrfCookie)
|
|
req.Header.Set(auth.CSRFHeaderName, csrfCookie.Value)
|
|
rec := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(rec, req)
|
|
|
|
assert.Equal(t, http.StatusOK, rec.Code)
|
|
tokenCookie := findCookie(rec.Result().Cookies(), "godoxy_token")
|
|
require.NotNil(t, tokenCookie)
|
|
assert.NotEmpty(t, tokenCookie.Value)
|
|
}
|
|
|
|
func TestUnsafeRequestAcceptsQuotedCSRFCookieValue(t *testing.T) {
|
|
handler := newAuthenticatedHandler(t)
|
|
csrfCookie := issueCSRFCookie(t, handler)
|
|
sessionToken := issueSessionToken(t)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/logout", nil)
|
|
req.Host = "app.example.com"
|
|
req.Header.Set("Cookie", `godoxy_token=`+sessionToken+`; godoxy_csrf="`+csrfCookie.Value+`"`)
|
|
req.Header.Set(auth.CSRFHeaderName, csrfCookie.Value)
|
|
rec := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(rec, req)
|
|
|
|
assert.Equal(t, http.StatusFound, rec.Code)
|
|
}
|
|
|
|
func TestLogoutRequiresCSRFCookie(t *testing.T) {
|
|
handler := newAuthenticatedHandler(t)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/logout", nil)
|
|
req.Host = "app.example.com"
|
|
rec := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(rec, req)
|
|
|
|
assert.Equal(t, http.StatusForbidden, rec.Code)
|
|
}
|
|
|
|
func TestLoginAllowsSameOriginPostWithoutCSRFCookie(t *testing.T) {
|
|
handler := newAuthenticatedHandler(t)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", nil)
|
|
req.Host = "app.example.com"
|
|
req.Header.Set("Origin", "https://app.example.com")
|
|
req.Header.Set("Accept", "text/html")
|
|
rec := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(rec, req)
|
|
|
|
assert.Equal(t, http.StatusFound, rec.Code)
|
|
csrfCookie := findCookie(rec.Result().Cookies(), auth.CSRFCookieName)
|
|
require.NotNil(t, csrfCookie)
|
|
assert.NotEmpty(t, csrfCookie.Value)
|
|
}
|
|
|
|
func TestGetLogoutRouteStillAvailableForFrontend(t *testing.T) {
|
|
handler := newAuthenticatedHandler(t)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/logout", nil)
|
|
req.Host = "app.example.com"
|
|
rec := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(rec, req)
|
|
|
|
assert.Equal(t, http.StatusFound, rec.Code)
|
|
}
|
|
|
|
func TestCertRenewRejectsCrossOriginWebSocketRequest(t *testing.T) {
|
|
handler := newAuthenticatedHandler(t)
|
|
provider := &stubAutocertProvider{}
|
|
sessionToken := issueSessionToken(t)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/cert/renew", nil)
|
|
req.Host = "app.example.com"
|
|
req.Header.Set("Connection", "Upgrade")
|
|
req.Header.Set("Upgrade", "websocket")
|
|
req.Header.Set("Sec-WebSocket-Version", "13")
|
|
req.Header.Set("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==")
|
|
req.Header.Set("Origin", "https://evil.example.com")
|
|
req.AddCookie(&http.Cookie{Name: "godoxy_token", Value: sessionToken})
|
|
req = req.WithContext(context.WithValue(req.Context(), autocert.ContextKey{}, provider))
|
|
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
|
|
assert.Equal(t, http.StatusForbidden, rec.Code)
|
|
assert.Zero(t, provider.forceExpiryCalls)
|
|
}
|
|
|
|
func newAuthenticatedHandler(t *testing.T) *gin.Engine {
|
|
t.Helper()
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
prevSecret := common.APIJWTSecret
|
|
prevUser := common.APIUser
|
|
prevPassword := common.APIPassword
|
|
prevDisableAuth := common.DebugDisableAuth
|
|
prevIssuerURL := common.OIDCIssuerURL
|
|
|
|
common.APIJWTSecret = []byte("0123456789abcdef0123456789abcdef")
|
|
common.APIUser = "username"
|
|
common.APIPassword = "password"
|
|
common.DebugDisableAuth = false
|
|
common.OIDCIssuerURL = ""
|
|
|
|
t.Cleanup(func() {
|
|
common.APIJWTSecret = prevSecret
|
|
common.APIUser = prevUser
|
|
common.APIPassword = prevPassword
|
|
common.DebugDisableAuth = prevDisableAuth
|
|
common.OIDCIssuerURL = prevIssuerURL
|
|
})
|
|
|
|
require.NoError(t, auth.Initialize())
|
|
return NewHandler(true)
|
|
}
|
|
|
|
func issueCSRFCookie(t *testing.T, handler http.Handler) *http.Cookie {
|
|
t.Helper()
|
|
|
|
req := httptest.NewRequest(http.MethodHead, "/api/v1/auth/check", nil)
|
|
req.Host = "app.example.com"
|
|
rec := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(rec, req)
|
|
|
|
csrfCookie := findCookie(rec.Result().Cookies(), auth.CSRFCookieName)
|
|
require.NotNil(t, csrfCookie)
|
|
return csrfCookie
|
|
}
|
|
|
|
func issueSessionToken(t *testing.T) string {
|
|
t.Helper()
|
|
|
|
userpass, ok := auth.GetDefaultAuth().(*auth.UserPassAuth)
|
|
require.True(t, ok)
|
|
|
|
token, err := userpass.NewToken()
|
|
require.NoError(t, err)
|
|
return token
|
|
}
|
|
|
|
func newJSONRequest(t *testing.T, method, target string, body any) *http.Request {
|
|
t.Helper()
|
|
|
|
encoded, err := json.Marshal(body)
|
|
require.NoError(t, err)
|
|
|
|
req := httptest.NewRequest(method, target, bytes.NewReader(encoded))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
return req
|
|
}
|
|
|
|
func findCookie(cookies []*http.Cookie, name string) *http.Cookie {
|
|
for _, cookie := range cookies {
|
|
if cookie.Name == name {
|
|
return cookie
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type stubAutocertProvider struct {
|
|
forceExpiryCalls int
|
|
}
|
|
|
|
func (p *stubAutocertProvider) GetCert(*tls.ClientHelloInfo) (*tls.Certificate, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (p *stubAutocertProvider) GetCertInfos() ([]autocert.CertInfo, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (p *stubAutocertProvider) ScheduleRenewalAll(task.Parent) {}
|
|
|
|
func (p *stubAutocertProvider) ObtainCertAll() error {
|
|
return nil
|
|
}
|
|
|
|
func (p *stubAutocertProvider) ForceExpiryAll() bool {
|
|
p.forceExpiryCalls++
|
|
return true
|
|
}
|
|
|
|
func (p *stubAutocertProvider) WaitRenewalDone(context.Context) bool {
|
|
return true
|
|
}
|