235 lines
8.2 KiB
Go
235 lines
8.2 KiB
Go
package backendapp
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
func TestCORSMiddlewareEchoesOriginForCredentialedRequests(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
router := gin.New()
|
|
router.Use(corsMiddleware())
|
|
router.GET("/ping", func(c *gin.Context) {
|
|
c.String(http.StatusOK, "pong")
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/ping", nil)
|
|
req.Host = "localhost:38429"
|
|
req.Header.Set("Origin", "http://localhost:37429")
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "http://localhost:37429" {
|
|
t.Fatalf("Access-Control-Allow-Origin = %q, want request origin", got)
|
|
}
|
|
if got := rec.Header().Get("Access-Control-Allow-Credentials"); got != "true" {
|
|
t.Fatalf("Access-Control-Allow-Credentials = %q, want true", got)
|
|
}
|
|
}
|
|
|
|
func TestCORSMiddlewareExposesAuthenticationChallengeHeader(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
router := gin.New()
|
|
router.Use(corsMiddleware())
|
|
router.GET("/protected", func(c *gin.Context) {
|
|
c.Header("WWW-Authenticate", "Bearer")
|
|
c.Status(http.StatusUnauthorized)
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
|
req.Host = "localhost:38429"
|
|
req.Header.Set("Origin", "http://localhost:37429")
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
|
|
}
|
|
if got := rec.Header().Get("WWW-Authenticate"); got != "Bearer" {
|
|
t.Fatalf("WWW-Authenticate = %q, want Bearer", got)
|
|
}
|
|
if got := rec.Header().Get("Access-Control-Expose-Headers"); !strings.Contains(got, "WWW-Authenticate") {
|
|
t.Fatalf("Access-Control-Expose-Headers = %q, want WWW-Authenticate", got)
|
|
}
|
|
}
|
|
|
|
func TestCORSMiddlewareAllowsInterimSettingsInterlockHeader(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
router := gin.New()
|
|
router.Use(corsMiddleware())
|
|
router.POST("/api/v1/agents", func(c *gin.Context) { c.Status(http.StatusNoContent) })
|
|
|
|
req := httptest.NewRequest(http.MethodOptions, "/api/v1/agents", nil)
|
|
req.Host = "localhost:38429"
|
|
req.Header.Set("Origin", "http://localhost:37429")
|
|
req.Header.Set("Access-Control-Request-Method", http.MethodPost)
|
|
req.Header.Set("Access-Control-Request-Headers", "X-Kandev-Interim-Settings-Interlock")
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusNoContent {
|
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNoContent)
|
|
}
|
|
if !strings.Contains(rec.Header().Get("Access-Control-Allow-Headers"), "X-Kandev-Interim-Settings-Interlock") {
|
|
t.Fatalf("Access-Control-Allow-Headers = %q, want interim settings interlock header", rec.Header().Get("Access-Control-Allow-Headers"))
|
|
}
|
|
}
|
|
|
|
func TestCORSMiddlewareAllowsLoopbackAliasOriginForCredentialedRequests(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
router := gin.New()
|
|
router.Use(corsMiddleware())
|
|
router.GET("/ping", func(c *gin.Context) {
|
|
c.String(http.StatusOK, "pong")
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/ping", nil)
|
|
req.Host = "127.0.0.1:38429"
|
|
req.Header.Set("Origin", "http://localhost:37429")
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "http://localhost:37429" {
|
|
t.Fatalf("Access-Control-Allow-Origin = %q, want loopback origin", got)
|
|
}
|
|
if got := rec.Header().Get("Access-Control-Allow-Credentials"); got != "true" {
|
|
t.Fatalf("Access-Control-Allow-Credentials = %q, want true", got)
|
|
}
|
|
}
|
|
|
|
// TestCORSMiddlewareBlocksDisallowedOriginStateChange is the regression guard
|
|
// for the drive-by CSRF RCE: a disallowed cross-origin state-changing request
|
|
// (here the multipart/form-data POST used to reach /api/plugins/install) must
|
|
// be rejected with 403 before the handler — and its side effect — can run.
|
|
func TestCORSMiddlewareBlocksDisallowedOriginStateChange(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
for _, method := range []string{http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete} {
|
|
t.Run(method, func(t *testing.T) {
|
|
handlerRan := false
|
|
router := gin.New()
|
|
router.Use(corsMiddleware())
|
|
router.Handle(method, "/api/plugins/install", func(c *gin.Context) {
|
|
handlerRan = true
|
|
c.Status(http.StatusOK)
|
|
})
|
|
|
|
req := httptest.NewRequest(method, "/api/plugins/install", nil)
|
|
req.Host = "localhost:38429"
|
|
req.Header.Set("Origin", "https://evil.invalid")
|
|
// The CORS-safelisted content type that skips preflight.
|
|
req.Header.Set("Content-Type", "multipart/form-data; boundary=x")
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("status = %d, want 403 for disallowed cross-origin %s", rec.Code, method)
|
|
}
|
|
if handlerRan {
|
|
t.Fatalf("handler executed for disallowed cross-origin %s — side effect not blocked", method)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestCORSMiddlewareBlocksDisallowedOriginGET guards against treating GET as
|
|
// side-effect-free: some endpoints (e.g. plugin webhooks, registered for GET as
|
|
// well as POST) mutate state on GET, so a disallowed-origin GET must also be
|
|
// rejected with 403 before the handler runs.
|
|
func TestCORSMiddlewareBlocksDisallowedOriginGET(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
handlerRan := false
|
|
router := gin.New()
|
|
router.Use(corsMiddleware())
|
|
router.GET("/api/plugins/:id/webhooks/:key", func(c *gin.Context) {
|
|
handlerRan = true
|
|
c.String(http.StatusOK, "pong")
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/plugins/p/webhooks/k", nil)
|
|
req.Host = "localhost:38429"
|
|
req.Header.Set("Origin", "https://evil.invalid")
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("status = %d, want 403 for disallowed cross-origin GET", rec.Code)
|
|
}
|
|
if handlerRan {
|
|
t.Fatal("handler executed for disallowed cross-origin GET — side effect not blocked")
|
|
}
|
|
}
|
|
|
|
// TestCORSMiddlewareAllowsNoOriginStateChange ensures the fix does not break
|
|
// non-browser clients (CLI/curl) that send no Origin header.
|
|
func TestCORSMiddlewareAllowsNoOriginStateChange(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
handlerRan := false
|
|
router := gin.New()
|
|
router.Use(corsMiddleware())
|
|
router.POST("/api/plugins/install", func(c *gin.Context) {
|
|
handlerRan = true
|
|
c.Status(http.StatusOK)
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/plugins/install", nil)
|
|
req.Host = "localhost:38429"
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if !handlerRan || rec.Code != http.StatusOK {
|
|
t.Fatalf("no-Origin POST: handlerRan=%v status=%d, want handler run + 200", handlerRan, rec.Code)
|
|
}
|
|
}
|
|
|
|
// TestCORSMiddlewareAllowsAllowedOriginStateChange ensures the legitimate
|
|
// same/loopback-origin frontend can still make mutating requests.
|
|
func TestCORSMiddlewareAllowsAllowedOriginStateChange(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
handlerRan := false
|
|
router := gin.New()
|
|
router.Use(corsMiddleware())
|
|
router.POST("/api/plugins/install", func(c *gin.Context) {
|
|
handlerRan = true
|
|
c.Status(http.StatusOK)
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/plugins/install", nil)
|
|
req.Host = "localhost:38429"
|
|
req.Header.Set("Origin", "http://127.0.0.1:37429")
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if !handlerRan || rec.Code != http.StatusOK {
|
|
t.Fatalf("allowed-origin POST: handlerRan=%v status=%d, want handler run + 200", handlerRan, rec.Code)
|
|
}
|
|
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "http://127.0.0.1:37429" {
|
|
t.Fatalf("Access-Control-Allow-Origin = %q, want echoed allowed origin", got)
|
|
}
|
|
}
|
|
|
|
func TestCORSMiddlewareRejectsCredentialedRequestsFromUnrelatedOrigins(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
router := gin.New()
|
|
router.Use(corsMiddleware())
|
|
router.GET("/ping", func(c *gin.Context) {
|
|
c.String(http.StatusOK, "pong")
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/ping", nil)
|
|
req.Host = "localhost:38429"
|
|
req.Header.Set("Origin", "https://example.invalid")
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" {
|
|
t.Fatalf("Access-Control-Allow-Origin = %q, want no credentialed CORS", got)
|
|
}
|
|
if got := rec.Header().Get("Access-Control-Allow-Credentials"); got != "" {
|
|
t.Fatalf("Access-Control-Allow-Credentials = %q, want no credentialed CORS", got)
|
|
}
|
|
}
|