Skip to content

Commit d64ab0a

Browse files
authored
fix(api): enforce CSRF token despite Sec-Fetch-Site (GHSA-9fhj-f35q-w532) (#4142)
Echo v4.15's CSRF middleware treats Sec-Fetch-Site: same-origin or none as already-safe and returns before comparing the CSRF token. A non-browser client that forges that header while holding a session cookie can call CSRF-protected routes without a token. Echo is already on the latest v4.15.4 and offers no config knob to force validation, so neutralize the short-circuit in NewCSRF: for non-skipped requests, strip a same-origin/none Sec-Fetch-Site value before Echo inspects it so Echo always runs its token-validation path, then restore the header via defer. same-site and cross-site are left intact, preserving Echo's explicit cross-site block. EnsureCSRFToken now ignores Echo's Sec-Fetch-Site sentinel so the SPA is never handed the sentinel as a usable token. Remove CSRFCookieRefresh: it re-set the cookie after the handler committed the response, which is a no-op in production. With the short-circuit neutralized, Echo's own token path refreshes the cookie's expiry before commit on every non-skipped request, so the workaround is no longer needed. Add regression tests covering the bypass across Sec-Fetch-Site values and unsafe methods, the legitimate token round-trip, the skipped-route exemption, a mismatched-token rejection, and the sentinel guard.
1 parent ab57c40 commit d64ab0a

6 files changed

Lines changed: 233 additions & 173 deletions

File tree

internal/api/middleware/csrf.go

Lines changed: 37 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ import (
1414
// CSRF configuration constants used by both csrf.go and csrf_token.go.
1515
// These are unexported since they're only used within the middleware package.
1616
const (
17-
// CSRFContextKey is the key used to store CSRF token in the context.
18-
// This must match what spa.go expects when retrieving the token.
17+
// CSRFContextKey is the key used to store the CSRF token in the Echo context.
18+
// EnsureCSRFToken reads it to hand the token to the SPA via /api/v2/app/config.
1919
CSRFContextKey = "csrf"
2020

2121
// csrfCookieName is the name of the CSRF cookie.
@@ -26,6 +26,13 @@ const (
2626

2727
// csrfTokenLength is the length of the generated CSRF token in bytes.
2828
csrfTokenLength = 32
29+
30+
// secFetchSiteSameOrigin and secFetchSiteNone are the Sec-Fetch-Site header
31+
// values that Echo v4.15 treats as already-safe, returning before it compares
32+
// the CSRF token (GHSA-9fhj-f35q-w532). NewCSRF strips these values on
33+
// non-skipped requests so Echo always validates the token.
34+
secFetchSiteSameOrigin = "same-origin"
35+
secFetchSiteNone = "none"
2936
)
3037

3138
// pprofBasePath is the URL prefix under which the api package mounts the Go
@@ -114,12 +121,11 @@ type CSRFConfig struct {
114121
// Default is 1800 (30 minutes).
115122
CookieMaxAge int
116123

117-
// SecureCookie sets the Secure flag on the initial CSRF cookie set by Echo's
118-
// middleware. Set to true when the server is configured with TLS directly.
119-
// For reverse-proxy deployments (TLS terminated upstream, TLSEnabled=false),
120-
// CSRFCookieRefresh overwrites the cookie with the correct Secure flag via
121-
// IsSecureRequest() on every successful response, so the initial value here
122-
// is only relevant for the first request before CSRFCookieRefresh runs.
124+
// SecureCookie sets the Secure flag on the CSRF cookie Echo's middleware sets.
125+
// Set to true when the server terminates TLS directly. For reverse-proxy
126+
// deployments that terminate TLS upstream (TLSEnabled=false), the cookie is
127+
// not marked Secure; making that flag request-aware (via IsSecureRequest) is a
128+
// separate, pre-existing improvement tracked outside this change.
123129
SecureCookie bool
124130
}
125131

@@ -195,39 +201,6 @@ func DefaultCSRFSkipper(c echo.Context) bool {
195201
return false
196202
}
197203

198-
// CSRFCookieRefresh returns a middleware that refreshes the CSRF cookie expiration
199-
// on every non-skipped API request. The skipper should match the one used by
200-
// NewCSRF to ensure consistent skip behavior. If nil, DefaultCSRFSkipper is used.
201-
//
202-
// Echo v4.15.0+ introduced Sec-Fetch-Site header checks that short-circuit the
203-
// CSRF middleware before it reaches the cookie-setting code. This means the
204-
// cookie's max-age is never extended during normal same-origin browsing, causing
205-
// it to expire after 30 minutes. This middleware fixes that by refreshing the
206-
// cookie independently of the token validation path.
207-
func CSRFCookieRefresh(skipper middleware.Skipper) echo.MiddlewareFunc {
208-
if skipper == nil {
209-
skipper = DefaultCSRFSkipper
210-
}
211-
return func(next echo.HandlerFunc) echo.HandlerFunc {
212-
return func(c echo.Context) error {
213-
if skipper(c) {
214-
return next(c)
215-
}
216-
217-
err := next(c)
218-
219-
// On success, refresh the CSRF cookie if one exists
220-
if err == nil {
221-
if cookie, cookieErr := c.Cookie(csrfCookieName); cookieErr == nil && cookie.Value != "" {
222-
setCSRFCookie(c, cookie.Value)
223-
}
224-
}
225-
226-
return err
227-
}
228-
}
229-
}
230-
231204
// NewCSRF creates a CSRF middleware with the given configuration.
232205
// If config is nil, sensible defaults are used that match the legacy implementation.
233206
func NewCSRF(config *CSRFConfig) echo.MiddlewareFunc {
@@ -261,7 +234,7 @@ func NewCSRF(config *CSRFConfig) echo.MiddlewareFunc {
261234
cookieMaxAge = csrfCookieMaxAge
262235
}
263236

264-
return middleware.CSRFWithConfig(middleware.CSRFConfig{
237+
echoCSRF := middleware.CSRFWithConfig(middleware.CSRFConfig{
265238
Skipper: skipper,
266239
TokenLength: tokenLength,
267240
TokenLookup: tokenLookup,
@@ -282,4 +255,26 @@ func NewCSRF(config *CSRFConfig) echo.MiddlewareFunc {
282255
return echo.NewHTTPError(http.StatusForbidden, "Invalid CSRF token")
283256
},
284257
})
258+
259+
// Defense in depth against GHSA-9fhj-f35q-w532. Echo v4.15's CSRF middleware
260+
// treats a request as already safe, skipping token comparison entirely, when
261+
// it carries Sec-Fetch-Site: same-origin or none. Browsers forbid scripts from
262+
// setting Sec-Fetch-Site, but a non-browser client that already holds a session
263+
// cookie can forge it and reach state-changing routes without a token. For
264+
// requests the skipper does not exempt, strip those two values before Echo
265+
// inspects them so Echo always falls through to token validation, then restore
266+
// the header afterward so the request is left as received. same-site and
267+
// cross-site are left intact, preserving Echo's explicit cross-site block.
268+
return func(next echo.HandlerFunc) echo.HandlerFunc {
269+
guarded := echoCSRF(next)
270+
return func(c echo.Context) error {
271+
if !skipper(c) {
272+
if secFetchSite := c.Request().Header.Get(echo.HeaderSecFetchSite); secFetchSite == secFetchSiteSameOrigin || secFetchSite == secFetchSiteNone {
273+
c.Request().Header.Del(echo.HeaderSecFetchSite)
274+
defer c.Request().Header.Set(echo.HeaderSecFetchSite, secFetchSite)
275+
}
276+
}
277+
return guarded(c)
278+
}
279+
}
285280
}

0 commit comments

Comments
 (0)