fix: add CSRF protection to API v1 routes (closes #112)
All checks were successful
Check / check (pull_request) Successful in 12m25s
All checks were successful
Check / check (pull_request) Successful in 12m25s
Add APICSRFProtection middleware that requires X-Requested-With header on all state-changing (non-GET/HEAD/OPTIONS) API requests. This prevents CSRF attacks since browsers won't send custom headers in cross-origin simple requests (form posts, navigations). Changes: - Add APICSRFProtection() middleware in internal/middleware/middleware.go - Apply middleware to /api/v1 route group in routes.go - Add X-Requested-With to CORS allowed headers - Add unit tests for the middleware (csrf_test.go) - Add integration tests for CSRF rejection/allowance (api_test.go) - Update existing API tests to include the required header
This commit is contained in:
@@ -17,6 +17,7 @@ func apiRouter(tc *testContext) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
r.Route("/api/v1", func(apiR chi.Router) {
|
||||
apiR.Use(tc.middleware.APICSRFProtection())
|
||||
apiR.Post("/login", tc.handlers.HandleAPILoginPOST())
|
||||
|
||||
apiR.Group(func(apiR chi.Router) {
|
||||
@@ -50,6 +51,7 @@ func setupAPITest(t *testing.T) (*testContext, []*http.Cookie) {
|
||||
loginBody := `{"username":"admin","password":"password123"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/login", strings.NewReader(loginBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Requested-With", "XMLHttpRequest")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
@@ -80,6 +82,8 @@ func apiRequest(
|
||||
req = httptest.NewRequest(method, path, nil)
|
||||
}
|
||||
|
||||
req.Header.Set("X-Requested-With", "XMLHttpRequest")
|
||||
|
||||
for _, c := range cookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
@@ -105,6 +109,7 @@ func TestAPILoginSuccess(t *testing.T) {
|
||||
body := `{"username":"admin","password":"password123"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/login", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Requested-With", "XMLHttpRequest")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
@@ -132,6 +137,7 @@ func TestAPILoginInvalidCredentials(t *testing.T) {
|
||||
body := `{"username":"admin","password":"wrong"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/login", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Requested-With", "XMLHttpRequest")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
@@ -149,6 +155,7 @@ func TestAPILoginMissingFields(t *testing.T) {
|
||||
body := `{"username":"","password":""}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/login", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Requested-With", "XMLHttpRequest")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
@@ -297,3 +304,46 @@ func TestAPIListDeployments(t *testing.T) {
|
||||
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &deployments))
|
||||
assert.Empty(t, deployments)
|
||||
}
|
||||
|
||||
func TestAPICSRFRejectsPostWithoutHeader(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tc, cookies := setupAPITest(t)
|
||||
|
||||
r := apiRouter(tc)
|
||||
|
||||
// POST without X-Requested-With header should be rejected.
|
||||
body := `{"name":"csrf-test","repoUrl":"https://example.com/repo"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/apps", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
for _, c := range cookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, rr.Code)
|
||||
assert.Contains(t, rr.Body.String(), "X-Requested-With")
|
||||
}
|
||||
|
||||
func TestAPICSRFAllowsGetWithoutHeader(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tc, cookies := setupAPITest(t)
|
||||
|
||||
r := apiRouter(tc)
|
||||
|
||||
// GET without X-Requested-With should still work.
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/apps", nil)
|
||||
|
||||
for _, c := range cookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user