Skip to content

Commit 1d4c87c

Browse files
committed
feat(csrf): Enhance extractor functionality with metadata and security validation
- Introduced Extractor struct for CSRF token extraction with metadata (source type, key). - Updated extractor methods (FromHeader, FromForm, FromQuery, FromParam) to return Extractor instances. - Added Chain method to allow multiple extractors with fallback behavior. - Implemented security validation for extractors to prevent insecure configurations. - Added tests for extractor security, metadata, and error handling.
1 parent deabee4 commit 1d4c87c

File tree

7 files changed

+830
-120
lines changed

7 files changed

+830
-120
lines changed

docs/middleware/csrf.md

Lines changed: 132 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -216,15 +216,57 @@ 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+
- You read from a different cookie (not the CSRF cookie itself)
239+
- You use multiple cookies for custom validation
240+
- You implement custom logic across different cookie sources
241+
242+
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.
243+
244+
**Warning:**
245+
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.
246+
:::
247+
248+
### Extractor Metadata
249+
250+
Each extractor returns an `Extractor` struct with metadata about its behavior:
251+
252+
```go
253+
extractor := csrf.FromHeader("X-Csrf-Token")
254+
fmt.Printf("Source: %v, Key: %s", extractor.Source, extractor.Key)
255+
// Output: Source: 0, Key: X-Csrf-Token
256+
257+
// Available source types:
258+
// - csrf.SourceHeader (0): Most secure, not logged
259+
// - csrf.SourceForm (1): Secure, not typically logged
260+
// - csrf.SourceQuery (2): Less secure, URLs may be logged
261+
// - csrf.SourceParam (3): Less secure, URLs may be logged
262+
// - csrf.SourceCookie (4): Not recommended for CSRF, no built-in extractor for this source
263+
// - csrf.SourceCustom (5): Security depends on implementation
264+
265+
// Check source type
266+
if extractor.Source == csrf.SourceHeader {
267+
fmt.Println("Using secure header extraction")
268+
}
269+
```
228270

229271
#### Using Route-Specific Extractors
230272

@@ -246,15 +288,19 @@ forms.Use(csrf.New(csrf.Config{
246288

247289
### Custom Extractor
248290

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

251293
:::danger Never Extract from Cookies
252294
**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.
253295

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

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

291-
#### Chain Extractor (Advanced)
379+
### Chain Extractor (Advanced)
292380

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

295383
```go
296-
// Only if you absolutely need multiple sources
384+
// Try header first, fallback to form
297385
app.Use(csrf.New(csrf.Config{
298386
Extractor: csrf.Chain(
299-
csrf.FromHeader("X-Csrf-Token"), // Try header first
300-
csrf.FromForm("_csrf"), // Fallback to form
387+
csrf.FromHeader("X-Csrf-Token"),
388+
csrf.FromForm("_csrf"),
301389
),
302390
}))
391+
392+
// Check chain metadata
393+
chained := csrf.Chain(
394+
csrf.FromHeader("X-Csrf-Token"),
395+
csrf.FromForm("_csrf"),
396+
)
397+
fmt.Printf("Primary source: %v, Chain length: %d", chained.Source, len(chained.Chain))
398+
// Output: Primary source: 0, Chain length: 2
303399
```
304400

305401
:::danger Security Risk
@@ -396,7 +492,7 @@ func (h *csrf.Handler) DeleteToken(c fiber.Ctx) error
396492
| IdleTimeout | `time.Duration` | Token expiration time | `30 * time.Minute` |
397493
| KeyGenerator | `func() string` | Token generation function | `utils.UUIDv4` |
398494
| ErrorHandler | `fiber.ErrorHandler` | Custom error handler | `defaultErrorHandler` |
399-
| Extractor | `func(fiber.Ctx) (string, error)` | Token extraction method | `FromHeader("X-Csrf-Token")` |
495+
| Extractor | `csrf.Extractor` | Token extraction method with metadata | `FromHeader("X-Csrf-Token")` |
400496
| Session | `*session.Store` | Session store (**recommended for production**) | `nil` |
401497
| Storage | `fiber.Storage` | Token storage (overridden by Session) | `nil` |
402498
| TrustedOrigins | `[]string` | Trusted origins for cross-origin requests | `[]` |
@@ -422,4 +518,14 @@ var (
422518
const (
423519
HeaderName = "X-Csrf-Token"
424520
)
521+
522+
// Source types for extractor metadata
523+
const (
524+
SourceHeader Source = iota // 0 - Most secure
525+
SourceForm // 1 - Secure
526+
SourceQuery // 2 - Less secure
527+
SourceParam // 3 - Less secure
528+
SourceCookie // 4 - Not recommended for CSRF, no built-in extractor for this source
529+
SourceCustom // 5 - Security depends on implementation
530+
)
425531
```

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.Warn("[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.Warn("[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)