Skip to content

Commit a09643c

Browse files
TBX3Dvmfunc
andauthored
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>
1 parent 6500b08 commit a09643c

2 files changed

Lines changed: 87 additions & 13 deletions

File tree

internal/scan/js/supabase.go

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"encoding/base64"
2121
"encoding/json"
2222
"errors"
23+
"fmt"
2324
"io"
2425
"net/http"
2526
"os"
@@ -41,6 +42,34 @@ type supabaseJwtBody struct {
4142
Role *string `json:"role"`
4243
}
4344

45+
// parseSupabaseJwtBody decodes the claims segment of a jwt. jwt payloads are
46+
// unpadded base64url, so decode with that alphabet and fall back to the padded
47+
// variant for emitters that pad; RawStdEncoding would reject any payload whose
48+
// base64 lands on the url-safe - or _ characters.
49+
func parseSupabaseJwtBody(token string) (*supabaseJwtBody, error) {
50+
parts := strings.Split(token, ".")
51+
if len(parts) < 2 {
52+
return nil, fmt.Errorf("jwt has %d segments, want 3", len(parts))
53+
}
54+
decoded, err := base64.RawURLEncoding.DecodeString(parts[1])
55+
if err != nil {
56+
decoded, err = base64.URLEncoding.DecodeString(parts[1])
57+
if err != nil {
58+
return nil, fmt.Errorf("base64url decode jwt body: %w", err)
59+
}
60+
}
61+
var body *supabaseJwtBody
62+
if err := json.Unmarshal(decoded, &body); err != nil {
63+
return nil, fmt.Errorf("unmarshal jwt body: %w", err)
64+
}
65+
// a literal json null unmarshals into a nil pointer with no error; guard so
66+
// callers can dereference the result without a nil panic.
67+
if body == nil {
68+
return nil, errors.New("jwt body is json null")
69+
}
70+
return body, nil
71+
}
72+
4473
type supabaseScanResult struct {
4574
ProjectId string `json:"project_id"`
4675
ApiKey string `json:"api_key"`
@@ -163,20 +192,9 @@ func ScanSupabase(jsContent string, jsUrl string, timeout time.Duration) ([]supa
163192
jwts = slices.Compact(jwts)
164193

165194
for _, jwt := range jwts {
166-
parts := strings.Split(jwt, ".")
167-
body := parts[1]
168-
169-
decoded, err := base64.RawStdEncoding.DecodeString(body)
170-
if err != nil {
171-
supabaselog.Debugf("Failed to decode JWT %s: %s", body, err)
172-
continue
173-
}
174-
175-
supabaselog.Debugf("JWT body: %s", decoded)
176-
var supabaseJwt *supabaseJwtBody
177-
err = json.Unmarshal(decoded, &supabaseJwt)
195+
supabaseJwt, err := parseSupabaseJwtBody(jwt)
178196
if err != nil {
179-
supabaselog.Debugf("Failed to json parse JWT %s: %s", jwt, err)
197+
supabaselog.Debugf("Failed to parse JWT %s: %s", jwt, err)
180198
continue
181199
}
182200

internal/scan/js/supabase_test.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,3 +134,59 @@ func TestScanSupabase_PartialFailureAccumulates(t *testing.T) {
134134
t.Fatalf("expected proja's items collection intact, got %+v", results[0].Collections)
135135
}
136136
}
137+
138+
func TestParseSupabaseJwtBody(t *testing.T) {
139+
// claims segment whose base64url encoding contains both - and _; decodes to
140+
// {"ref":"|Z7>2V[qx?fw0","role":"anon"}. RawStdEncoding rejects it outright.
141+
urlSafeSeg := "eyJyZWYiOiJ8Wjc-MlZbcXg_ZncwIiwicm9sZSI6ImFub24ifQ"
142+
143+
stdJSON := []byte(`{"ref":"mjrnzxqptwubhklsdvca","role":"anon"}`)
144+
rawSeg := base64.RawURLEncoding.EncodeToString(stdJSON)
145+
paddedSeg := base64.URLEncoding.EncodeToString(stdJSON)
146+
147+
// json null unmarshals into a nil pointer without error; the decoder must
148+
// surface it as an error so ScanSupabase does not nil-deref the result.
149+
nullSeg := base64.RawURLEncoding.EncodeToString([]byte("null"))
150+
// valid claims without ref/role must decode cleanly with nil fields.
151+
noClaimsSeg := base64.RawURLEncoding.EncodeToString([]byte(`{"iss":"supabase"}`))
152+
153+
cases := []struct {
154+
name string
155+
token string
156+
wantErr bool
157+
wantRef string // only checked when the case sets a non-empty value
158+
}{
159+
{"url-safe payload", "hdr." + urlSafeSeg + ".sig", false, "|Z7>2V[qx?fw0"},
160+
{"unpadded base64url", "hdr." + rawSeg + ".sig", false, "mjrnzxqptwubhklsdvca"},
161+
{"padded base64url", "hdr." + paddedSeg + ".sig", false, "mjrnzxqptwubhklsdvca"},
162+
{"too few segments", "hdr.sig", true, ""},
163+
{"invalid base64", "hdr.!!!!.sig", true, ""},
164+
{"json null body", "hdr." + nullSeg + ".sig", true, ""},
165+
{"no ref or role", "hdr." + noClaimsSeg + ".sig", false, ""},
166+
}
167+
168+
for _, tc := range cases {
169+
t.Run(tc.name, func(t *testing.T) {
170+
body, err := parseSupabaseJwtBody(tc.token)
171+
if tc.wantErr {
172+
if err == nil {
173+
t.Fatalf("parseSupabaseJwtBody(%q) = nil err, want error", tc.token)
174+
}
175+
return
176+
}
177+
if err != nil {
178+
t.Fatalf("parseSupabaseJwtBody(%q) error: %v", tc.token, err)
179+
}
180+
// a valid decode must never yield a nil body; callers dereference it.
181+
if body == nil {
182+
t.Fatalf("parseSupabaseJwtBody(%q) = nil body, nil err", tc.token)
183+
}
184+
if tc.wantRef == "" {
185+
return
186+
}
187+
if body.ProjectId == nil || *body.ProjectId != tc.wantRef {
188+
t.Fatalf("ProjectId = %v, want %q", body.ProjectId, tc.wantRef)
189+
}
190+
})
191+
}
192+
}

0 commit comments

Comments
 (0)