diff --git a/Makefile b/Makefile index b0a8151fbe5..7f31d5dc0b1 100644 --- a/Makefile +++ b/Makefile @@ -35,7 +35,7 @@ markdown: ## lint: 🚨 Run lint checks .PHONY: lint lint: - golangci-lint run + golangci-lint run ## modernize: 🛠 Run gopls modernize .PHONY: modernize diff --git a/docs/middleware/csrf.md b/docs/middleware/csrf.md index 1f7473c7e3d..66d19398e73 100644 --- a/docs/middleware/csrf.md +++ b/docs/middleware/csrf.md @@ -4,209 +4,322 @@ id: csrf # CSRF -The CSRF middleware for [Fiber](https://github.com/gofiber/fiber) provides protection against [Cross-Site Request Forgery](https://en.wikipedia.org/wiki/Cross-site_request_forgery) (CSRF) attacks. Requests made using methods other than those defined as 'safe' by [RFC9110#section-9.2.1](https://datatracker.ietf.org/doc/html/rfc9110.html#section-9.2.1) (GET, HEAD, OPTIONS, and TRACE) are validated using tokens. If a potential attack is detected, the middleware will return a default 403 Forbidden error. +The CSRF middleware provides protection against [Cross-Site Request Forgery](https://en.wikipedia.org/wiki/Cross-site_request_forgery) attacks. It validates tokens on unsafe HTTP methods (POST, PUT, DELETE, etc.) and returns 403 Forbidden if an attack is detected. -This middleware offers two [Token Validation Patterns](#token-validation-patterns): the [Double Submit Cookie Pattern (default)](#double-submit-cookie-pattern-default), and the [Synchronizer Token Pattern (with Session)](#synchronizer-token-pattern-with-session). +## Table of Contents -As a [Defense In Depth](#defense-in-depth) measure, this middleware performs [Referer Checking](#referer-checking) for HTTPS requests. +- [Quick Start](#quick-start) +- [Best Practices & Production Requirements](#best-practices--production-requirements) +- [Configuration by Application Type](#configuration-by-application-type) +- [Recipes for Common Use Cases](#recipes-for-common-use-cases) +- [Using CSRF Tokens](#using-csrf-tokens) +- [Security Model](#security-model) +- [Token Extractors](#token-extractors) +- [Advanced Configuration](#advanced-configuration) +- [API Reference](#api-reference) +- [Config Properties](#config-properties) +- [Error Types](#error-types) +- [Constants](#constants) -## How to use Fiber's CSRF Middleware - -## Examples - -Import the middleware package that is part of the Fiber web framework: +## Quick Start ```go import ( "github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3/middleware/csrf" ) + +// Default config (development only) +app.Use(csrf.New()) + +// Production config +app.Use(csrf.New(csrf.Config{ + CookieName: "__Host-csrf_", + CookieSecure: true, + CookieHTTPOnly: true, // false for SPAs + CookieSameSite: "Lax", + CookieSessionOnly: true, + Extractor: csrf.FromHeader("X-Csrf-Token"), + Session: sessionStore, +})) ``` -After initializing your Fiber app, you can use the following code to initialize the middleware: +## Best Practices & Production Requirements + +:::danger Production Requirements + +- `CookieSecure: true` (HTTPS only) +- `CookieSameSite: "Lax"` or `"Strict"` +- Use `Session` store for better security + +::: + +1. **Always use HTTPS** in production +2. **Use sessions** for authenticated applications +3. **Set `CookieSecure: true`** and appropriate SameSite values +4. **Implement XSS protection** alongside CSRF +5. **Regenerate tokens** after auth changes +6. **Use `__Host-` cookie prefix** when possible + +:::warning BREACH Protection +To mitigate BREACH attacks, ensure your pages are served over HTTPS, disable HTTP compression, and implement rate limiting for requests. The CSRF token is sent as a header on every request, so if you include the token in a page that is vulnerable to BREACH, an attacker may be able to extract the token. +::: + +## Configuration by Application Type + +### Server-Side Rendered Apps ```go -// Initialize default config -app.Use(csrf.New()) +app.Use(csrf.New(csrf.Config{ + CookieName: "__Host-csrf_", + CookieSecure: true, + CookieHTTPOnly: true, // Secure - blocks JavaScript + CookieSameSite: "Lax", + CookieSessionOnly: true, + Extractor: csrf.FromForm("_csrf"), + Session: sessionStore, +})) +``` -// Or extend your config for customization +### Single Page Applications (SPAs) + +```go app.Use(csrf.New(csrf.Config{ - KeyLookup: "header:X-Csrf-Token", - CookieName: "csrf_", - CookieSameSite: "Lax", - IdleTimeout: 30 * time.Minute, - KeyGenerator: utils.UUIDv4, - Extractor: func(c fiber.Ctx) (string, error) { ... }, + CookieName: "__Host-csrf_", + CookieSecure: true, + CookieHTTPOnly: false, // Required for JavaScript access to tokens + CookieSameSite: "Lax", + CookieSessionOnly: true, + Extractor: csrf.FromHeader("X-Csrf-Token"), + Session: sessionStore, })) ``` -:::info -KeyLookup will be ignored if Extractor is explicitly set. +:::warning SPA Security Trade-off +SPAs require `CookieHTTPOnly: false` to access tokens via JavaScript. This slightly increases XSS risk but is necessary for SPA functionality. ::: -Getting the CSRF token in a handler: +## Recipes for Common Use Cases + +- **Without Sessions**: [CSRF Recipe](https://github.com/gofiber/recipes/tree/master/csrf) - Simple Double Submit Cookie pattern +- **With Sessions**: [CSRF with Session Recipe](https://github.com/gofiber/recipes/tree/master/csrf-with-session) - More secure Synchronizer Token pattern + +## Using CSRF Tokens + +### Server-Side Forms ```go -func handler(c fiber.Ctx) error { - // Get CSRF token from the context +func formHandler(c fiber.Ctx) error { token := csrf.TokenFromContext(c) - if token == "" { - return c.Status(fiber.StatusInternalServerError) - } - // Note: Make sure this matches the KeyLookup configured in your middleware - // Example: If you configured csrf.Config{KeyLookup: "form:_csrf"} - formKey := "_csrf" + return c.SendString(fmt.Sprintf(` +
+ + + +
+ `, token)) +} +``` + +### Single Page Applications + +```go +func apiHandler(c fiber.Ctx) error { + token := csrf.TokenFromContext(c) + + return c.JSON(fiber.Map{ + "csrf_token": token, + "data": "your data", + }) +} +``` + +```javascript +// Get CSRF token from cookie +function getCsrfToken() { + const value = `; ${document.cookie}`; + const parts = value.split(`; __Host-csrf_=`); + if (parts.length === 2) return parts.pop().split(';').shift(); +} + +// Use with fetch API +async function makeRequest(url, data) { + const csrfToken = getCsrfToken(); - // Create a form with the CSRF token - tmpl := fmt.Sprintf(`
- - - -
`, formKey, token) + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Csrf-Token': csrfToken + }, + body: JSON.stringify(data) + }); - c.Set("Content-Type", "text/html") - return c.SendString(tmpl) + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + return response.json(); } ``` -## Recipes for Common Use Cases +## Security Model -There are two basic use cases for the CSRF middleware: +The middleware employs a robust, defense-in-depth strategy to protect against CSRF attacks. The primary defense is token-based validation, which operates in one of two modes depending on your configuration. This is supplemented by a mandatory secondary check on the request's origin. -1. **Without Sessions**: This is the simplest way to use the middleware. It uses the Double Submit Cookie Pattern and does not require a user session. +### 1. Token Validation Patterns - - See GoFiber recipe [CSRF](https://github.com/gofiber/recipes/tree/master/csrf) for an example of using the CSRF middleware without a user session. +#### Double Submit Cookie (Default Mode) -2. **With Sessions**: This is generally considered more secure. It uses the Synchronizer Token Pattern and requires a user session, and the use of pre-session, which prevents login CSRF attacks. +This is the default pattern, used when a `Session` store is **not** configured. It is a "semi-stateless" approach; while it doesn't tie tokens to a specific user session, the server still maintains a record of all validly issued tokens. - - See GoFiber recipe [CSRF with Session](https://github.com/gofiber/recipes/tree/master/csrf-with-session) for an example of using the CSRF middleware with a user session. +- **How it Works:** + 1. On a user's first visit (or a safe request like `GET`), the middleware generates a unique token. + 2. This token is sent to the client in a `Set-Cookie` header. + 3. A record of this token is also kept on the server (in-memory by default, or in your configured `Storage`). This proves the token was generated by the server and is not expired, but does not link it to a specific user. + 4. For any subsequent unsafe request (e.g., `POST`, `PUT`), the client application must read the token from the cookie and send it back in a different location, such as the `X-CSRF-Token` header. -## Signatures +- **Validation:** The middleware validates three things: that the token from the header/form **exactly matches** the token from the cookie, that the token **exists** in the server-side storage, and that it **has not expired**. +- **Why it's Secure:** An attacker on a malicious domain cannot read the victim's cookie to forge a matching header. Furthermore, they cannot invent a token, because it wouldn't exist in the server's storage registry. -```go -func New(config ...Config) fiber.Handler -func TokenFromContext(c fiber.Ctx) string -func HandlerFromContext(c fiber.Ctx) *Handler +#### Synchronizer Token (Session-Based Mode) -func (h *Handler) DeleteToken(c fiber.Ctx) error -``` +This is a more secure, stateful pattern that is **automatically enabled** when you provide a `Session` store in the configuration. + +- **How it Works:** + 1. A unique token is generated and stored directly within the user's session data on the server. + 2. The token is also sent to the client as a cookie. + 3. For unsafe requests, the client sends the token back in a header or form field. -## Config - -| Property | Type | Description | Default | -|:------------------|:-----------------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:-----------------------------| -| Next | `func(fiber.Ctx) bool` | Next defines a function to skip this middleware when returned true. | `nil` | -| KeyLookup | `string` | KeyLookup is a string in the form of "`:`" that is used to create an Extractor that extracts the token from the request. Possible values: "`header:`", "`query:`", "`param:`", "`form:`", "`cookie:`". Ignored if an Extractor is explicitly set. | "header:X-CSRF-Token" | -| CookieName | `string` | Name of the csrf cookie. This cookie will store the csrf key. | "csrf_" | -| CookieDomain | `string` | Domain of the CSRF cookie. | "" | -| CookiePath | `string` | Path of the CSRF cookie. | "" | -| CookieSecure | `bool` | Indicates if the CSRF cookie is secure. | false | -| CookieHTTPOnly | `bool` | Indicates if the CSRF cookie is HTTP-only. | false | -| CookieSameSite | `string` | Value of SameSite cookie. | "Lax" | -| CookieSessionOnly | `bool` | Decides whether the cookie should last for only the browser session. (cookie expires on close). | false | -| IdleTimeout | `time.Duration` | IdleTimeout is the duration of inactivity before the CSRF token will expire. | 30 * time.Minute | -| KeyGenerator | `func() string` | KeyGenerator creates a new CSRF token. | utils.UUID | -| ErrorHandler | `fiber.ErrorHandler` | ErrorHandler is executed when an error is returned from fiber.Handler. | DefaultErrorHandler | -| Extractor | `func(fiber.Ctx) (string, error)` | Extractor returns the CSRF token. If set, this will be used in place of an Extractor based on KeyLookup. | Extractor based on KeyLookup | -| SingleUseToken | `bool` | SingleUseToken indicates if the CSRF token be destroyed and a new one generated on each use. (See TokenLifecycle) | false | -| Storage | `fiber.Storage` | Store is used to store the state of the middleware. | `nil` | -| Session | `*session.Store` | Session is used to store the state of the middleware. Overrides Storage if set. | `nil` | -| TrustedOrigins | `[]string` | TrustedOrigins is a list of trusted origins for unsafe requests. This supports subdomain matching, so you can use a value like "https://*.example.com" to allow any subdomain of example.com to submit requests. | `[]` | - -### Default Config +- **Validation:** The middleware performs a multi-step validation: + 1. It first performs the standard **Double Submit Cookie check**: the token from the header/form must exactly match the token from the cookie. This is a fast and efficient first line of defense, and there is little benefit of skipping it. + 2. It then validates that this token exists and is valid within the user's **server-side session**. This is the authoritative check that ties the token to the authenticated user. + +- **Why it's More Secure:** Tying the token to the server-side session provides the strongest CSRF protection, as the token is then guaranteed to have been generated for the specific user. While browsers handle sending the required cookie automatically, it's important to note that custom API clients must also remember to send the cookie with their requests for validation to succeed. ```go -var ConfigDefault = Config{ - KeyLookup: "header:" + HeaderName, - CookieName: "csrf_", - CookieSameSite: "Lax", - IdleTimeout: 30 * time.Minute, - KeyGenerator: utils.UUIDv4, - ErrorHandler: defaultErrorHandler, - Extractor: FromHeader(HeaderName), -} +// Enable the more secure Synchronizer Token pattern +app.Use(csrf.New(csrf.Config{ + Session: sessionStore, // Providing a session store activates this mode +})) ``` -### Recommended Config (with session) +### 2. Origin & Referer Validation -It's recommended to use this middleware with [fiber/middleware/session](https://docs.gofiber.io/api/middleware/session) to store the CSRF token within the session. This is generally more secure than the default configuration. +As a crucial second layer of defense, the middleware **always** performs `Origin` and `Referer` header checks for unsafe requests (when the connection is HTTPS). -```go -var ConfigDefault = Config{ - KeyLookup: "header:" + HeaderName, - CookieName: "__Host-csrf_", - CookieSameSite: "Lax", - CookieSecure: true, - CookieSessionOnly: true, - CookieHTTPOnly: true, - IdleTimeout: 30 * time.Minute, - KeyGenerator: utils.UUIDv4, - ErrorHandler: defaultErrorHandler, - Extractor: FromHeader(HeaderName), - Session: session.Store, -} -``` +- The request's `Origin` (for cross-origin requests) or `Referer` (for same-origin requests) header **must** match the application's `Host` header or be explicitly allowed in the `TrustedOrigins` list. +- This check is performed *in addition* to token validation and provides strong protection because these headers are reliably set by browsers and cannot be programmatically controlled by an attacker from a malicious site. -### Trusted Origins +## Token Extractors + +### Built-in Extractors + +**Secure (Recommended):** -The `TrustedOrigins` option is used to specify a list of trusted origins for unsafe requests. This is useful when you want to allow requests from other origins. This supports matching subdomains at any level. This means you can use a value like `"https://*.example.com"` to allow any subdomain of `example.com` to submit requests, including multiple subdomain levels such as `"https://sub.sub.example.com"`. +- `csrf.FromHeader("X-Csrf-Token")` - Most secure, preferred for APIs +- `csrf.FromForm("_csrf")` - Secure for form submissions -To ensure that the provided `TrustedOrigins` origins are correctly formatted, this middleware validates and normalizes them. It checks for valid schemes, i.e., HTTP or HTTPS, and it will automatically remove trailing slashes. If the provided origin is invalid, the middleware will panic. +**Acceptable:** -#### Example with Explicit Origins +- `csrf.FromQuery("csrf_token")` - URL parameters +- `csrf.FromParam("csrf")` - Route parameters -In the following example, the CSRF middleware will allow requests from `trusted.example.com`, in addition to the current host. +#### Using Route-Specific Extractors + +There are cases where you might want to use different extractors for different routes: ```go -app.Use(csrf.New(csrf.Config{ - TrustedOrigins: []string{"https://trusted.example.com"}, +// API routes - header only +api := app.Group("/api") +api.Use(csrf.New(csrf.Config{ + Extractor: csrf.FromHeader("X-Csrf-Token"), +})) + +// Form routes - form only +forms := app.Group("/forms") +forms.Use(csrf.New(csrf.Config{ + Extractor: csrf.FromForm("_csrf"), })) ``` -#### Example with Subdomain Matching +### Custom Extractor -In the following example, the CSRF middleware will allow requests from any subdomain of `example.com`, in addition to the current host. +You can create a custom extractor to handle specific cases: + +:::danger Never Extract from Cookies +**NEVER create custom extractors that read from cookies using the same `CookieName` as your CSRF configuration.** This completely defeats CSRF protection by making the extracted token always match the cookie value, allowing any CSRF attack to succeed. ```go +// ❌ NEVER DO THIS - Completely defeats CSRF protection +func BadExtractor(c fiber.Ctx) (string, error) { + return c.Cookies("csrf_"), nil // Always passes validation! +} + +// ✅ DO THIS - Extract from different source than cookie app.Use(csrf.New(csrf.Config{ - TrustedOrigins: []string{"https://*.example.com"}, + CookieName: "csrf_", + Extractor: csrf.FromHeader("X-Csrf-Token"), // Header vs cookie comparison })) ``` -:::caution -When using `TrustedOrigins` with subdomain matching, make sure you control and trust all the subdomains, including all subdomain levels. If not, an attacker could create a subdomain under a trusted origin and use it to send harmful requests. +The middleware uses the **Double Submit Cookie** pattern - it compares the extracted token against the cookie value. If your extractor reads from the same cookie, they will always match and provide zero CSRF protection. ::: -## Constants +#### Bearer Token Embedding ```go -const ( - HeaderName = "X-Csrf-Token" -) +// Extract CSRF token embedded in JWT Authorization header +// Useful for APIs that combine JWT auth with CSRF protection +func BearerTokenExtractor(c fiber.Ctx) (string, error) { + // Extract from "Authorization: Bearer :" + auth := c.Get("Authorization") + if !strings.HasPrefix(auth, "Bearer ") { + return "", csrf.ErrTokenNotFound + } + + parts := strings.SplitN(strings.TrimPrefix(auth, "Bearer "), ":", 2) + if len(parts) != 2 || parts[1] == "" { + return "", csrf.ErrTokenNotFound + } + + return parts[1], nil +} ``` -## Sentinel Errors +#### Chain Extractor (Advanced) -The CSRF middleware utilizes a set of sentinel errors to handle various scenarios and communicate errors effectively. These can be used within a [custom error handler](#custom-error-handler) to handle errors returned by the middleware. +For edge cases requiring multiple token sources, use the `Chain` extractor: -### Errors Returned to Error Handler +```go +// Only if you absolutely need multiple sources +app.Use(csrf.New(csrf.Config{ + Extractor: csrf.Chain( + csrf.FromHeader("X-Csrf-Token"), // Try header first + csrf.FromForm("_csrf"), // Fallback to form + ), +})) +``` -- `ErrTokenNotFound`: Indicates that the CSRF token was not found. -- `ErrTokenInvalid`: Indicates that the CSRF token is invalid. -- `ErrRefererNotFound`: Indicates that the referer was not supplied. -- `ErrRefererInvalid`: Indicates that the referer is invalid. -- `ErrRefererNoMatch`: Indicates that the referer does not match host and is not a trusted origin. -- `ErrOriginInvalid`: Indicates that the origin is invalid. -- `ErrOriginNoMatch`: Indicates that the origin does not match host and is not a trusted origin. +:::danger Security Risk +Chaining extractors increases attack surface and complexity. Most applications should use a single, appropriate extractor for their use case. +::: -If you use the default error handler, the client will receive a 403 Forbidden error without any additional information. +## Advanced Configuration -## Custom Error Handler +### Trusted Origins -You can use a custom error handler to handle errors returned by the CSRF middleware. The error handler is executed when an error is returned from the middleware. The error handler is passed the error returned from the middleware and the fiber.Ctx. +```go +app.Use(csrf.New(csrf.Config{ + TrustedOrigins: []string{ + "https://trusted.example.com", + "https://*.example.com", // Wildcard subdomains + }, +})) +``` -Example, returning a JSON response for API requests and rendering an error page for other requests: +### Custom Error Handler ```go app.Use(csrf.New(csrf.Config{ @@ -226,7 +339,7 @@ app.Use(csrf.New(csrf.Config{ })) ``` -## Custom Storage/Database +### Custom Storage/Database You can use any storage from our [storage](https://github.com/gofiber/storage/) package. @@ -237,95 +350,76 @@ app.Use(csrf.New(csrf.Config{ })) ``` -## How It Works - -### Token Generation - -CSRF tokens are generated on 'safe' requests and when the existing token has expired or hasn't been set yet. If `SingleUseToken` is `true`, a new token is generated after each use. Retrieve the CSRF token using `csrf.TokenFromContext(c)`. - -### Security Considerations - -This middleware is designed to protect against CSRF attacks but does not protect against other attack vectors, such as XSS. It should be used in combination with other security measures. - -:::danger -Never use 'safe' methods to mutate data, for example, never use a GET request to modify a resource. This middleware will not protect against CSRF attacks on 'safe' methods. -::: - -## Token Validation Patterns - -### Double Submit Cookie Pattern (Default) - -By default, the middleware generates and stores tokens using the `fiber.Storage` interface. These tokens are not linked to any particular user session, and they are validated using the Double Submit Cookie pattern. The token is stored in a cookie, and then sent as a header on requests. The middleware compares the cookie value with the header value to validate the token. This is a secure pattern that does not require a user session. - -When the authorization status changes, the previously issued token MUST be deleted, and a new one generated. See [Token Lifecycle](#token-lifecycle) [Deleting Tokens](#deleting-tokens) for more information. - -:::caution -When using this pattern, it's important to set the `CookieSameSite` option to `Lax` or `Strict` and ensure that the Extractor is not `FromCookie`, and KeyLookup is not `cookie:`. -::: - -:::note -When using this pattern, this middleware uses our [Storage](https://github.com/gofiber/storage) package to support various databases through a single interface. The default configuration for Storage saves data to memory. See [Custom Storage/Database](#custom-storagedatabase) for customizing the storage. -::: - -### Synchronizer Token Pattern (with Session) - -When using this middleware with a user session, the middleware can be configured to store the token within the session. This method is recommended when using a user session, as it is generally more secure than the Double Submit Cookie Pattern. - -When using this pattern it's important to regenerate the session when the authorization status changes, this will also delete the token. See: [Token Lifecycle](#token-lifecycle) for more information. - -:::caution -Pre-sessions are required and will be created automatically if not present. Use a session value to indicate authentication instead of relying on the presence of a session. -::: - -## Defense In Depth - -When using this middleware, it's recommended to serve your pages over HTTPS, set the `CookieSecure` option to `true`, and set the `CookieSameSite` option to `Lax` or `Strict`. This ensures that the cookie is only sent over HTTPS and not on requests from external sites. - -:::note -Cookie prefixes `__Host-` and `__Secure-` can be used to further secure the cookie. Note that these prefixes are not supported by all browsers and there are 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. +### Token Management -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. Even if a subdomain can set or modify cookies on your domain, it can't force a user to post to your application, since that request won't come from your own exact domain. - -:::caution -When HTTPS requests are protected by CSRF, referer checking is always carried out. - -The Referer header is automatically included in requests by all modern browsers, including those made using the JS Fetch API. However, if you're making use of this middleware with a custom client, it's important to ensure that the client sends a valid Referer header. -::: +```go +// Delete token (e.g., on logout) +handler := csrf.HandlerFromContext(c) +if handler != nil { + if err := handler.DeleteToken(c); err != nil { + // handle error, e.g. log it + } +} -## Token Lifecycle +// With session middleware +session.Destroy() // Also deletes CSRF token +``` -Tokens are valid until they expire or until they are deleted. By default, tokens are valid for 30 minutes, and each subsequent request extends the expiration by the idle timeout. The token only expires if the user doesn't make a request for the duration of the idle timeout. +## API Reference -### Token Reuse +```go +// Create middleware +func New(config ...csrf.Config) fiber.Handler -By default, tokens may be used multiple times. If you want to delete the token after it has been used, you can set the `SingleUseToken` option to `true`. This will delete the token after it has been used, and a new token will be generated on the next request. +// Get token from context +func TokenFromContext(c fiber.Ctx) string -:::info -Using `SingleUseToken` comes with usability trade-offs and is not enabled by default. For example, it can interfere with the user experience if the user has multiple tabs open or uses the back button. -::: +// Get handler from context +func HandlerFromContext(c fiber.Ctx) *csrf.Handler -### Deleting Tokens +// Delete token +func (h *csrf.Handler) DeleteToken(c fiber.Ctx) error +``` -When the authorization status changes, the CSRF token MUST be deleted, and a new one generated. This can be done by calling `handler.DeleteToken(c)`. +## Config Properties + +| Property | Type | Description | Default | +|:------------------|:-----------------------------------|:------------------------------------------------------------------------------------------------------------------------------|:-----------------------------| +| Next | `func(fiber.Ctx) bool` | Skip middleware when returns true | `nil` | +| CookieName | `string` | CSRF cookie name | `"csrf_"` | +| CookieDomain | `string` | CSRF cookie domain | `""` | +| CookiePath | `string` | CSRF cookie path | `""` | +| CookieSecure | `bool` | HTTPS only cookie (**required for production**) | `false` | +| CookieHTTPOnly | `bool` | Prevent JavaScript access (**use `false` for SPAs**) | `false` | +| CookieSameSite | `string` | SameSite attribute (**use "Lax" or "Strict"**) | `"Lax"` | +| CookieSessionOnly | `bool` | Session-only cookie (expires on browser close) | `false` | +| IdleTimeout | `time.Duration` | Token expiration time | `30 * time.Minute` | +| KeyGenerator | `func() string` | Token generation function | `utils.UUIDv4` | +| ErrorHandler | `fiber.ErrorHandler` | Custom error handler | `defaultErrorHandler` | +| Extractor | `func(fiber.Ctx) (string, error)` | Token extraction method | `FromHeader("X-Csrf-Token")` | +| Session | `*session.Store` | Session store (**recommended for production**) | `nil` | +| Storage | `fiber.Storage` | Token storage (overridden by Session) | `nil` | +| TrustedOrigins | `[]string` | Trusted origins for cross-origin requests | `[]` | +| SingleUseToken | `bool` | Generate new token after each use | `false` | + +## Error Types ```go -handler := csrf.HandlerFromContext(ctx) -if handler != nil { - if err := handler.DeleteToken(app.AcquireCtx(ctx)); err != nil { - // handle error - } -} +var ( + ErrTokenNotFound = errors.New("csrf: token not found") + ErrTokenInvalid = errors.New("csrf: token invalid") + ErrRefererNotFound = errors.New("csrf: referer header missing") + ErrRefererInvalid = errors.New("csrf: referer header invalid") + ErrRefererNoMatch = errors.New("csrf: referer does not match host or trusted origins") + ErrOriginInvalid = errors.New("csrf: origin header invalid") + ErrOriginNoMatch = errors.New("csrf: origin does not match host or trusted origins") +) ``` -:::tip -If you are using this middleware with the fiber session middleware, then you can simply call `session.Destroy()`, `session.Regenerate()`, or `session.Reset()` to delete the session and the token stored therein. -::: - -## BREACH +## Constants -It's important to note that the token is sent as a header on every request. If you include the token in a page that is vulnerable to [BREACH](https://en.wikipedia.org/wiki/BREACH), an attacker may be able to extract the token. To mitigate this, ensure your pages are served over HTTPS, disable HTTP compression, and implement rate limiting for requests. +```go +const ( + HeaderName = "X-Csrf-Token" +) +``` diff --git a/docs/whats_new.md b/docs/whats_new.md index a3f305e4279..f49ed1f8611 100644 --- a/docs/whats_new.md +++ b/docs/whats_new.md @@ -1885,6 +1885,42 @@ app.Use(csrf.New(csrf.Config{ - **Session Key Removal**: The `SessionKey` field has been removed from the CSRF middleware configuration. The session key is now an unexported constant within the middleware to avoid potential key collisions in the session store. +- **KeyLookup Field Removal**: The `KeyLookup` field has been removed from the CSRF middleware configuration. This field was deprecated and is no longer needed as the middleware now uses a more secure approach for token management. + +```go +// Before +app.Use(csrf.New(csrf.Config{ + KeyLookup: "header:X-CSRF-Token", + // other config... +})) + +// After - use Extractor instead +app.Use(csrf.New(csrf.Config{ + Extractor: csrf.FromHeader("X-CSRF-Token"), + // other config... +})) +``` + +- **FromCookie Extractor Removal**: The `csrf.FromCookie` extractor has been intentionally removed for security reasons. Using cookie-based extraction defeats the purpose of CSRF protection by making the extracted token always match the cookie value. + +```go +// Before - This was a security vulnerability +app.Use(csrf.New(csrf.Config{ + Extractor: csrf.FromCookie("csrf_token"), // ❌ Insecure! +})) + +// After - Use secure extractors instead +app.Use(csrf.New(csrf.Config{ + Extractor: csrf.FromHeader("X-Csrf-Token"), // ✅ Secure + // or + Extractor: csrf.FromForm("_csrf"), // ✅ Secure + // or + Extractor: csrf.FromQuery("csrf_token"), // ✅ Acceptable +})) +``` + +**Security Note**: The removal of `FromCookie` prevents a common misconfiguration that would completely bypass CSRF protection. The middleware uses the Double Submit Cookie pattern, which requires the token to be submitted through a different channel than the cookie to provide meaningful protection. + #### Filesystem You need to move filesystem middleware to static middleware due to it has been removed from the core. diff --git a/middleware/csrf/config.go b/middleware/csrf/config.go index e718b15874f..076429d1d0d 100644 --- a/middleware/csrf/config.go +++ b/middleware/csrf/config.go @@ -1,19 +1,16 @@ package csrf import ( - "net/textproto" - "strings" "time" "github.com/gofiber/fiber/v3" - "github.com/gofiber/fiber/v3/log" "github.com/gofiber/fiber/v3/middleware/session" "github.com/gofiber/utils/v2" ) -// Config defines the config for middleware. +// Config defines the config for CSRF middleware. type Config struct { - // Store is used to store the state of the middleware + // Storage is used to store the state of the middleware. // // Optional. Default: memory.New() // Ignored if Session is set. @@ -24,69 +21,61 @@ type Config struct { // Optional. Default: nil Next func(c fiber.Ctx) bool - // Session is used to store the state of the middleware + // Session is used to store the state of the middleware. // // Optional. Default: nil - // If set, the middleware will use the session store instead of the storage + // If set, the middleware will use the session store instead of the storage. Session *session.Store - // KeyGenerator creates a new CSRF token + // KeyGenerator creates a new CSRF token. // - // Optional. Default: utils.UUID + // Optional. Default: utils.UUIDv4 KeyGenerator func() string // ErrorHandler is executed when an error is returned from fiber.Handler. // - // Optional. Default: DefaultErrorHandler + // Optional. Default: defaultErrorHandler ErrorHandler fiber.ErrorHandler - // Extractor returns the csrf token + // Extractor returns the CSRF token from the request. // - // If set this will be used in place of an Extractor based on KeyLookup. + // Optional. Default: FromHeader("X-Csrf-Token") // - // Optional. Default will create an Extractor based on KeyLookup. + // Available extractors: FromHeader, FromQuery, FromParam, FromForm + // + // WARNING: Never create custom extractors that read from cookies with the same + // CookieName as this defeats CSRF protection entirely. Extractor func(c fiber.Ctx) (string, error) - // KeyLookup is a string in the form of ":" that is used - // to create an Extractor that extracts the token from the request. - // Possible values: - // - "header:" - // - "query:" - // - "param:" - // - "form:" - // - "cookie:" - // - // Ignored if an Extractor is explicitly set. + // CookieName is the name of the CSRF cookie. // - // Optional. Default: "header:X-Csrf-Token" - KeyLookup string - - // Name of the session cookie. This cookie will store session key. - // Optional. Default value "csrf_". - // Overridden if KeyLookup == "cookie:" + // Optional. Default: "csrf_" CookieName string - // Domain of the CSRF cookie. - // Optional. Default value "". + // CookieDomain is the domain of the CSRF cookie. + // + // Optional. Default: "" CookieDomain string - // Path of the CSRF cookie. - // Optional. Default value "". + // CookiePath is the path of the CSRF cookie. + // + // Optional. Default: "" CookiePath string - // Value of SameSite cookie. - // Optional. Default value "Lax". + // CookieSameSite is the SameSite attribute of the CSRF cookie. + // + // Optional. Default: "Lax" CookieSameSite string // TrustedOrigins is a list of trusted origins for unsafe requests. // For requests that use the Origin header, the origin must match the // Host header or one of the TrustedOrigins. - // For secure requests, that do not include the Origin header, the Referer + // For secure requests that do not include the Origin header, the Referer // header must match the Host header or one of the TrustedOrigins. // // This supports matching subdomains at any level. This means you can use a value like - // `"https://*.example.com"` to allow any subdomain of `example.com` to submit requests, - // including multiple subdomain levels such as `"https://sub.sub.example.com"`. + // "https://*.example.com" to allow any subdomain of example.com to submit requests, + // including multiple subdomain levels such as "https://sub.sub.example.com". // // Optional. Default: [] TrustedOrigins []string @@ -96,30 +85,34 @@ type Config struct { // Optional. Default: 30 * time.Minute IdleTimeout time.Duration - // Indicates if CSRF cookie is secure. - // Optional. Default value false. + // CookieSecure indicates if CSRF cookie is secure. + // + // Optional. Default: false CookieSecure bool - // Indicates if CSRF cookie is HTTP only. - // Optional. Default value false. + // CookieHTTPOnly indicates if CSRF cookie is HTTP only. + // + // Optional. Default: false CookieHTTPOnly bool - // Decides whether cookie should last for only the browser sesison. - // Ignores Expiration if set to true + // CookieSessionOnly decides whether cookie should last for only the browser session. + // Ignores Expiration if set to true. + // + // Optional. Default: false CookieSessionOnly bool - // SingleUseToken indicates if the CSRF token be destroyed + // SingleUseToken indicates if the CSRF token should be destroyed // and a new one generated on each use. // // Optional. Default: false SingleUseToken bool } +// HeaderName is the default header name for CSRF tokens. const HeaderName = "X-Csrf-Token" -// ConfigDefault is the default config +// ConfigDefault is the default config for CSRF middleware. var ConfigDefault = Config{ - KeyLookup: "header:" + HeaderName, CookieName: "csrf_", CookieSameSite: "Lax", IdleTimeout: 30 * time.Minute, @@ -128,12 +121,12 @@ var ConfigDefault = Config{ Extractor: FromHeader(HeaderName), } -// default ErrorHandler that process return error from fiber.Handler +// defaultErrorHandler is the default error handler that processes errors from fiber.Handler. func defaultErrorHandler(_ fiber.Ctx, _ error) error { return fiber.ErrForbidden } -// Helper function to set default values +// configDefault is a helper function to set default values. func configDefault(config ...Config) Config { // Return default config if nothing provided if len(config) < 1 { @@ -144,9 +137,6 @@ func configDefault(config ...Config) Config { cfg := config[0] // Set default values - if cfg.KeyLookup == "" { - cfg.KeyLookup = ConfigDefault.KeyLookup - } if cfg.IdleTimeout <= 0 { cfg.IdleTimeout = ConfigDefault.IdleTimeout } @@ -162,36 +152,8 @@ func configDefault(config ...Config) Config { if cfg.ErrorHandler == nil { cfg.ErrorHandler = ConfigDefault.ErrorHandler } - - // Generate the correct extractor to get the token from the correct location - selectors := strings.Split(cfg.KeyLookup, ":") - - const numParts = 2 - if len(selectors) != numParts { - panic("[CSRF] KeyLookup must in the form of :") - } - if cfg.Extractor == nil { - // By default we extract from a header - cfg.Extractor = FromHeader(textproto.CanonicalMIMEHeaderKey(selectors[1])) - - switch selectors[0] { - case "form": - cfg.Extractor = FromForm(selectors[1]) - case "query": - cfg.Extractor = FromQuery(selectors[1]) - case "param": - cfg.Extractor = FromParam(selectors[1]) - case "cookie": - if cfg.Session == nil { - log.Warn("[CSRF] Cookie extractor is not recommended without a session store") - } - if cfg.CookieSameSite == "None" || cfg.CookieSameSite != "Lax" && cfg.CookieSameSite != "Strict" { - log.Warn("[CSRF] Cookie extractor is only recommended for use with SameSite=Lax or SameSite=Strict") - } - cfg.Extractor = FromCookie(selectors[1]) - cfg.CookieName = selectors[1] // Cookie name is the same as the key - } + cfg.Extractor = ConfigDefault.Extractor } return cfg diff --git a/middleware/csrf/csrf.go b/middleware/csrf/csrf.go index 090cf666a6d..09c6de90980 100644 --- a/middleware/csrf/csrf.go +++ b/middleware/csrf/csrf.go @@ -3,7 +3,6 @@ package csrf import ( "errors" "net/url" - "reflect" "slices" "strings" "time" @@ -21,7 +20,8 @@ var ( ErrOriginInvalid = errors.New("origin invalid") ErrOriginNoMatch = errors.New("origin does not match host and is not a trusted origin") errOriginNotFound = errors.New("origin not supplied or is null") // internal error, will not be returned to the user - dummyValue = []byte{'+'} + dummyValue = []byte{'+'} // dummyValue is a placeholder value stored in token storage. The actual token validation relies on the key, not this value. + ) // Handler for CSRF middleware @@ -130,7 +130,7 @@ func New(config ...Config) fiber.Handler { return cfg.ErrorHandler(c, err) } - // Extract token from client request i.e. header, query, param, form or cookie + // Extract token from client request i.e. header, query, param, form extractedToken, err := cfg.Extractor(c) if err != nil { return cfg.ErrorHandler(c, err) @@ -140,10 +140,11 @@ func New(config ...Config) fiber.Handler { return cfg.ErrorHandler(c, ErrTokenNotFound) } - // If not using FromCookie 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 !isFromCookie(cfg.Extractor) && !compareStrings(extractedToken, c.Cookies(cfg.CookieName)) { + // Double Submit Cookie validation: ensure the extracted token matches the cookie value + // This prevents CSRF attacks by requiring attackers to know both the cookie AND submit + // the same token through a different channel (header, form, etc.) + // WARNING: If using a custom extractor that reads from the same cookie, this provides no protection + if !compareStrings(extractedToken, c.Cookies(cfg.CookieName)) { return cfg.ErrorHandler(c, ErrTokenInvalid) } @@ -274,11 +275,6 @@ func (handler *Handler) DeleteToken(c fiber.Ctx) error { return nil } -// isFromCookie checks if the extractor is set to ExtractFromCookie -func isFromCookie(extractor any) bool { - return reflect.ValueOf(extractor).Pointer() == reflect.ValueOf(FromCookie).Pointer() -} - // originMatchesHost checks that the origin header matches the host header // returns an error if the origin header is not present or is invalid // returns nil if the origin header is valid diff --git a/middleware/csrf/csrf_test.go b/middleware/csrf/csrf_test.go index 142bdadd295..8903da85e90 100644 --- a/middleware/csrf/csrf_test.go +++ b/middleware/csrf/csrf_test.go @@ -1,17 +1,14 @@ package csrf import ( - "bytes" "context" "net/http" "net/http/httptest" - "os" "strings" "testing" "time" "github.com/gofiber/fiber/v3" - "github.com/gofiber/fiber/v3/log" "github.com/gofiber/fiber/v3/middleware/session" "github.com/gofiber/utils/v2" "github.com/stretchr/testify/require" @@ -355,7 +352,7 @@ func Test_CSRF_MultiUseToken(t *testing.T) { app := fiber.New() app.Use(New(Config{ - KeyLookup: "header:X-Csrf-Token", + Extractor: FromHeader("X-Csrf-Token"), })) app.Post("/", func(c fiber.Ctx) error { @@ -454,30 +451,11 @@ func Test_CSRF_Next(t *testing.T) { require.Equal(t, fiber.StatusNotFound, resp.StatusCode) } -func Test_CSRF_Invalid_KeyLookup(t *testing.T) { - t.Parallel() - defer func() { - require.Equal(t, "[CSRF] KeyLookup must in the form of :", recover()) - }() - app := fiber.New() - - app.Use(New(Config{KeyLookup: "I:am:invalid"})) - - app.Post("/", func(c fiber.Ctx) error { - return c.SendStatus(fiber.StatusOK) - }) - - h := app.Handler() - ctx := &fasthttp.RequestCtx{} - ctx.Request.Header.SetMethod(fiber.MethodGet) - h(ctx) -} - func Test_CSRF_From_Form(t *testing.T) { t.Parallel() app := fiber.New() - app.Use(New(Config{KeyLookup: "form:_csrf"})) + app.Use(New(Config{Extractor: FromForm("_csrf")})) app.Post("/", func(c fiber.Ctx) error { return c.SendStatus(fiber.StatusOK) @@ -514,7 +492,7 @@ func Test_CSRF_From_Query(t *testing.T) { t.Parallel() app := fiber.New() - app.Use(New(Config{KeyLookup: "query:_csrf"})) + app.Use(New(Config{Extractor: FromQuery("_csrf")})) app.Post("/", func(c fiber.Ctx) error { return c.SendStatus(fiber.StatusOK) @@ -552,7 +530,7 @@ func Test_CSRF_From_Param(t *testing.T) { t.Parallel() app := fiber.New() - csrfGroup := app.Group("/:csrf", New(Config{KeyLookup: "param:csrf"})) + csrfGroup := app.Group("/:csrf", New(Config{Extractor: FromParam("csrf")})) csrfGroup.Post("/", func(c fiber.Ctx) error { return c.SendStatus(fiber.StatusOK) @@ -586,45 +564,6 @@ func Test_CSRF_From_Param(t *testing.T) { require.Equal(t, "OK", string(ctx.Response.Body())) } -func Test_CSRF_From_Cookie(t *testing.T) { - t.Parallel() - app := fiber.New() - - csrfGroup := app.Group("/", New(Config{KeyLookup: "cookie:csrf"})) - - csrfGroup.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.SetRequestURI("/") - ctx.Request.Header.Set(fiber.HeaderCookie, "csrf="+utils.UUIDv4()+";") - h(ctx) - require.Equal(t, 403, ctx.Response.StatusCode()) - - // Generate CSRF token - ctx.Request.Reset() - ctx.Response.Reset() - ctx.Request.Header.SetMethod(fiber.MethodGet) - ctx.Request.SetRequestURI("/") - 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(fiber.HeaderCookie, "csrf="+token+";") - ctx.Request.SetRequestURI("/") - h(ctx) - require.Equal(t, 200, ctx.Response.StatusCode()) - require.Equal(t, "OK", string(ctx.Response.Body())) -} - func Test_CSRF_From_Custom(t *testing.T) { t.Parallel() app := fiber.New() @@ -1599,26 +1538,12 @@ func Test_CSRF_FromContextMethods_Invalid(t *testing.T) { require.Equal(t, fiber.StatusOK, resp.StatusCode) } -func Test_configDefault_WarnCookieSameSite(t *testing.T) { - var buf bytes.Buffer - log.SetOutput(&buf) - t.Cleanup(func() { log.SetOutput(os.Stderr) }) - - cfg := configDefault(Config{ - KeyLookup: "cookie:csrf", - CookieSameSite: "None", - }) - - require.Equal(t, "csrf", cfg.CookieName) - require.Contains(t, buf.String(), "Cookie extractor is only recommended for use with SameSite=Lax or SameSite=Strict") -} - func Test_deleteTokenFromStorage(t *testing.T) { t.Parallel() app := fiber.New() ctx := app.AcquireCtx(&fasthttp.RequestCtx{}) - defer app.ReleaseCtx(ctx) + t.Cleanup(func() { app.ReleaseCtx(ctx) }) token := "token123" dummy := []byte("dummy") @@ -1638,3 +1563,405 @@ func Test_deleteTokenFromStorage(t *testing.T) { deleteTokenFromStorage(ctx, token, Config{}, sm2, stm2) require.Nil(t, stm2.getRaw(context.Background(), token)) } + +func Test_CSRF_Chain_Extractor(t *testing.T) { + t.Parallel() + app := fiber.New() + + // Chain extractor: try header first, fallback to form + chainExtractor := Chain( + FromHeader("X-Csrf-Token"), + FromForm("_csrf"), + ) + + app.Use(New(Config{Extractor: chainExtractor})) + + 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] + + // Test 1: Token in header (first extractor should succeed) + 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) + require.Equal(t, 200, ctx.Response.StatusCode()) + + // Test 2: Token in form (fallback should succeed) + 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) + ctx.Request.Header.SetCookie(ConfigDefault.CookieName, token) + h(ctx) + require.Equal(t, 200, ctx.Response.StatusCode()) + + // Test 3: Token in both header and form (header should take precedence) + ctx.Request.Reset() + ctx.Response.Reset() + ctx.Request.Header.SetMethod(fiber.MethodPost) + ctx.Request.Header.Set(fiber.HeaderContentType, fiber.MIMEApplicationForm) + ctx.Request.Header.Set("X-Csrf-Token", token) + ctx.Request.SetBodyString("_csrf=wrong_token") + ctx.Request.Header.SetCookie(ConfigDefault.CookieName, token) + h(ctx) + require.Equal(t, 200, ctx.Response.StatusCode()) + + // Test 4: No token in either location + ctx.Request.Reset() + ctx.Response.Reset() + ctx.Request.Header.SetMethod(fiber.MethodPost) + ctx.Request.Header.Set(fiber.HeaderContentType, fiber.MIMEApplicationForm) + h(ctx) + require.Equal(t, 403, ctx.Response.StatusCode()) + + // Test 5: Wrong token in both locations + ctx.Request.Reset() + ctx.Response.Reset() + ctx.Request.Header.SetMethod(fiber.MethodPost) + ctx.Request.Header.Set(fiber.HeaderContentType, fiber.MIMEApplicationForm) + ctx.Request.Header.Set("X-Csrf-Token", "wrong_token") + ctx.Request.SetBodyString("_csrf=also_wrong") + ctx.Request.Header.SetCookie(ConfigDefault.CookieName, token) + h(ctx) + require.Equal(t, 403, ctx.Response.StatusCode()) +} + +func Test_CSRF_Chain_Extractor_Empty(t *testing.T) { + t.Parallel() + app := fiber.New() + + // Empty chain extractor + emptyChain := Chain() + + app.Use(New(Config{Extractor: emptyChain})) + + 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] + + // Test with empty chain - should always fail + 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) + require.Equal(t, 403, ctx.Response.StatusCode()) +} + +func Test_CSRF_Chain_Extractor_SingleExtractor(t *testing.T) { + t.Parallel() + app := fiber.New() + + // Chain with single extractor (should behave like the single extractor) + singleChain := Chain(FromHeader("X-Csrf-Token")) + + app.Use(New(Config{Extractor: singleChain})) + + 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] + + // Test valid token in header + 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) + require.Equal(t, 200, ctx.Response.StatusCode()) + + // Test no token + ctx.Request.Reset() + ctx.Response.Reset() + ctx.Request.Header.SetMethod(fiber.MethodPost) + ctx.Request.Header.SetCookie(ConfigDefault.CookieName, token) + h(ctx) + require.Equal(t, 403, ctx.Response.StatusCode()) +} + +func Test_CSRF_All_Extractors(t *testing.T) { + t.Parallel() + + testCases := []struct { + extractor func(c fiber.Ctx) (string, error) + setupRequest func(ctx *fasthttp.RequestCtx, token string) + name string + expectStatus int + }{ + { + name: "FromHeader", + extractor: FromHeader("X-Csrf-Token"), + setupRequest: func(ctx *fasthttp.RequestCtx, token string) { + ctx.Request.Header.SetMethod(fiber.MethodPost) + ctx.Request.Header.Set("X-Csrf-Token", token) + ctx.Request.Header.SetCookie(ConfigDefault.CookieName, token) + }, + expectStatus: 200, + }, + { + name: "FromHeader_Missing", + extractor: FromHeader("X-Csrf-Token"), + setupRequest: func(ctx *fasthttp.RequestCtx, token string) { + ctx.Request.Header.SetMethod(fiber.MethodPost) + ctx.Request.Header.SetCookie(ConfigDefault.CookieName, token) + }, + expectStatus: 403, + }, + { + name: "FromForm", + extractor: FromForm("_csrf"), + setupRequest: func(ctx *fasthttp.RequestCtx, token string) { + ctx.Request.Header.SetMethod(fiber.MethodPost) + ctx.Request.Header.Set(fiber.HeaderContentType, fiber.MIMEApplicationForm) + ctx.Request.SetBodyString("_csrf=" + token) + ctx.Request.Header.SetCookie(ConfigDefault.CookieName, token) + }, + expectStatus: 200, + }, + { + name: "FromForm_Missing", + extractor: FromForm("_csrf"), + setupRequest: func(ctx *fasthttp.RequestCtx, token string) { + ctx.Request.Header.SetMethod(fiber.MethodPost) + ctx.Request.Header.Set(fiber.HeaderContentType, fiber.MIMEApplicationForm) + ctx.Request.Header.SetCookie(ConfigDefault.CookieName, token) + }, + expectStatus: 403, + }, + { + name: "FromQuery", + extractor: FromQuery("csrf_token"), + setupRequest: func(ctx *fasthttp.RequestCtx, token string) { + ctx.Request.Header.SetMethod(fiber.MethodPost) + ctx.Request.SetRequestURI("/?csrf_token=" + token) + ctx.Request.Header.SetCookie(ConfigDefault.CookieName, token) + }, + expectStatus: 200, + }, + { + name: "FromQuery_Missing", + extractor: FromQuery("csrf_token"), + setupRequest: func(ctx *fasthttp.RequestCtx, token string) { + ctx.Request.Header.SetMethod(fiber.MethodPost) + ctx.Request.SetRequestURI("/") + ctx.Request.Header.SetCookie(ConfigDefault.CookieName, token) + }, + expectStatus: 403, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + app := fiber.New() + + app.Use(New(Config{Extractor: tc.extractor})) + 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) + ctx.Request.SetRequestURI("/") + h(ctx) + token := string(ctx.Response.Header.Peek(fiber.HeaderSetCookie)) + token = strings.Split(strings.Split(token, ";")[0], "=")[1] + + // Test the extractor + ctx.Request.Reset() + ctx.Response.Reset() + tc.setupRequest(ctx, token) + h(ctx) + require.Equal(t, tc.expectStatus, ctx.Response.StatusCode(), + "Test case %s failed: expected %d, got %d", tc.name, tc.expectStatus, ctx.Response.StatusCode()) + }) + } +} + +func Test_CSRF_Param_Extractor(t *testing.T) { + t.Parallel() + + testCases := []struct { + setupRequest func(ctx *fasthttp.RequestCtx, token string) + name string + expectStatus int + }{ + { + name: "FromParam_Valid", + setupRequest: func(ctx *fasthttp.RequestCtx, token string) { + ctx.Request.Header.SetMethod(fiber.MethodPost) + ctx.Request.SetRequestURI("/" + token) + ctx.Request.Header.SetCookie(ConfigDefault.CookieName, token) + }, + expectStatus: 200, + }, + { + name: "FromParam_Invalid", + setupRequest: func(ctx *fasthttp.RequestCtx, token string) { + ctx.Request.Header.SetMethod(fiber.MethodPost) + ctx.Request.SetRequestURI("/wrong_token") + ctx.Request.Header.SetCookie(ConfigDefault.CookieName, token) + }, + expectStatus: 403, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + app := fiber.New() + + // Only use param-based routing for param extractor tests + csrfGroup := app.Group("/:csrf", New(Config{Extractor: FromParam("csrf")})) + csrfGroup.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) + ctx.Request.SetRequestURI("/" + utils.UUIDv4()) + h(ctx) + token := string(ctx.Response.Header.Peek(fiber.HeaderSetCookie)) + token = strings.Split(strings.Split(token, ";")[0], "=")[1] + + // Test the extractor + ctx.Request.Reset() + ctx.Response.Reset() + tc.setupRequest(ctx, token) + h(ctx) + require.Equal(t, tc.expectStatus, ctx.Response.StatusCode(), + "Test case %s failed: expected %d, got %d", tc.name, tc.expectStatus, ctx.Response.StatusCode()) + }) + } +} + +func Test_CSRF_Param_Extractor_Missing(t *testing.T) { + t.Parallel() + + // Test the case where no param is provided (should get 403 from CSRF middleware on the catch-all route) + app := fiber.New() + + // Add a catch-all route with CSRF middleware for missing param case + app.Use(New(Config{Extractor: FromParam("csrf")})) + 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) + ctx.Request.SetRequestURI("/") + h(ctx) + token := string(ctx.Response.Header.Peek(fiber.HeaderSetCookie)) + token = strings.Split(strings.Split(token, ";")[0], "=")[1] + + // Test missing param (accessing "/" instead of "/:csrf") + ctx.Request.Reset() + ctx.Response.Reset() + ctx.Request.Header.SetMethod(fiber.MethodPost) + ctx.Request.SetRequestURI("/") + ctx.Request.Header.SetCookie(ConfigDefault.CookieName, token) + h(ctx) + require.Equal(t, 403, ctx.Response.StatusCode(), "Missing param should return 403") +} + +func Test_CSRF_Extractors_ErrorTypes(t *testing.T) { + t.Parallel() + + // Test all extractor error types + testCases := []struct { + expected error + extractor func(c fiber.Ctx) (string, error) + setupCtx func(ctx *fasthttp.RequestCtx) // Add setup function + name string + }{ + { + name: "Missing header", + extractor: FromHeader("X-Missing-Header"), + expected: ErrMissingHeader, + setupCtx: func(_ *fasthttp.RequestCtx) {}, // No setup needed for headers + }, + { + name: "Missing query", + extractor: FromQuery("missing_param"), + expected: ErrMissingQuery, + setupCtx: func(ctx *fasthttp.RequestCtx) { + ctx.Request.SetRequestURI("/") // Set URI for query parsing + }, + }, + { + name: "Missing param", + extractor: FromParam("missing_param"), + expected: ErrMissingParam, + setupCtx: func(_ *fasthttp.RequestCtx) {}, // Params are handled by router + }, + { + name: "Missing form", + extractor: FromForm("missing_field"), + expected: ErrMissingForm, + setupCtx: func(ctx *fasthttp.RequestCtx) { + // Properly initialize request for form parsing + ctx.Request.Header.SetMethod(fiber.MethodPost) + ctx.Request.Header.SetContentType(fiber.MIMEApplicationForm) + ctx.Request.SetBodyString("") // Empty form body + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + app := fiber.New() + requestCtx := &fasthttp.RequestCtx{} + tc.setupCtx(requestCtx) // Setup the context properly + + ctx := app.AcquireCtx(requestCtx) + defer app.ReleaseCtx(ctx) + + token, err := tc.extractor(ctx) + require.Empty(t, token) + require.Equal(t, tc.expected, err) + }) + } +} diff --git a/middleware/csrf/extractors.go b/middleware/csrf/extractors.go index 8e4a119f9eb..a6bed5fe1a6 100644 --- a/middleware/csrf/extractors.go +++ b/middleware/csrf/extractors.go @@ -14,6 +14,9 @@ var ( ErrMissingCookie = errors.New("missing csrf token in cookie") ) +// Note: FromCookie is intentionally omitted as it would defeat CSRF protection. +// See documentation for security implications of cookie-based extraction. + // FromParam returns a function that extracts token from the url param string. func FromParam(param string) func(c fiber.Ctx) (string, error) { return func(c fiber.Ctx) (string, error) { @@ -36,17 +39,6 @@ func FromForm(param string) func(c fiber.Ctx) (string, error) { } } -// FromCookie returns a function that extracts token from the cookie header. -func FromCookie(param string) func(c fiber.Ctx) (string, error) { - return func(c fiber.Ctx) (string, error) { - token := c.Cookies(param) - if token == "" { - return "", ErrMissingCookie - } - return token, nil - } -} - // FromHeader returns a function that extracts token from the request header. func FromHeader(param string) func(c fiber.Ctx) (string, error) { return func(c fiber.Ctx) (string, error) { @@ -68,3 +60,35 @@ func FromQuery(param string) func(c fiber.Ctx) (string, error) { return token, nil } } + +// Chain tries multiple extractors in order until one succeeds. +// Returns the first successful extraction or the last error encountered. +func Chain(extractors ...func(fiber.Ctx) (string, error)) func(fiber.Ctx) (string, error) { + if len(extractors) == 0 { + return func(fiber.Ctx) (string, error) { + return "", ErrTokenNotFound + } + } + + return func(c fiber.Ctx) (string, error) { + var lastErr error + + for _, extractor := range extractors { + token, err := extractor(c) + + if err == nil && token != "" { + return token, nil + } + + // Only update lastErr if we got an actual error + if err != nil { + lastErr = err + } + } + + if lastErr != nil { + return "", lastErr + } + return "", ErrTokenNotFound + } +} diff --git a/middleware/csrf/extractors_test.go b/middleware/csrf/extractors_test.go index 4dc75854add..24ff3a88238 100644 --- a/middleware/csrf/extractors_test.go +++ b/middleware/csrf/extractors_test.go @@ -1,6 +1,8 @@ package csrf import ( + "context" + "net/http" "testing" "github.com/gofiber/fiber/v3" @@ -13,26 +15,131 @@ func Test_Extractors_Missing(t *testing.T) { t.Parallel() app := fiber.New() + // Add a route to test the missing param + app.Get("/test", func(c fiber.Ctx) error { + token, err := FromParam("csrf")(c) + require.Empty(t, token) + require.Equal(t, ErrMissingParam, err) + return nil + }) + _, err := app.Test(newRequest(fiber.MethodGet, "/test")) + require.NoError(t, err) + ctx := app.AcquireCtx(&fasthttp.RequestCtx{}) defer app.ReleaseCtx(ctx) - // Missing param - token, err := FromParam("csrf")(ctx) + // Missing form + token, err := FromForm("csrf")(ctx) require.Empty(t, token) - require.Equal(t, ErrMissingParam, err) + require.Equal(t, ErrMissingForm, err) - // Missing cookie - token, err = FromCookie("csrf")(ctx) + // Missing query + token, err = FromQuery("csrf")(ctx) require.Empty(t, token) - require.Equal(t, ErrMissingCookie, err) + require.Equal(t, ErrMissingQuery, err) - // Missing form - token, err = FromForm("csrf")(ctx) + // Missing header + token, err = FromHeader("X-CSRF-Token")(ctx) require.Empty(t, token) - require.Equal(t, ErrMissingForm, err) + require.Equal(t, ErrMissingHeader, err) +} - // Missing query +// newRequest creates a new *http.Request for Fiber's app.Test +func newRequest(method, target string) *http.Request { + req, err := http.NewRequestWithContext(context.Background(), method, target, nil) + if err != nil { + panic(err) + } + return req +} + +// go test -run Test_Extractors +func Test_Extractors(t *testing.T) { + t.Parallel() + + app := fiber.New() + + // FromParam + app.Get("/test/:csrf", func(c fiber.Ctx) error { + token, err := FromParam("csrf")(c) + require.NoError(t, err) + require.Equal(t, "token_from_param", token) + return nil + }) + _, err := app.Test(newRequest(fiber.MethodGet, "/test/token_from_param")) + require.NoError(t, err) + + // FromForm + ctx := app.AcquireCtx(&fasthttp.RequestCtx{}) + defer app.ReleaseCtx(ctx) + ctx.Request().Header.SetContentType(fiber.MIMEApplicationForm) + ctx.Request().SetBodyString("csrf=token_from_form") + token, err := FromForm("csrf")(ctx) + require.NoError(t, err) + require.Equal(t, "token_from_form", token) + + // FromQuery + ctx = app.AcquireCtx(&fasthttp.RequestCtx{}) + defer app.ReleaseCtx(ctx) + ctx.Request().SetRequestURI("/?csrf=token_from_query") token, err = FromQuery("csrf")(ctx) + require.NoError(t, err) + require.Equal(t, "token_from_query", token) + + // FromHeader + ctx = app.AcquireCtx(&fasthttp.RequestCtx{}) + defer app.ReleaseCtx(ctx) + ctx.Request().Header.Set("X-CSRF-Token", "token_from_header") + token, err = FromHeader("X-CSRF-Token")(ctx) + require.NoError(t, err) + require.Equal(t, "token_from_header", token) +} + +// go test -run Test_Extractor_Chain +func Test_Extractor_Chain(t *testing.T) { + t.Parallel() + + app := fiber.New() + + // No extractors + ctx := app.AcquireCtx(&fasthttp.RequestCtx{}) + defer app.ReleaseCtx(ctx) + token, err := Chain()(ctx) + require.Empty(t, token) + require.Equal(t, ErrTokenNotFound, err) + + // First extractor succeeds + ctx = app.AcquireCtx(&fasthttp.RequestCtx{}) + defer app.ReleaseCtx(ctx) + ctx.Request().Header.Set("X-CSRF-Token", "token_from_header") + ctx.Request().SetRequestURI("/?csrf=token_from_query") + token, err = Chain(FromHeader("X-CSRF-Token"), FromQuery("csrf"))(ctx) + require.NoError(t, err) + require.Equal(t, "token_from_header", token) + + // Second extractor succeeds + ctx = app.AcquireCtx(&fasthttp.RequestCtx{}) + defer app.ReleaseCtx(ctx) + ctx.Request().SetRequestURI("/?csrf=token_from_query") + token, err = Chain(FromHeader("X-CSRF-Token"), FromQuery("csrf"))(ctx) + require.NoError(t, err) + require.Equal(t, "token_from_query", token) + + // All extractors fail, should return the last error + ctx = app.AcquireCtx(&fasthttp.RequestCtx{}) + defer app.ReleaseCtx(ctx) + token, err = Chain(FromHeader("X-CSRF-Token"), FromQuery("csrf"))(ctx) require.Empty(t, token) require.Equal(t, ErrMissingQuery, err) + + // All extractors find nothing (return empty string and nil error), should return ErrTokenNotFound + ctx = app.AcquireCtx(&fasthttp.RequestCtx{}) + defer app.ReleaseCtx(ctx) + // This extractor will return "", nil + dummyExtractor := func(_ fiber.Ctx) (string, error) { + return "", nil + } + token, err = Chain(dummyExtractor)(ctx) + require.Empty(t, token) + require.Equal(t, ErrTokenNotFound, err) } diff --git a/middleware/csrf/token.go b/middleware/csrf/token.go index b96b013a800..4a58b0cde42 100644 --- a/middleware/csrf/token.go +++ b/middleware/csrf/token.go @@ -4,6 +4,8 @@ import ( "time" ) +// Token represents a CSRF token with expiration metadata. +// This is used internally for token storage and validation. type Token struct { Expiration time.Time `json:"expiration"` Key string `json:"key"`