Skip to content

Commit 1d9eca3

Browse files
authored
feat(csrf): Enhance extractor functionality with metadata and security validation (#3630)
1 parent 2a01f97 commit 1d9eca3

File tree

7 files changed

+831
-120
lines changed

7 files changed

+831
-120
lines changed

docs/middleware/csrf.md

Lines changed: 133 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -216,15 +216,58 @@ As a crucial second layer of defense, the middleware **always** performs `Origin
216216

217217
### Built-in Extractors
218218

219-
**Secure (Recommended):**
219+
**Most Secure (Recommended):**
220220

221-
- `csrf.FromHeader("X-Csrf-Token")` - Most secure, preferred for APIs
222-
- `csrf.FromForm("_csrf")` - Secure for form submissions
221+
- `csrf.FromHeader("X-Csrf-Token")` - Headers are not logged and cannot be manipulated via URL
222+
- `csrf.FromForm("_csrf")` - Form data is secure and not typically logged
223223

224-
**Acceptable:**
224+
**Less Secure (Use with caution):**
225225

226-
- `csrf.FromQuery("csrf_token")` - URL parameters
227-
- `csrf.FromParam("csrf")` - Route parameters
226+
- `csrf.FromQuery("csrf_token")` - URLs may be logged by servers, proxies, browsers
227+
- `csrf.FromParam("csrf")` - URLs may be logged by servers, proxies, browsers
228+
229+
**Advanced:**
230+
231+
- `csrf.Chain(...)` - Try multiple extractors in sequence
232+
233+
:::note What about cookies?
234+
**Cookies are generally not a secure source for CSRF tokens.** The middleware does not provide a built-in cookie extractor because reading the CSRF token from a cookie with the same name as the CSRF cookie defeats CSRF protection.
235+
236+
**Advanced usage:**
237+
In rare cases, you may securely extract a CSRF token from a cookie if:
238+
239+
- You read from a different cookie (not the CSRF cookie itself)
240+
- You use multiple cookies for custom validation
241+
- You implement custom logic across different cookie sources
242+
243+
If you do this, set the extractor’s `Source` to `SourceCookie` and allow the middleware to check that the cookie name is different from your CSRF cookie. It will panic if this is the case.
244+
245+
**Warning:**
246+
We strongly discourage cookie-based extraction, as it is easy to misconfigure and creates security risks. Prefer extracting tokens from headers or form fields for robust CSRF protection.
247+
:::
248+
249+
### Extractor Metadata
250+
251+
Each extractor returns an `Extractor` struct with metadata about its behavior:
252+
253+
```go
254+
extractor := csrf.FromHeader("X-Csrf-Token")
255+
fmt.Printf("Source: %v, Key: %s", extractor.Source, extractor.Key)
256+
// Output: Source: 0, Key: X-Csrf-Token
257+
258+
// Available source types:
259+
// - csrf.SourceHeader (0): Most secure, not logged
260+
// - csrf.SourceForm (1): Secure, not typically logged
261+
// - csrf.SourceQuery (2): Less secure, URLs may be logged
262+
// - csrf.SourceParam (3): Less secure, URLs may be logged
263+
// - csrf.SourceCookie (4): Not recommended for CSRF, no built-in extractor for this source
264+
// - csrf.SourceCustom (5): Security depends on implementation
265+
266+
// Check source type
267+
if extractor.Source == csrf.SourceHeader {
268+
fmt.Println("Using secure header extraction")
269+
}
270+
```
228271

229272
#### Using Route-Specific Extractors
230273

@@ -246,15 +289,19 @@ forms.Use(csrf.New(csrf.Config{
246289

247290
### Custom Extractor
248291

249-
You can create a custom extractor to handle specific cases:
292+
You can create a custom extractor to handle specific cases by creating an `Extractor` struct:
250293

251294
:::danger Never Extract from Cookies
252295
**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.
253296

254297
```go
255298
// ❌ NEVER DO THIS - Completely defeats CSRF protection
256-
func BadExtractor(c fiber.Ctx) (string, error) {
257-
return c.Cookies("csrf_"), nil // Always passes validation!
299+
badExtractor := csrf.Extractor{
300+
Extract: func(c fiber.Ctx) (string, error) {
301+
return c.Cookies("csrf_"), nil // Always passes validation!
302+
},
303+
Source: csrf.SourceCustom,
304+
Key: "csrf_",
258305
}
259306

260307
// ✅ DO THIS - Extract from different source than cookie
@@ -272,34 +319,84 @@ The middleware uses the **Double Submit Cookie** pattern - it compares the extra
272319
```go
273320
// Extract CSRF token embedded in JWT Authorization header
274321
// Useful for APIs that combine JWT auth with CSRF protection
275-
func BearerTokenExtractor(c fiber.Ctx) (string, error) {
276-
// Extract from "Authorization: Bearer <jwt>:<csrf>"
277-
auth := c.Get("Authorization")
278-
if !strings.HasPrefix(auth, "Bearer ") {
279-
return "", csrf.ErrTokenNotFound
322+
func BearerTokenExtractor() csrf.Extractor {
323+
return csrf.Extractor{
324+
Extract: func(c fiber.Ctx) (string, error) {
325+
// Extract from "Authorization: Bearer <jwt>:<csrf>"
326+
auth := c.Get("Authorization")
327+
if !strings.HasPrefix(auth, "Bearer ") {
328+
return "", csrf.ErrTokenNotFound
329+
}
330+
331+
parts := strings.SplitN(strings.TrimPrefix(auth, "Bearer "), ":", 2)
332+
if len(parts) != 2 || parts[1] == "" {
333+
return "", csrf.ErrTokenNotFound
334+
}
335+
336+
return parts[1], nil
337+
},
338+
Source: csrf.SourceCustom,
339+
Key: "Authorization",
280340
}
281-
282-
parts := strings.SplitN(strings.TrimPrefix(auth, "Bearer "), ":", 2)
283-
if len(parts) != 2 || parts[1] == "" {
284-
return "", csrf.ErrTokenNotFound
341+
}
342+
343+
// Usage
344+
app.Use(csrf.New(csrf.Config{
345+
Extractor: BearerTokenExtractor(),
346+
}))
347+
```
348+
349+
#### Custom JSON Body Extractor
350+
351+
```go
352+
// Extract CSRF token from JSON request body
353+
// Useful for APIs that need token in request payload
354+
func JSONBodyExtractor(field string) csrf.Extractor {
355+
return csrf.Extractor{
356+
Extract: func(c fiber.Ctx) (string, error) {
357+
var body map[string]interface{}
358+
if err := c.BodyParser(&body); err != nil {
359+
return "", csrf.ErrTokenNotFound
360+
}
361+
362+
token, ok := body[field].(string)
363+
if !ok || token == "" {
364+
return "", csrf.ErrTokenNotFound
365+
}
366+
367+
return token, nil
368+
},
369+
Source: csrf.SourceCustom,
370+
Key: field,
285371
}
286-
287-
return parts[1], nil
288372
}
373+
374+
// Usage
375+
app.Use(csrf.New(csrf.Config{
376+
Extractor: JSONBodyExtractor("csrf_token"),
377+
}))
289378
```
290379

291-
#### Chain Extractor (Advanced)
380+
### Chain Extractor (Advanced)
292381

293-
For edge cases requiring multiple token sources, use the `Chain` extractor:
382+
For specific cases requiring fallback behavior:
294383

295384
```go
296-
// Only if you absolutely need multiple sources
385+
// Try header first, fallback to form
297386
app.Use(csrf.New(csrf.Config{
298387
Extractor: csrf.Chain(
299-
csrf.FromHeader("X-Csrf-Token"), // Try header first
300-
csrf.FromForm("_csrf"), // Fallback to form
388+
csrf.FromHeader("X-Csrf-Token"),
389+
csrf.FromForm("_csrf"),
301390
),
302391
}))
392+
393+
// Check chain metadata
394+
chained := csrf.Chain(
395+
csrf.FromHeader("X-Csrf-Token"),
396+
csrf.FromForm("_csrf"),
397+
)
398+
fmt.Printf("Primary source: %v, Chain length: %d", chained.Source, len(chained.Chain))
399+
// Output: Primary source: 0, Chain length: 2
303400
```
304401

305402
:::danger Security Risk
@@ -396,7 +493,7 @@ func (h *csrf.Handler) DeleteToken(c fiber.Ctx) error
396493
| IdleTimeout | `time.Duration` | Token expiration time | `30 * time.Minute` |
397494
| KeyGenerator | `func() string` | Token generation function | `utils.UUIDv4` |
398495
| ErrorHandler | `fiber.ErrorHandler` | Custom error handler | `defaultErrorHandler` |
399-
| Extractor | `func(fiber.Ctx) (string, error)` | Token extraction method | `FromHeader("X-Csrf-Token")` |
496+
| Extractor | `csrf.Extractor` | Token extraction method with metadata | `FromHeader("X-Csrf-Token")` |
400497
| Session | `*session.Store` | Session store (**recommended for production**) | `nil` |
401498
| Storage | `fiber.Storage` | Token storage (overridden by Session) | `nil` |
402499
| TrustedOrigins | `[]string` | Trusted origins for cross-origin requests | `[]` |
@@ -422,4 +519,14 @@ var (
422519
const (
423520
HeaderName = "X-Csrf-Token"
424521
)
522+
523+
// Source types for extractor metadata
524+
const (
525+
SourceHeader Source = iota // 0 - Most secure
526+
SourceForm // 1 - Secure
527+
SourceQuery // 2 - Less secure
528+
SourceParam // 3 - Less secure
529+
SourceCookie // 4 - Not recommended for CSRF, no built-in extractor for this source
530+
SourceCustom // 5 - Security depends on implementation
531+
)
425532
```

middleware/csrf/config.go

Lines changed: 62 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
package csrf
22

33
import (
4+
"fmt"
5+
"strings"
46
"time"
57

68
"github.com/gofiber/fiber/v3"
9+
"github.com/gofiber/fiber/v3/log"
710
"github.com/gofiber/fiber/v3/middleware/session"
811
"github.com/gofiber/utils/v2"
912
)
@@ -37,16 +40,6 @@ type Config struct {
3740
// Optional. Default: defaultErrorHandler
3841
ErrorHandler fiber.ErrorHandler
3942

40-
// Extractor returns the CSRF token from the request.
41-
//
42-
// Optional. Default: FromHeader("X-Csrf-Token")
43-
//
44-
// Available extractors: FromHeader, FromQuery, FromParam, FromForm
45-
//
46-
// WARNING: Never create custom extractors that read from cookies with the same
47-
// CookieName as this defeats CSRF protection entirely.
48-
Extractor func(c fiber.Ctx) (string, error)
49-
5043
// CookieName is the name of the CSRF cookie.
5144
//
5245
// Optional. Default: "csrf_"
@@ -80,6 +73,21 @@ type Config struct {
8073
// Optional. Default: []
8174
TrustedOrigins []string
8275

76+
// Extractor returns the CSRF token from the request.
77+
//
78+
// Optional. Default: FromHeader("X-Csrf-Token")
79+
//
80+
// Available extractors:
81+
// - FromHeader: Most secure, recommended for APIs
82+
// - FromForm: Secure, recommended for form submissions
83+
// - FromQuery: Less secure, URLs may be logged
84+
// - FromParam: Less secure, URLs may be logged
85+
// - Chain: Advanced chaining of multiple extractors
86+
//
87+
// WARNING: Never create custom extractors that read from cookies with the same
88+
// CookieName as this defeats CSRF protection entirely.
89+
Extractor Extractor
90+
8391
// IdleTimeout is the duration of time the CSRF token is valid.
8492
//
8593
// Optional. Default: 30 * time.Minute
@@ -152,9 +160,52 @@ func configDefault(config ...Config) Config {
152160
if cfg.ErrorHandler == nil {
153161
cfg.ErrorHandler = ConfigDefault.ErrorHandler
154162
}
155-
if cfg.Extractor == nil {
163+
// Check if Extractor is zero value (since it's a struct)
164+
if cfg.Extractor.Extract == nil {
156165
cfg.Extractor = ConfigDefault.Extractor
157166
}
167+
// Validate extractor security configurations
168+
validateExtractorSecurity(cfg)
158169

159170
return cfg
160171
}
172+
173+
// validateExtractorSecurity checks for insecure extractor configurations
174+
func validateExtractorSecurity(cfg Config) {
175+
// Check primary extractor
176+
if isInsecureCookieExtractor(cfg.Extractor, cfg.CookieName) {
177+
panic("CSRF: Extractor reads from the same cookie '" + cfg.CookieName +
178+
"' used for token storage. This completely defeats CSRF protection.")
179+
}
180+
181+
// Check chained extractors
182+
for i, extractor := range cfg.Extractor.Chain {
183+
if isInsecureCookieExtractor(extractor, cfg.CookieName) {
184+
panic(fmt.Sprintf("CSRF: Chained extractor #%d reads from the same cookie '%s' "+
185+
"used for token storage. This completely defeats CSRF protection.", i+1, cfg.CookieName))
186+
}
187+
}
188+
189+
// Additional security warnings (non-fatal)
190+
if cfg.Extractor.Source == SourceQuery || cfg.Extractor.Source == SourceParam {
191+
log.Warnf("[CSRF WARNING] Using %v extractor - URLs may be logged", cfg.Extractor.Source)
192+
}
193+
}
194+
195+
// isInsecureCookieExtractor checks if an extractor unsafely reads from the CSRF cookie
196+
func isInsecureCookieExtractor(extractor Extractor, cookieName string) bool {
197+
if extractor.Source == SourceCookie {
198+
// Exact match - definitely insecure
199+
if extractor.Key == cookieName {
200+
return true
201+
}
202+
203+
// Case-insensitive match - potentially confusing, warn but don't panic
204+
if strings.EqualFold(extractor.Key, cookieName) && extractor.Key != cookieName {
205+
log.Warnf("[CSRF WARNING] Extractor cookie name '%s' is similar to CSRF cookie '%s' - this may be confusing",
206+
extractor.Key, cookieName)
207+
}
208+
}
209+
210+
return false
211+
}

0 commit comments

Comments
 (0)