Skip to content

Commit 39bfe92

Browse files
feat: refactor captcha to stateless JWT-based design (#146)
* feat: refactor captcha to stateless JWT-based design Replaces session-based captcha with stateless JWT tokens. Changes: - Stateless JWT cookies with HMAC-SHA256 signing - Removed internal/session package (no server-side storage) - Removed internal/cookie package (consolidated into captcha) - Added 20 comprehensive JWT tests (signing, expiration, tampering) - Fixed RFC-compliant Content-Type matching (case-insensitive + charset) Breaking changes: - signing_key now required (min 32 bytes) - Cookie format changed from custom to JWT Stats: +961 / -1,072 lines (net -111) * fix: resolve golangci-lint issues - Use assert.Len/require.Len for length checks (testifylint) - Use require.Error for critical error assertions (testifylint) - Remove unused removeHost/patchHost methods All tests pass. 0 linting issues. * fix: address Copilot review feedback High Priority: - Fix nil pointer dereference after cookie generation failure Update token status even if cookie generation fails to prevent users from being stuck after successful captcha validation Medium Priority: - Add error logging for UUID generation failures - Remove redundant nil check (tok guaranteed non-nil at this point) Code Quality: - Fix misleading comment (JWT tokens, not base64-encoded) - Skip unnecessary encoding for empty cookie values - Simplify duplicate error message All tests pass. 0 linting issues. * fix: work on @blotus comments * fix: refactoring * refactor: happy path spoa captcha * fix: linter * Refactor captcha: split JWT and cookie logic into separate packages - Move JWT token handling from internal/remediation/captcha to internal/captcha/jwt - Extract cookie generation to internal/captcha/cookie - Move captcha package from internal/remediation/captcha to pkg/captcha - Rename CaptchaToken to Token, SignCaptchaToken to Sign, etc. - Update imports in pkg/host and pkg/spoa to use new package structure
1 parent 7f2a126 commit 39bfe92

File tree

19 files changed

+871
-1127
lines changed

19 files changed

+871
-1127
lines changed

cmd/root.go

Lines changed: 7 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ import (
2020
"golang.org/x/sync/errgroup"
2121

2222
"github.com/crowdsecurity/crowdsec-spoa/internal/appsec"
23-
"github.com/crowdsecurity/crowdsec-spoa/internal/session"
2423
"github.com/crowdsecurity/crowdsec-spoa/pkg/cfg"
2524
"github.com/crowdsecurity/crowdsec-spoa/pkg/dataset"
2625
"github.com/crowdsecurity/crowdsec-spoa/pkg/host"
@@ -246,15 +245,6 @@ func Execute() error {
246245
}
247246
}
248247

249-
// Create and initialize global session manager (single GC goroutine for all hosts)
250-
globalSessions := &session.Sessions{
251-
SessionIdleTimeout: "1h", // Default values
252-
SessionMaxTime: "12h",
253-
SessionGarbageSeconds: 60,
254-
}
255-
sessionLogger := log.WithField("component", "global_sessions")
256-
globalSessions.Init(sessionLogger, ctx)
257-
258248
// Add hosts from config
259249
for _, h := range config.Hosts {
260250
HostManager.AddHost(h)
@@ -271,14 +261,13 @@ func Execute() error {
271261

272262
// Create single SPOA directly with minimal configuration
273263
spoaConfig := &spoa.SpoaConfig{
274-
TcpAddr: config.ListenTCP,
275-
UnixAddr: config.ListenUnix,
276-
Dataset: dataSet,
277-
HostManager: HostManager,
278-
GeoDatabase: &config.Geo,
279-
GlobalSessions: globalSessions,
280-
GlobalAppSec: globalAppSec,
281-
Logger: spoaLogger,
264+
TcpAddr: config.ListenTCP,
265+
UnixAddr: config.ListenUnix,
266+
Dataset: dataSet,
267+
HostManager: HostManager,
268+
GeoDatabase: &config.Geo,
269+
GlobalAppSec: globalAppSec,
270+
Logger: spoaLogger,
282271
}
283272

284273
singleSpoa, err := spoa.New(spoaConfig)

config/haproxy-upstreamproxy.cfg

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,8 @@ frontend test
6767
## Set a custom header on the request for upstream services to use
6868
http-request set-header X-Crowdsec-IsoCode %[var(txn.crowdsec.isocode)] if { var(txn.crowdsec.isocode) -m found }
6969

70-
## Handle 302 redirect for successful captcha validation (native HAProxy redirect)
71-
http-request redirect code 302 location %[var(txn.crowdsec.redirect)] if { var(txn.crowdsec.remediation) -m str "allow" } { var(txn.crowdsec.redirect) -m found }
70+
## Handle 302 redirect for successful captcha validation (redirect to current request URL)
71+
http-request redirect code 302 location %[url] if { var(txn.crowdsec.remediation) -m str "allow" } { var(txn.crowdsec.redirect) -m found }
7272

7373
## Call lua script only for ban and captcha remediations (performance optimization)
7474
http-request lua.crowdsec_handle if { var(txn.crowdsec.remediation) -m str "captcha" }

config/haproxy.cfg

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,8 @@ frontend test
5151
## Set a custom header on the request for upstream services to use
5252
http-request set-header X-Crowdsec-IsoCode %[var(txn.crowdsec.isocode)] if { var(txn.crowdsec.isocode) -m found }
5353

54-
## Handle 302 redirect for successful captcha validation (native HAProxy redirect)
55-
http-request redirect code 302 location %[var(txn.crowdsec.redirect)] if { var(txn.crowdsec.remediation) -m str "allow" } { var(txn.crowdsec.redirect) -m found }
54+
## Handle 302 redirect for successful captcha validation (redirect to current request URL)
55+
http-request redirect code 302 location %[url] if { var(txn.crowdsec.remediation) -m str "allow" } { var(txn.crowdsec.redirect) -m found }
5656

5757
## Call lua script only for ban and captcha remediations (performance optimization)
5858
http-request lua.crowdsec_handle if { var(txn.crowdsec.remediation) -m str "captcha" }

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ require (
88
github.com/crowdsecurity/go-cs-lib v0.0.23
99
github.com/dropmorepackets/haproxy-go v0.0.7
1010
github.com/gaissmai/bart v0.25.0
11+
github.com/golang-jwt/jwt/v5 v5.3.0
1112
github.com/google/uuid v1.6.0
1213
github.com/oschwald/geoip2-golang/v2 v2.0.0
1314
github.com/prometheus/client_golang v1.23.2

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk
5353
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
5454
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
5555
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
56+
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
57+
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
5658
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
5759
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
5860
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=

internal/captcha/cookie/cookie.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package cookie
2+
3+
import (
4+
"fmt"
5+
"net/http"
6+
7+
"github.com/crowdsecurity/go-cs-lib/ptr"
8+
log "github.com/sirupsen/logrus"
9+
)
10+
11+
// Generator handles cookie configuration and generation for captcha
12+
// Note: This struct handles HTTP cookie attributes only (Secure, HttpOnly, etc.)
13+
// JWT signing is handled at the Captcha level using signing_key
14+
type Generator struct {
15+
Secure string `yaml:"secure"` // Secure sets the secure flag on the cookie, valid arguments are "auto", "always", "never". "auto" relies on the `ssl_fc` flag from HAProxy
16+
HTTPOnly *bool `yaml:"http_only"` // HttpOnly sets the HttpOnly flag on the cookie
17+
Name string `yaml:"-"` // Name of the cookie, usually set by the remediation. EG "crowdsec_captcha"
18+
logger *log.Entry `yaml:"-"` // logger passed from the remediation
19+
}
20+
21+
func (g *Generator) Init(logger *log.Entry, name string) {
22+
g.logger = logger.WithField("type", "cookie")
23+
g.Name = name
24+
g.SetDefaults()
25+
}
26+
27+
func (g *Generator) SetDefaults() {
28+
if g.Secure == "" {
29+
g.Secure = "auto"
30+
}
31+
if g.HTTPOnly == nil {
32+
g.HTTPOnly = ptr.Of(true)
33+
}
34+
}
35+
36+
// resolveSecure determines the secure flag value based on configuration and SSL state
37+
func (g *Generator) resolveSecure(ssl *bool) bool {
38+
switch g.Secure {
39+
case "always":
40+
return true
41+
case "auto":
42+
if ssl != nil {
43+
return *ssl
44+
}
45+
return false
46+
default: // "never" or any other value
47+
return false
48+
}
49+
}
50+
51+
// GenerateUnset creates a cookie deletion header
52+
func (g *Generator) GenerateUnset(ssl *bool) *http.Cookie {
53+
return &http.Cookie{
54+
Name: g.Name,
55+
Value: "",
56+
MaxAge: -1,
57+
HttpOnly: *g.HTTPOnly,
58+
Secure: g.resolveSecure(ssl),
59+
SameSite: http.SameSiteStrictMode,
60+
Path: "/",
61+
}
62+
}
63+
64+
// Generate generates an HTTP cookie with the provided signed token value
65+
// This is called by Captcha.GenerateCookie() which handles the JWT signing
66+
func (g *Generator) Generate(signedToken string, ssl *bool) (*http.Cookie, error) {
67+
cookie := &http.Cookie{
68+
Name: g.Name,
69+
Value: signedToken,
70+
MaxAge: 0, // Session cookie
71+
HttpOnly: *g.HTTPOnly,
72+
Secure: g.resolveSecure(ssl),
73+
SameSite: http.SameSiteStrictMode,
74+
Path: "/",
75+
}
76+
77+
if len(cookie.String()) > 4096 {
78+
return nil, fmt.Errorf("cookie value too long")
79+
}
80+
81+
return cookie, nil
82+
}

internal/captcha/jwt/jwt.go

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
package jwt
2+
3+
import (
4+
"fmt"
5+
"time"
6+
7+
jwtlib "github.com/golang-jwt/jwt/v5"
8+
)
9+
10+
const (
11+
// Status constants for captcha tokens
12+
Pending = "pending"
13+
Valid = "valid"
14+
15+
// DefaultPendingTTL is the default TTL for pending captcha tokens (30 minutes)
16+
DefaultPendingTTL = 30 * time.Minute
17+
// DefaultPassedTTL is the default TTL for passed captcha tokens (24 hours)
18+
DefaultPassedTTL = 24 * time.Hour
19+
)
20+
21+
// Token represents the payload stored in a signed captcha cookie
22+
type Token struct {
23+
UUID string `json:"uuid"` // UUID for traceability and debugging
24+
St string `json:"st"` // status: "pending", "passed", "failed", etc.
25+
Iat int64 `json:"iat"` // issued at (unix seconds)
26+
Exp int64 `json:"exp"` // expires at (unix seconds)
27+
}
28+
29+
// Sign signs a Token using JWT (JWS) and returns a signed JWT string
30+
// Uses HMAC-SHA256 for signing. The token can be encrypted (JWE) in the future if needed.
31+
func Sign(tok Token, secret []byte) (string, error) {
32+
// Create JWT claims from Token
33+
claims := jwtlib.MapClaims{
34+
"uuid": tok.UUID,
35+
"st": tok.St,
36+
"iat": tok.Iat,
37+
"exp": tok.Exp,
38+
}
39+
40+
// Create token with HMAC-SHA256 signing method
41+
token := jwtlib.NewWithClaims(jwtlib.SigningMethodHS256, claims)
42+
43+
// Sign and get the complete encoded token as a string
44+
tokenString, err := token.SignedString(secret)
45+
if err != nil {
46+
return "", fmt.Errorf("failed to sign token: %w", err)
47+
}
48+
49+
return tokenString, nil
50+
}
51+
52+
// ParseAndVerify parses and verifies a JWT token string
53+
// Returns the token if valid, or an error if invalid, expired, or tampered with
54+
// JWT library automatically handles expiration checking via the "exp" claim
55+
func ParseAndVerify(raw string, secret []byte) (*Token, error) {
56+
if raw == "" {
57+
return nil, fmt.Errorf("empty token")
58+
}
59+
60+
// Parse and verify the JWT token
61+
token, err := jwtlib.Parse(raw, func(token *jwtlib.Token) (any, error) {
62+
// Validate signing method
63+
if _, ok := token.Method.(*jwtlib.SigningMethodHMAC); !ok {
64+
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
65+
}
66+
return secret, nil
67+
})
68+
69+
if err != nil {
70+
return nil, fmt.Errorf("failed to parse/verify token: %w", err)
71+
}
72+
73+
// Verify token is valid (signature, expiration, etc.)
74+
if !token.Valid {
75+
return nil, fmt.Errorf("invalid token")
76+
}
77+
78+
// Extract claims
79+
claims, ok := token.Claims.(jwtlib.MapClaims)
80+
if !ok {
81+
return nil, fmt.Errorf("invalid token claims")
82+
}
83+
84+
// Convert JWT claims back to Token
85+
tok := &Token{
86+
UUID: getStringClaim(claims, "uuid"),
87+
St: getStringClaim(claims, "st"),
88+
Iat: getInt64Claim(claims, "iat"),
89+
Exp: getInt64Claim(claims, "exp"),
90+
}
91+
92+
return tok, nil
93+
}
94+
95+
// Helper functions to safely extract claims from JWT
96+
func getStringClaim(claims jwtlib.MapClaims, key string) string {
97+
if val, ok := claims[key]; ok {
98+
if str, ok := val.(string); ok {
99+
return str
100+
}
101+
}
102+
return ""
103+
}
104+
105+
func getInt64Claim(claims jwtlib.MapClaims, key string) int64 {
106+
if val, ok := claims[key]; ok {
107+
switch v := val.(type) {
108+
case int64:
109+
return v
110+
case float64:
111+
return int64(v)
112+
case int:
113+
return int64(v)
114+
}
115+
}
116+
return 0
117+
}
118+
119+
// IsPassed checks if the token indicates the captcha was passed (not expired and status is valid)
120+
func (t *Token) IsPassed() bool {
121+
return time.Now().Unix() <= t.Exp && t.St == Valid
122+
}
123+
124+
// IsPending checks if the token indicates the captcha is pending (not expired and status is pending)
125+
func (t *Token) IsPending() bool {
126+
return time.Now().Unix() <= t.Exp && t.St == Pending
127+
}

0 commit comments

Comments
 (0)