Merge pull request from GHSA-94w9-97p3-p368

* feat: improved csrf with session support

* fix: double submit cookie

* feat: add warning cookie extractor without session

* feat: add warning CsrfFromCookie SameSite

* fix: use byes.Equal instead

* fix: Overriden CookieName KeyLookup cookie:<name>

* feat: Create helpers.go

* feat: use compareTokens (constant time compare)

* feat: validate cookie to prevent token injection

* refactor: clean up csrf.go

* docs: update comment about Double Submit Cookie

* docs: update docs for CSRF changes

* feat: add DeleteToken

* refactor: no else

* test: add more tests

* refactor: re-order tests

* docs: update safe methods RCF add note

* test: add CSRF_Cookie_Injection_Exploit

* feat: add SingleUseToken config

* test: check for new token

* docs: use warning

* fix: always register type Token

* feat: use UUIDv4

* test: swap in UUIDv4 here too

* fix: raw token injection

* fix: merege error

* feat: Sentinel errors

* chore: rename test

* fix: url parse

* test: add path to referer

* test: add expiration tests

* docs: add cookie prefix note

* docs: fix typo

* docs: add warning for refer checks

* test: add referer edge cases

And call ctx.Request.Reset() and
ctx.Response.Reset() before re-using ctx.
pull/2679/head
Jason McNeil 2023-10-16 04:06:30 -03:00 committed by GitHub
parent d736d3a644
commit 8c3916dbf4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 266 additions and 26 deletions

View File

