fix(scan): decode supabase jwt body as base64url (#348)

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.

Co-authored-by: vmfunc <vmfunc.lc@gmail.com>
This commit is contained in:
Tigah
2026-07-22 22:06:32 +00:00
committed by GitHub
co-authored by vmfunc
parent 6500b08d1a
commit a09643c070
2 changed files with 87 additions and 13 deletions
+31 -13
View File
@@ -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)
supabaseJwt, err := parseSupabaseJwtBody(jwt)
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)
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
}
+56
View File
@@ -134,3 +134,59 @@ func TestScanSupabase_PartialFailureAccumulates(t *testing.T) {
t.Fatalf("expected proja's items collection intact, got %+v", results[0].Collections)
}
}
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)
}
})
}
}