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