From 9494e52f3787fccb30eff0b31ef83947b9fefdf9 Mon Sep 17 00:00:00 2001 From: TBX3D <88289044+TBX3D@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:09:25 -0700 Subject: [PATCH] fix(scan): decode supabase jwt body as base64url jwt payloads are unpadded base64url, but the supabase detector decoded them with RawStdEncoding, which errors on any payload whose base64 lands on the url-safe - or _ characters and silently skips the token. mirror the jwt.go decoder: raw base64url with a padded fallback. extract the decode into parseSupabaseJwtBody so it is unit-testable off the network. --- internal/scan/js/supabase.go | 44 +++++++++++++++------- internal/scan/js/supabase_test.go | 62 +++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 13 deletions(-) create mode 100644 internal/scan/js/supabase_test.go diff --git a/internal/scan/js/supabase.go b/internal/scan/js/supabase.go index 922ebc8c..8bde34ff 100644 --- a/internal/scan/js/supabase.go +++ b/internal/scan/js/supabase.go @@ -20,6 +20,7 @@ import ( "encoding/base64" "encoding/json" "errors" + "fmt" "io" "net/http" "os" @@ -41,6 +42,34 @@ type supabaseJwtBody struct { Role *string `json:"role"` } +// parseSupabaseJwtBody decodes the claims segment of a jwt. jwt payloads are +// unpadded base64url, so decode with that alphabet and fall back to the padded +// variant for emitters that pad; RawStdEncoding would reject any payload whose +// base64 lands on the url-safe - or _ characters. +func parseSupabaseJwtBody(token string) (*supabaseJwtBody, error) { + parts := strings.Split(token, ".") + if len(parts) < 2 { + return nil, fmt.Errorf("jwt has %d segments, want 3", len(parts)) + } + decoded, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + decoded, err = base64.URLEncoding.DecodeString(parts[1]) + if err != nil { + return nil, fmt.Errorf("base64url decode jwt body: %w", err) + } + } + var body *supabaseJwtBody + if err := json.Unmarshal(decoded, &body); err != nil { + return nil, fmt.Errorf("unmarshal jwt body: %w", err) + } + // a literal json null unmarshals into a nil pointer with no error; guard so + // callers can dereference the result without a nil panic. + if body == nil { + return nil, errors.New("jwt body is json null") + } + return body, nil +} + type supabaseScanResult struct { ProjectId string `json:"project_id"` ApiKey string `json:"api_key"` @@ -163,20 +192,9 @@ func ScanSupabase(jsContent string, jsUrl string, timeout time.Duration) ([]supa jwts = slices.Compact(jwts) for _, jwt := range jwts { - parts := strings.Split(jwt, ".") - body := parts[1] - - decoded, err := base64.RawStdEncoding.DecodeString(body) - if err != nil { - supabaselog.Debugf("Failed to decode JWT %s: %s", body, err) - continue - } - - supabaselog.Debugf("JWT body: %s", decoded) - var supabaseJwt *supabaseJwtBody - err = json.Unmarshal(decoded, &supabaseJwt) + supabaseJwt, err := parseSupabaseJwtBody(jwt) if err != nil { - supabaselog.Debugf("Failed to json parse JWT %s: %s", jwt, err) + supabaselog.Debugf("Failed to parse JWT %s: %s", jwt, err) continue } diff --git a/internal/scan/js/supabase_test.go b/internal/scan/js/supabase_test.go new file mode 100644 index 00000000..cb6c3bba --- /dev/null +++ b/internal/scan/js/supabase_test.go @@ -0,0 +1,62 @@ +package js + +import ( + "encoding/base64" + "testing" +) + +func TestParseSupabaseJwtBody(t *testing.T) { + // claims segment whose base64url encoding contains both - and _; decodes to + // {"ref":"|Z7>2V[qx?fw0","role":"anon"}. RawStdEncoding rejects it outright. + urlSafeSeg := "eyJyZWYiOiJ8Wjc-MlZbcXg_ZncwIiwicm9sZSI6ImFub24ifQ" + + stdJSON := []byte(`{"ref":"mjrnzxqptwubhklsdvca","role":"anon"}`) + rawSeg := base64.RawURLEncoding.EncodeToString(stdJSON) + paddedSeg := base64.URLEncoding.EncodeToString(stdJSON) + + // json null unmarshals into a nil pointer without error; the decoder must + // surface it as an error so ScanSupabase does not nil-deref the result. + nullSeg := base64.RawURLEncoding.EncodeToString([]byte("null")) + // valid claims without ref/role must decode cleanly with nil fields. + noClaimsSeg := base64.RawURLEncoding.EncodeToString([]byte(`{"iss":"supabase"}`)) + + cases := []struct { + name string + token string + wantErr bool + wantRef string // only checked when the case sets a non-empty value + }{ + {"url-safe payload", "hdr." + urlSafeSeg + ".sig", false, "|Z7>2V[qx?fw0"}, + {"unpadded base64url", "hdr." + rawSeg + ".sig", false, "mjrnzxqptwubhklsdvca"}, + {"padded base64url", "hdr." + paddedSeg + ".sig", false, "mjrnzxqptwubhklsdvca"}, + {"too few segments", "hdr.sig", true, ""}, + {"invalid base64", "hdr.!!!!.sig", true, ""}, + {"json null body", "hdr." + nullSeg + ".sig", true, ""}, + {"no ref or role", "hdr." + noClaimsSeg + ".sig", false, ""}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + body, err := parseSupabaseJwtBody(tc.token) + if tc.wantErr { + if err == nil { + t.Fatalf("parseSupabaseJwtBody(%q) = nil err, want error", tc.token) + } + return + } + if err != nil { + t.Fatalf("parseSupabaseJwtBody(%q) error: %v", tc.token, err) + } + // a valid decode must never yield a nil body; callers dereference it. + if body == nil { + t.Fatalf("parseSupabaseJwtBody(%q) = nil body, nil err", tc.token) + } + if tc.wantRef == "" { + return + } + if body.ProjectId == nil || *body.ProjectId != tc.wantRef { + t.Fatalf("ProjectId = %v, want %q", body.ProjectId, tc.wantRef) + } + }) + } +}