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.
This commit is contained in:
Tigah
2026-07-22 12:49:50 -07:00
committed by GitHub
parent 5e1c029e09
commit a46aea12fc
2 changed files with 72 additions and 3 deletions
+17 -3
View File
@@ -24,6 +24,7 @@ import (
"io" "io"
"net/http" "net/http"
"regexp" "regexp"
"strconv"
"strings" "strings"
"time" "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 // 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) { func numericClaim(payload map[string]any, key string) (float64, bool) {
v, ok := payload[key] v, ok := payload[key]
if !ok { if !ok {
return 0, false return 0, false
} }
f, ok := v.(float64) switch n := v.(type) {
return f, ok 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). // isHMACAlg reports whether alg is one of the HMAC family (HS256/HS384/HS512).
+55
View File
@@ -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) { func TestJWT_NoTokens(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("nothing to see here")) _, _ = w.Write([]byte("nothing to see here"))