mirror of
https://github.com/lunchcat/sif.git
synced 2026-07-28 14:37:01 -07:00
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:
+17
-3
@@ -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).
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
Reference in New Issue
Block a user