Files
sif/internal/scan/jwt_test.go
T
TigahandGitHub a46aea12fc 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.
2026-07-22 12:49:50 -07:00

364 lines
12 KiB
Go

/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package scan
import (
"net/http"
"net/http/httptest"
"testing"
"time"
)
// fixed jwt fixtures, generated offline. each exercises a distinct weakness.
const (
// header {alg:none}, payload {sub:admin}, empty signature - forgeable.
jwtNone = "eyJhbGciOiAibm9uZSIsICJ0eXAiOiAiSldUIn0." +
"eyJzdWIiOiAiYWRtaW4iLCAicm9sZSI6ICJ1c2VyIn0."
// HS256, no exp claim, signed with the bundled weak secret "secret".
jwtWeakHS256 = "eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9." +
"eyJzdWIiOiAiMTIzNDU2Nzg5MCIsICJuYW1lIjogInRlc3RlciJ9." +
"JOjVfLa8gp3cvFkNVgOnmdrI1MCHZRA_ChBmCPF-Z8w"
// HS256, exp in 2001 (long past), signed with a secret not in the wordlist.
jwtExpired = "eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9." +
"eyJzdWIiOiAieCIsICJleHAiOiAxMDAwMDAwMDAwfQ." +
"gr28Ffm4wJkonHGSKmMD5Rj7e1pTt2o_EwG6lMWQeSc"
// HS256 carrying a plaintext password claim (jwt bodies are not encrypted).
jwtSensitive = "eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9." +
"eyJzdWIiOiAieCIsICJwYXNzd29yZCI6ICJodW50ZXIyIiwgImV4cCI6IDk5OTk5OTk5OTl9." +
"rjEf0CUa7_qppuINi6zL9vupJIX0rzSBhul7kKM9uSA"
// HS384, signed with the bundled weak secret "secret".
jwtWeakHS384 = "eyJhbGciOiJIUzM4NCIsInR5cCI6IkpXVCJ9." +
"eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6InRlc3RlciJ9." +
"OXNTuzKiGLxnpUjL24vvKlQzdOD-YKMinN8eu_v5luTXDUF65bHAQnz-M3VG2TVh"
// HS512, signed with the bundled weak secret "secret".
jwtWeakHS512 = "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9." +
"eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6InRlc3RlciJ9." +
"CXcZz0F9TTPg--B4WV1Vzty3gG_wcDG86H5QDSRe94MpcVXIcRTBK6H7OmqFyG4nNWYNXPOODCu426bgQMOzRQ"
// HS384/HS512 signed with a strong secret absent from the wordlist; these
// must never be cracked (no false positive on the wide-digest path).
jwtStrongHS384 = "eyJhbGciOiJIUzM4NCIsInR5cCI6IkpXVCJ9." +
"eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6InRlc3RlciJ9." +
"vHhjZPoXZnnZEvVYxX64J2wm8qWk-e6y_T20qTy_Su6sPmoUSMHS4tv6_D-hfwrY"
jwtStrongHS512 = "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9." +
"eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6InRlc3RlciJ9." +
"80ueFa0oI88ftySkn_MJ12GAd1r2cahXt_ICtCfWx58wJoAvEocbjBPC_efzOp8vm_39GlcCCDLeb6cFix3DBw"
)
// hasIssue reports whether the analyzed token carries an issue of the given kind.
func hasIssue(token *JWTToken, kind string) bool {
for i := 0; i < len(token.Issues); i++ {
if token.Issues[i].Kind == kind {
return true
}
}
return false
}
func TestJWT_AlgNoneAndMissingExpFlagged(t *testing.T) {
// serve the alg:none token in the Authorization header echo.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Authorization", "Bearer "+jwtNone)
w.WriteHeader(http.StatusOK)
}))
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 exactly one analyzed token, got %+v", result)
}
token := &result.Tokens[0]
if !hasIssue(token, "alg:none") {
t.Errorf("expected alg:none to be flagged, got issues %+v", token.Issues)
}
if !hasIssue(token, "missing exp") {
t.Errorf("expected missing exp to be flagged, got issues %+v", token.Issues)
}
// the preview must never carry the whole token.
if len(token.Preview) >= len(jwtNone) {
t.Errorf("preview should be trimmed, got full token %q", token.Preview)
}
}
func TestJWT_WeakSecretCracked(t *testing.T) {
// token rides in a Set-Cookie this time.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.SetCookie(w, &http.Cookie{Name: "session", Value: jwtWeakHS256})
w.WriteHeader(http.StatusOK)
}))
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 token.WeakKey != "secret" {
t.Errorf("expected weak secret 'secret' to be cracked, got %q", token.WeakKey)
}
if !hasIssue(token, "weak hmac secret") {
t.Errorf("expected weak hmac secret issue, got %+v", token.Issues)
}
if !hasIssue(token, "rs256->hs256 confusion surface") {
t.Errorf("expected hmac confusion surface to be flagged, got %+v", token.Issues)
}
}
func TestJWT_WeakSecretCrackedHS384HS512(t *testing.T) {
cases := []struct {
name string
token string
}{
{"HS384", jwtWeakHS384},
{"HS512", jwtWeakHS512},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.SetCookie(w, &http.Cookie{Name: "session", Value: tc.token})
w.WriteHeader(http.StatusOK)
}))
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 token.WeakKey != "secret" {
t.Errorf("expected weak secret 'secret' cracked on %s, got %q", tc.name, token.WeakKey)
}
if !hasIssue(token, "weak hmac secret") {
t.Errorf("expected weak hmac secret issue on %s, got %+v", tc.name, token.Issues)
}
})
}
}
func TestJWT_StrongSecretNotCrackedHS384HS512(t *testing.T) {
cases := []struct {
name string
token string
}{
{"HS384", jwtStrongHS384},
{"HS512", jwtStrongHS512},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.SetCookie(w, &http.Cookie{Name: "session", Value: tc.token})
w.WriteHeader(http.StatusOK)
}))
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 token.WeakKey != "" {
t.Errorf("strong-secret %s token must not be cracked, got %q", tc.name, token.WeakKey)
}
if hasIssue(token, "weak hmac secret") {
t.Errorf("strong-secret %s token must not raise a weak-secret issue, got %+v", tc.name, token.Issues)
}
})
}
}
func TestHMACHash(t *testing.T) {
cases := []struct {
alg string
wantOK bool
wantSize int // digest bytes when ok
}{
{"HS256", true, 32},
{"HS384", true, 48},
{"HS512", true, 64},
{"hs256", true, 32}, // alg match is case-insensitive
{"", false, 0},
{"none", false, 0},
{"RS256", false, 0},
{"ES256", false, 0},
{"HS1", false, 0},
{"HS", false, 0},
}
for _, tc := range cases {
newHash, ok := hmacHash(tc.alg)
if ok != tc.wantOK {
t.Errorf("hmacHash(%q) ok = %v, want %v", tc.alg, ok, tc.wantOK)
continue
}
if ok && newHash().Size() != tc.wantSize {
t.Errorf("hmacHash(%q) digest size = %d, want %d", tc.alg, newHash().Size(), tc.wantSize)
}
}
}
func TestCrackHMAC_RejectsMalformedAndNonHMAC(t *testing.T) {
cases := []struct {
name string
raw string
alg string
}{
{"non-hmac alg", jwtWeakHS384, "RS256"},
{"unknown hs alg", jwtWeakHS384, "HS1"},
{"too few parts", "only.two", "HS256"},
{"non-base64 signature", "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ4In0.!!!notb64!!!", "HS256"},
}
for _, tc := range cases {
if secret, ok := crackHMAC(tc.raw, tc.alg); ok || secret != "" {
t.Errorf("%s: crackHMAC = (%q, %v), want (\"\", false)", tc.name, secret, ok)
}
}
}
func TestJWT_ExpiredFlagged(t *testing.T) {
// token in the response body.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"token":"` + jwtExpired + `"}`))
}))
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)
}
if !hasIssue(&result.Tokens[0], "expired token") {
t.Errorf("expected expired token to be flagged, got %+v", result.Tokens[0].Issues)
}
// a strong, unguessed secret must not be cracked.
if result.Tokens[0].WeakKey != "" {
t.Errorf("did not expect a cracked key on the strong-secret token, got %q", result.Tokens[0].WeakKey)
}
}
func TestJWT_SensitiveClaimFlagged(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(jwtSensitive))
}))
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)
}
if !hasIssue(&result.Tokens[0], "sensitive plaintext claim") {
t.Errorf("expected sensitive claim to be flagged, got %+v", result.Tokens[0].Issues)
}
}
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"))
}))
defer srv.Close()
result, err := JWT(srv.URL, 5*time.Second, "")
if err != nil {
t.Fatalf("JWT: %v", err)
}
if result != nil {
t.Errorf("expected nil result when no tokens present, got %+v", result)
}
}
func TestJWTResult_ResultType(t *testing.T) {
r := &JWTResult{}
if r.ResultType() != "jwt" {
t.Errorf("expected result type 'jwt', got %q", r.ResultType())
}
}