@ -42,10 +42,20 @@ When using this method, pre-sessions are required and will be created if a sessi
When using this middleware, it is recommended that you serve your pages over HTTPS, that the `CookieSecure` option is set to `true`, and that the `CookieSameSite` option is set to `Lax` or `Strict`. This will ensure that the cookie is only sent over HTTPS and that it is not sent on requests from external sites.
:::note
Cookie prefixes __Host- and __Secure- can be used to further secure the cookie. However, these prefixes are not supported by all browsers and there are some other limitations. See [MDN#Set-Cookie#cookie_prefixes](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#cookie_prefixes) for more information.
To use these prefixes, set the `CookieName` option to `__Host-csrf_` or `__Secure-csrf_`.
:::
### Referer Checking
For HTTPS requests, this middleware performs strict referer checking. This means that even if a subdomain can set or modify cookies on your domain, it cant force a user to post to your application since that request wont come from your own exact domain.
:::warning
Referer checking is required for https requests protected by CSRF. All modern browsers will automatically include the Referer header in requests, including those made with the JS Fetch API. However, if you are using this middleware with a custom client you must ensure that the client sends a valid Referer header.
:::
### Token Lifecycle
Tokens are valid until they expire, or until they are deleted. By default, tokens are valid for 1 hour and each subsequent request will extend the expiration by 1 hour. This means that if a user makes a request every hour, the token will never expire. If a user makes a request after the token has expired, then a new token will be generated and the `csrf_` cookie will be set again. This means that the token will only expire if the user does not make a request for the duration of the expiration time.

View File

@ -2,6 +2,7 @@ package csrf
import (
"errors"
"net/url"
"reflect"
"time"
@ -63,10 +64,10 @@ func New(config ...Config) fiber.Handler {
cookieToken := c.Cookies(cfg.CookieName)
if cookieToken != "" {
rawToken := getTokenFromStorage(c, cookieToken, cfg, sessionManager, storageManager)
raw := getRawFromStorage(c, cookieToken, cfg, sessionManager, storageManager)
if rawToken != nil {
token = string(rawToken)
if raw != nil {
token = cookieToken // Token is valid, safe to set it
}
}
default:
@ -92,13 +93,13 @@ func New(config ...Config) fiber.Handler {
// If not using CsrfFromCookie extractor, check that the token matches the cookie
// This is to prevent CSRF attacks by using a Double Submit Cookie method
// Useful when we do not have access to the users Session
if !isCsrfFromCookie(cfg.Extractor) && extractedToken != c.Cookies(cfg.CookieName) {
if !isCsrfFromCookie(cfg.Extractor) && !compareStrings(extractedToken, c.Cookies(cfg.CookieName)) {
return cfg.ErrorHandler(c, ErrTokenInvalid)
}
rawToken := getTokenFromStorage(c, extractedToken, cfg, sessionManager, storageManager)
raw := getRawFromStorage(c, extractedToken, cfg, sessionManager, storageManager)
if rawToken == nil {
if raw == nil {
// If token is not in storage, expire the cookie
expireCSRFCookie(c, cfg)
// and return an error
@ -108,7 +109,7 @@ func New(config ...Config) fiber.Handler {
// If token is single use, delete it from storage
deleteTokenFromStorage(c, extractedToken, cfg, sessionManager, storageManager)
} else {
token = string(rawToken)
token = extractedToken // Token is valid, safe to set it
}
}
@ -137,9 +138,9 @@ func New(config ...Config) fiber.Handler {
}
}
// getTokenFromStorage returns the raw token from the storage
// getRawFromStorage returns the raw value from the storage for the given token
// returns nil if the token does not exist, is expired or is invalid
func getTokenFromStorage(c *fiber.Ctx, token string, cfg Config, sessionManager *sessionManager, storageManager *storageManager) []byte {
func getRawFromStorage(c *fiber.Ctx, token string, cfg Config, sessionManager *sessionManager, storageManager *storageManager) []byte {
if cfg.Session != nil {
return sessionManager.getRaw(c, token, dummyValue)
}
@ -223,8 +224,15 @@ func refererMatchesHost(c *fiber.Ctx) error {
if referer == "" {
return ErrNoReferer
}
if referer != c.Protocol()+"://"+c.Hostname() {
refererURL, err := url.Parse(referer)
if err != nil {
return ErrBadReferer
}
if refererURL.Scheme+"://"+refererURL.Host != c.Protocol()+"://"+c.Hostname() {
return ErrBadReferer
}
return nil
}

View File

@ -4,6 +4,7 @@ import (
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/session"
@ -153,6 +154,170 @@ func Test_CSRF_WithSession(t *testing.T) {
}
}
// go test -run Test_CSRF_ExpiredToken
func Test_CSRF_ExpiredToken(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{
Expiration: 1 * time.Second,
}))
app.Post("/", func(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusOK)
})
h := app.Handler()
ctx := &fasthttp.RequestCtx{}
// Generate CSRF token
ctx.Request.Header.SetMethod(fiber.MethodGet)
h(ctx)
token := string(ctx.Response.Header.Peek(fiber.HeaderSetCookie))
token = strings.Split(strings.Split(token, ";")[0], "=")[1]
// Use the CSRF token
ctx.Request.Reset()
ctx.Response.Reset()
ctx.Request.Header.SetMethod(fiber.MethodPost)
ctx.Request.Header.Set(HeaderName, token)
ctx.Request.Header.SetCookie(ConfigDefault.CookieName, token)
h(ctx)
utils.AssertEqual(t, 200, ctx.Response.StatusCode())
// Wait for the token to expire
time.Sleep(1 * time.Second)
// Expired CSRF token
ctx.Request.Reset()
ctx.Response.Reset()
ctx.Request.Header.SetMethod(fiber.MethodPost)
ctx.Request.Header.Set(HeaderName, token)
ctx.Request.Header.SetCookie(ConfigDefault.CookieName, token)
h(ctx)
utils.AssertEqual(t, 403, ctx.Response.StatusCode())
}
// go test -run Test_CSRF_ExpiredToken_WithSession
func Test_CSRF_ExpiredToken_WithSession(t *testing.T) {
t.Parallel()
// session store
store := session.New(session.Config{
KeyLookup: "cookie:_session",
})
// fiber instance
app := fiber.New()
// fiber context
ctx := &fasthttp.RequestCtx{}
defer app.ReleaseCtx(app.AcquireCtx(ctx))
// get session
sess, err := store.Get(app.AcquireCtx(ctx))
utils.AssertEqual(t, nil, err)
utils.AssertEqual(t, true, sess.Fresh())
// get session id
newSessionIDString := sess.ID()
app.AcquireCtx(ctx).Request().Header.SetCookie("_session", newSessionIDString)
// middleware config
config := Config{
Session: store,
Expiration: 1 * time.Second,
}
// middleware
app.Use(New(config))
app.Post("/", func(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusOK)
})
h := app.Handler()
// Generate CSRF token
ctx.Request.Header.SetMethod(fiber.MethodGet)
ctx.Request.Header.SetCookie("_session", newSessionIDString)
h(ctx)
token := string(ctx.Response.Header.Peek(fiber.HeaderSetCookie))
for _, header := range strings.Split(token, ";") {
if strings.Split(strings.TrimSpace(header), "=")[0] == ConfigDefault.CookieName {
token = strings.Split(header, "=")[1]
break
}
}
// Use the CSRF token
ctx.Request.Reset()
ctx.Response.Reset()
ctx.Request.Header.SetMethod(fiber.MethodPost)
ctx.Request.Header.Set(HeaderName, token)
ctx.Request.Header.SetCookie("_session", newSessionIDString)
ctx.Request.Header.SetCookie(ConfigDefault.CookieName, token)
h(ctx)
utils.AssertEqual(t, 200, ctx.Response.StatusCode())
// Wait for the token to expire
time.Sleep(1 * time.Second)
// Expired CSRF token
ctx.Request.Reset()
ctx.Response.Reset()
ctx.Request.Header.SetMethod(fiber.MethodPost)
ctx.Request.Header.Set(HeaderName, token)
ctx.Request.Header.SetCookie("_session", newSessionIDString)
ctx.Request.Header.SetCookie(ConfigDefault.CookieName, token)
h(ctx)
utils.AssertEqual(t, 403, ctx.Response.StatusCode())
}
// go test -run Test_CSRF_MultiUseToken
func Test_CSRF_MultiUseToken(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{
KeyLookup: "header:X-CSRF-Token",
}))
app.Post("/", func(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusOK)
})
h := app.Handler()
ctx := &fasthttp.RequestCtx{}
// Invalid CSRF token
ctx.Request.Header.SetMethod(fiber.MethodPost)
ctx.Request.Header.Set("X-CSRF-Token", "johndoe")
h(ctx)
utils.AssertEqual(t, 403, ctx.Response.StatusCode())
// Generate CSRF token
ctx.Request.Reset()
ctx.Response.Reset()
ctx.Request.Header.SetMethod(fiber.MethodGet)
h(ctx)
token := string(ctx.Response.Header.Peek(fiber.HeaderSetCookie))
token = strings.Split(strings.Split(token, ";")[0], "=")[1]
ctx.Request.Reset()
ctx.Response.Reset()
ctx.Request.Header.SetMethod(fiber.MethodPost)
ctx.Request.Header.Set("X-CSRF-Token", token)
ctx.Request.Header.SetCookie(ConfigDefault.CookieName, token)
h(ctx)
newToken := string(ctx.Response.Header.Peek(fiber.HeaderSetCookie))
newToken = strings.Split(strings.Split(newToken, ";")[0], "=")[1]
utils.AssertEqual(t, 200, ctx.Response.StatusCode())
// Check if the token is not a dummy value
utils.AssertEqual(t, token, newToken)
}
// go test -run Test_CSRF_SingleUseToken
func Test_CSRF_SingleUseToken(t *testing.T) {
t.Parallel()
@ -260,6 +425,8 @@ func Test_CSRF_From_Form(t *testing.T) {
token := string(ctx.Response.Header.Peek(fiber.HeaderSetCookie))
token = strings.Split(strings.Split(token, ";")[0], "=")[1]
ctx.Request.Reset()
ctx.Response.Reset()
ctx.Request.Header.SetMethod(fiber.MethodPost)
ctx.Request.Header.Set(fiber.HeaderContentType, fiber.MIMEApplicationForm)
ctx.Request.SetBodyString("_csrf=" + token)
@ -393,7 +560,7 @@ func Test_CSRF_From_Custom(t *testing.T) {
selectors := strings.Split(body, "=")
if len(selectors) != 2 || selectors[1] == "" {
return "", errMissingParam
return "", ErrMissingParam
}
return selectors[1], nil
}
@ -421,6 +588,8 @@ func Test_CSRF_From_Custom(t *testing.T) {
token := string(ctx.Response.Header.Peek(fiber.HeaderSetCookie))
token = strings.Split(strings.Split(token, ";")[0], "=")[1]
ctx.Request.Reset()
ctx.Response.Reset()
ctx.Request.Header.SetMethod(fiber.MethodPost)
ctx.Request.Header.Set(fiber.HeaderContentType, fiber.MIMETextPlain)
ctx.Request.SetBodyString("_csrf=" + token)
@ -447,17 +616,67 @@ func Test_CSRF_Referer(t *testing.T) {
token := string(ctx.Response.Header.Peek(fiber.HeaderSetCookie))
token = strings.Split(strings.Split(token, ";")[0], "=")[1]
// Test Correct Referer
// Test Correct Referer with port
ctx.Request.Reset()
ctx.Response.Reset()
ctx.Request.Header.SetMethod(fiber.MethodPost)
ctx.Request.URI().SetScheme("https")
ctx.Request.URI().SetHost("example.com:8443")
ctx.Request.Header.SetProtocol("https")
ctx.Request.Header.SetHost("example.com:8443")
ctx.Request.Header.Set(fiber.HeaderReferer, ctx.Request.URI().String())
ctx.Request.Header.Set(HeaderName, token)
ctx.Request.Header.SetCookie(ConfigDefault.CookieName, token)
h(ctx)
utils.AssertEqual(t, 200, ctx.Response.StatusCode())
// Test Correct Referer with ReverseProxy
ctx.Request.Reset()
ctx.Response.Reset()
ctx.Request.Header.SetMethod(fiber.MethodPost)
ctx.Request.URI().SetScheme("https")
ctx.Request.URI().SetHost("10.0.1.42.com:8443")
ctx.Request.Header.SetProtocol("https")
ctx.Request.Header.SetHost("10.0.1.42:8443")
ctx.Request.Header.Set(fiber.HeaderXForwardedProto, "https")
ctx.Request.Header.Set(fiber.HeaderXForwardedHost, "example.com")
ctx.Request.Header.Set(fiber.HeaderXForwardedFor, `192.0.2.43, "[2001:db8:cafe::17]"`)
ctx.Request.Header.Set(fiber.HeaderReferer, "https://example.com")
ctx.Request.Header.Set(HeaderName, token)
ctx.Request.Header.SetCookie(ConfigDefault.CookieName, token)
h(ctx)
utils.AssertEqual(t, 200, ctx.Response.StatusCode())
// Test Correct Referer with ReverseProxy Missing X-Forwarded-* Headers
ctx.Request.Reset()
ctx.Response.Reset()
ctx.Request.Header.SetMethod(fiber.MethodPost)
ctx.Request.URI().SetScheme("https")
ctx.Request.URI().SetHost("10.0.1.42:8443")
ctx.Request.Header.SetProtocol("https")
ctx.Request.Header.SetHost("10.0.1.42:8443")
ctx.Request.Header.Set(fiber.HeaderXUrlScheme, "https") // We need to set this header to make sure c.Protocol() returns https
ctx.Request.Header.Set(fiber.HeaderReferer, "https://example.com")
ctx.Request.Header.Set(HeaderName, token)
ctx.Request.Header.SetCookie(ConfigDefault.CookieName, token)
h(ctx)
utils.AssertEqual(t, 403, ctx.Response.StatusCode())
// Test Correct Referer with path
ctx.Request.Reset()
ctx.Response.Reset()
ctx.Request.Header.SetMethod(fiber.MethodPost)
ctx.Request.Header.Set(fiber.HeaderXForwardedProto, "https")
ctx.Request.Header.Set(fiber.HeaderXForwardedHost, "example.com")
ctx.Request.Header.Set(fiber.HeaderReferer, "https://example.com/action/items?gogogo=true")
ctx.Request.Header.Set(HeaderName, token)
ctx.Request.Header.SetCookie(ConfigDefault.CookieName, token)
h(ctx)
utils.AssertEqual(t, 200, ctx.Response.StatusCode())
// Test Wrong Referer
ctx.Request.Reset()
ctx.Response.Reset()
ctx.Request.Header.SetMethod(fiber.MethodPost)
ctx.Request.Header.Set(fiber.HeaderXForwardedProto, "https")
ctx.Request.Header.Set(fiber.HeaderXForwardedHost, "example.com")
@ -490,7 +709,6 @@ func Test_CSRF_DeleteToken(t *testing.T) {
token = strings.Split(strings.Split(token, ";")[0], "=")[1]
// Delete the CSRF token
ctx.Request.Header.SetMethod(fiber.MethodGet)
ctx.Request.Reset()
ctx.Response.Reset()
ctx.Request.Header.SetMethod(fiber.MethodPost)
@ -503,7 +721,6 @@ func Test_CSRF_DeleteToken(t *testing.T) {
}
h(ctx)
ctx.Request.Header.SetMethod(fiber.MethodGet)
ctx.Request.Reset()
ctx.Response.Reset()
ctx.Request.Header.SetMethod(fiber.MethodPost)
@ -559,7 +776,6 @@ func Test_CSRF_DeleteToken_WithSession(t *testing.T) {
token = strings.Split(strings.Split(token, ";")[0], "=")[1]
// Delete the CSRF token
ctx.Request.Header.SetMethod(fiber.MethodGet)
ctx.Request.Reset()
ctx.Response.Reset()
ctx.Request.Header.SetMethod(fiber.MethodPost)
@ -619,7 +835,7 @@ func Test_CSRF_ErrorHandler_EmptyToken(t *testing.T) {
app := fiber.New()
errHandler := func(ctx *fiber.Ctx, err error) error {
utils.AssertEqual(t, errMissingHeader, err)
utils.AssertEqual(t, ErrMissingHeader, err)
return ctx.Status(419).Send([]byte("empty CSRF token"))
}
@ -671,6 +887,8 @@ func Test_CSRF_ErrorHandler_MissingReferer(t *testing.T) {
token := string(ctx.Response.Header.Peek(fiber.HeaderSetCookie))
token = strings.Split(strings.Split(token, ";")[0], "=")[1]
ctx.Request.Reset()
ctx.Response.Reset()
ctx.Request.Header.SetMethod(fiber.MethodPost)
ctx.Request.Header.Set(fiber.HeaderXForwardedProto, "https")
ctx.Request.Header.Set(fiber.HeaderXForwardedHost, "example.com")

View File

@ -7,11 +7,11 @@ import (
)
var (
errMissingHeader = errors.New("missing csrf token in header")
errMissingQuery = errors.New("missing csrf token in query")
errMissingParam = errors.New("missing csrf token in param")
errMissingForm = errors.New("missing csrf token in form")
errMissingCookie = errors.New("missing csrf token in cookie")
ErrMissingHeader = errors.New("missing csrf token in header")
ErrMissingQuery = errors.New("missing csrf token in query")
ErrMissingParam = errors.New("missing csrf token in param")
ErrMissingForm = errors.New("missing csrf token in form")
ErrMissingCookie = errors.New("missing csrf token in cookie")
)
// csrfFromParam returns a function that extracts token from the url param string.
@ -19,7 +19,7 @@ func CsrfFromParam(param string) func(c *fiber.Ctx) (string, error) {
return func(c *fiber.Ctx) (string, error) {
token := c.Params(param)
if token == "" {
return "", errMissingParam
return "", ErrMissingParam
}
return token, nil
}
@ -30,7 +30,7 @@ func CsrfFromForm(param string) func(c *fiber.Ctx) (string, error) {
return func(c *fiber.Ctx) (string, error) {
token := c.FormValue(param)
if token == "" {
return "", errMissingForm
return "", ErrMissingForm
}
return token, nil
}
@ -41,7 +41,7 @@ func CsrfFromCookie(param string) func(c *fiber.Ctx) (string, error) {
return func(c *fiber.Ctx) (string, error) {
token := c.Cookies(param)
if token == "" {
return "", errMissingCookie
return "", ErrMissingCookie
}
return token, nil
}
@ -52,7 +52,7 @@ func CsrfFromHeader(param string) func(c *fiber.Ctx) (string, error) {
return func(c *fiber.Ctx) (string, error) {
token := c.Get(param)
if token == "" {
return "", errMissingHeader
return "", ErrMissingHeader
}
return token, nil
}
@ -63,7 +63,7 @@ func CsrfFromQuery(param string) func(c *fiber.Ctx) (string, error) {
return func(c *fiber.Ctx) (string, error) {
token := c.Query(param)
if token == "" {
return "", errMissingQuery
return "", ErrMissingQuery
}
return token, nil
}

View File

@ -7,3 +7,7 @@ import (
func compareTokens(a, b []byte) bool {
return subtle.ConstantTimeCompare(a, b) == 1
}
func compareStrings(a, b string) bool {
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}