From a46aea12fc0f98bf26030e15e8b4056dbbb39524 Mon Sep 17 00:00:00 2001 From: Tigah <88289044+TBX3D@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:49:50 -0700 Subject: [PATCH] fix(scan): accept string-encoded jwt exp claim (#311) numericClaim only accepted exp as a json number, but some emitters serialize it as a quoted string. a token with a past string exp was reported as missing exp instead of expired, an expiry false negative and a missing-claim false positive on the same token. also parse a string value via strconv.ParseFloat, falling back to absent when it does not parse. --- internal/scan/jwt.go | 20 +++++++++++--- internal/scan/jwt_test.go | 55 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/internal/scan/jwt.go b/internal/scan/jwt.go index 1edcb82..fc180d6 100644 --- a/internal/scan/jwt.go +++ b/internal/scan/jwt.go @@ -24,6 +24,7 @@ import ( "io" "net/http" "regexp" + "strconv" "strings" "time" @@ -375,14 +376,27 @@ func decodeJWTSegment(seg string) (map[string]any, error) { } // numericClaim pulls a numeric claim out of the payload. json numbers decode to -// float64, so that's the only shape we accept. +// float64, but some jwt libraries (older php/java stacks in particular) encode +// NumericDate as a quoted string, so a string that parses as a number counts too. +// otherwise a stringly-typed exp reads as absent and a genuinely expired token +// slips through as "missing exp" instead of "expired". func numericClaim(payload map[string]any, key string) (float64, bool) { v, ok := payload[key] if !ok { return 0, false } - f, ok := v.(float64) - return f, ok + switch n := v.(type) { + case float64: + return n, true + case string: + f, err := strconv.ParseFloat(n, 64) + if err != nil { + return 0, false + } + return f, true + default: + return 0, false + } } // isHMACAlg reports whether alg is one of the HMAC family (HS256/HS384/HS512). diff --git a/internal/scan/jwt_test.go b/internal/scan/jwt_test.go index def692c..95639ea 100644 --- a/internal/scan/jwt_test.go +++ b/internal/scan/jwt_test.go @@ -285,6 +285,61 @@ func TestJWT_SensitiveClaimFlagged(t *testing.T) { } } +func TestNumericClaim_AcceptsStringEncodedExp(t *testing.T) { + // some jwt stacks (older php/java libs) serialize NumericDate as a quoted + // string instead of a json number. a stringly-typed exp must still count + // as present, or a genuinely expired token reads as "missing exp" instead. + cases := []struct { + name string + payload map[string]any + wantF float64 + wantOK bool + }{ + {"string past exp", map[string]any{"exp": "1000000000"}, 1000000000, true}, + {"string future exp", map[string]any{"exp": "9999999999"}, 9999999999, true}, + {"float exp still works", map[string]any{"exp": float64(1000000000)}, 1000000000, true}, + {"garbage string exp", map[string]any{"exp": "not-a-number"}, 0, false}, + {"missing exp", map[string]any{}, 0, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f, ok := numericClaim(tc.payload, "exp") + if ok != tc.wantOK || f != tc.wantF { + t.Errorf("numericClaim(%+v) = (%v, %v), want (%v, %v)", tc.payload, f, ok, tc.wantF, tc.wantOK) + } + }) + } +} + +func TestJWT_StringExpStillDetectsExpiry(t *testing.T) { + // header {alg:none}, payload {sub:x, exp:"1000000000"} (string-typed, long past). + // exp is a valid number, just quoted, so this must flag "expired token" and + // must NOT also claim the expiry is missing. + jwtStringExpPast := "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0." + + "eyJzdWIiOiJ4IiwiZXhwIjoiMTAwMDAwMDAwMCJ9." + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(jwtStringExpPast)) + })) + defer srv.Close() + + result, err := JWT(srv.URL, 5*time.Second, "") + if err != nil { + t.Fatalf("JWT: %v", err) + } + if result == nil || len(result.Tokens) != 1 { + t.Fatalf("expected one token, got %+v", result) + } + + token := &result.Tokens[0] + if !hasIssue(token, "expired token") { + t.Errorf("expected string-encoded past exp to be flagged expired, got %+v", token.Issues) + } + if hasIssue(token, "missing exp") { + t.Errorf("string-encoded exp must not be reported as missing, got %+v", token.Issues) + } +} + func TestJWT_NoTokens(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("nothing to see here"))