Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions echo.go
Original file line number Diff line number Diff line change
Expand Up @@ -232,9 +232,12 @@ const (
HeaderXCorrelationID = "X-Correlation-Id"
HeaderXRequestedWith = "X-Requested-With"
HeaderServer = "Server"
HeaderOrigin = "Origin"
HeaderCacheControl = "Cache-Control"
HeaderConnection = "Connection"

// HeaderOrigin request header indicates the origin (scheme, hostname, and port) that caused the request.
// See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Origin
HeaderOrigin = "Origin"
HeaderCacheControl = "Cache-Control"
HeaderConnection = "Connection"

// Access control
HeaderAccessControlRequestMethod = "Access-Control-Request-Method"
Expand All @@ -255,6 +258,11 @@ const (
HeaderContentSecurityPolicyReportOnly = "Content-Security-Policy-Report-Only"
HeaderXCSRFToken = "X-CSRF-Token"
HeaderReferrerPolicy = "Referrer-Policy"

// HeaderSecFetchSite fetch metadata request header indicates the relationship between a request initiator's
// origin and the origin of the requested resource.
// See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Sec-Fetch-Site
HeaderSecFetchSite = "Sec-Fetch-Site"
)

const (
Expand Down
91 changes: 88 additions & 3 deletions middleware/csrf.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ package middleware
import (
"crypto/subtle"
"net/http"
"slices"
"strings"
"time"

"github.com/labstack/echo/v4"
Expand All @@ -16,6 +18,22 @@ type CSRFConfig struct {
// Skipper defines a function to skip middleware.
Skipper Skipper

// TrustedOrigin permits any request with `Sec-Fetch-Site` header whose `Origin` header
// exactly matches the specified value.
// Values should be formated as Origin header "scheme://host[:port]".
//
// See [Origin]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Origin
// See [Sec-Fetch-Site]: https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#fetch-metadata-headers
TrustedOrigins []string

// AllowSecFetchSameSite allows custom behaviour for `Sec-Fetch-Site` requests that are about to
// fail with CRSF error, to be allowed or replaced with custom error.
// This function applies to `Sec-Fetch-Site` values:
// - `same-site` same registrable domain (subdomain and/or different port)
// - `cross-site` request originates from different site
// See [Sec-Fetch-Site]: https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#fetch-metadata-headers
AllowSecFetchSiteFunc func(c echo.Context) (bool, error)

// TokenLength is the length of the generated token.
TokenLength uint8 `yaml:"token_length"`
// Optional. Default value 32.
Expand Down Expand Up @@ -94,7 +112,11 @@ func CSRF() echo.MiddlewareFunc {
// CSRFWithConfig returns a CSRF middleware with config.
// See `CSRF()`.
func CSRFWithConfig(config CSRFConfig) echo.MiddlewareFunc {
// Defaults
return toMiddlewareOrPanic(config)
}

// ToMiddleware converts CSRFConfig to middleware or returns an error for invalid configuration
func (config CSRFConfig) ToMiddleware() (echo.MiddlewareFunc, error) {
if config.Skipper == nil {
config.Skipper = DefaultCSRFConfig.Skipper
}
Expand All @@ -117,10 +139,16 @@ func CSRFWithConfig(config CSRFConfig) echo.MiddlewareFunc {
if config.CookieSameSite == http.SameSiteNoneMode {
config.CookieSecure = true
}
if len(config.TrustedOrigins) > 0 {
if vErr := validateOrigins(config.TrustedOrigins, "trusted origin"); vErr != nil {
return nil, vErr
}
config.TrustedOrigins = append([]string(nil), config.TrustedOrigins...)
}

extractors, cErr := CreateExtractors(config.TokenLookup)
if cErr != nil {
panic(cErr)
return nil, cErr
}

return func(next echo.HandlerFunc) echo.HandlerFunc {
Expand All @@ -129,6 +157,17 @@ func CSRFWithConfig(config CSRFConfig) echo.MiddlewareFunc {
return next(c)
}

// use the `Sec-Fetch-Site` header as part of a modern approach to CSRF protection
allow, err := config.checkSecFetchSiteRequest(c)
if err != nil {
return err
}
if allow {
return next(c)
}

// Fallback to legacy token based CSRF protection

token := ""
if k, err := c.Cookie(config.CookieName); err != nil {
token = randomString(config.TokenLength)
Expand Down Expand Up @@ -210,9 +249,55 @@ func CSRFWithConfig(config CSRFConfig) echo.MiddlewareFunc {

return next(c)
}
}
}, nil
}

func validateCSRFToken(token, clientToken string) bool {
return subtle.ConstantTimeCompare([]byte(token), []byte(clientToken)) == 1
}

var safeMethods = []string{http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodTrace}

func (config CSRFConfig) checkSecFetchSiteRequest(c echo.Context) (bool, error) {
// https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#fetch-metadata-headers
// Sec-Fetch-Site values are:
// - `same-origin` exact origin match - allow always
// - `same-site` same registrable domain (subdomain and/or different port) - block, unless explicitly trusted
// - `cross-site` request originates from different site - block, unless explicitly trusted
// - `none` direct navigation (URL bar, bookmark) - allow always
secFetchSite := c.Request().Header.Get(echo.HeaderSecFetchSite)
if secFetchSite == "" {
return false, nil
}

if len(config.TrustedOrigins) > 0 {
// trusted sites ala OAuth callbacks etc. should be let through
origin := c.Request().Header.Get(echo.HeaderOrigin)
if origin != "" {
for _, trustedOrigin := range config.TrustedOrigins {
if strings.EqualFold(origin, trustedOrigin) {
return true, nil
}
}
}
}
isSafe := slices.Contains(safeMethods, c.Request().Method)
if !isSafe { // for state-changing request check SecFetchSite value
isSafe = secFetchSite == "same-origin" || secFetchSite == "none"
}

if isSafe {
return true, nil
}
// we are here when request is state-changing and `cross-site` or `same-site`

// Note: if you want to block `same-site` use config.TrustedOrigins or `config.AllowSecFetchSiteFunc`
if config.AllowSecFetchSiteFunc != nil {
return config.AllowSecFetchSiteFunc(c)
}

if secFetchSite == "same-site" {
return false, nil // fall back to legacy token
}
return false, echo.NewHTTPError(http.StatusForbidden, "cross-site request blocked by CSRF")
}
Loading
Loading