Files
sif/pkg/scan/js/supabase.go
Celeste Hickenlooper 0210b55753 fix: regex compilation performance
Move regex compilation from inside functions to package level to avoid
recompiling on every function call. This improves performance by
compiling the regex patterns once at package initialization.

- Move jwtRegex to package level in supabase.go
- Move nextPagesRegex to package level in next.go
- Use strings.Builder instead of string concatenation in next.go
2026-01-03 05:57:10 -08:00

281 lines
8.0 KiB
Go

/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2025 vmfunc (Celeste Hickenlooper), xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
// todo: scan for storage and auth vulns
package js
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"io"
"net/http"
"os"
"regexp"
"slices"
"strconv"
"strings"
"time"
"github.com/charmbracelet/log"
)
// 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"`
}
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) (*supabaseArrayResponse, error) {
body, resp, err := doSupabaseRequest(projectId, path, apikey, auth)
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) (*supabaseOpenAPIResponse, error) {
body, _, err := doSupabaseRequest(projectId, "/rest/v1/", apikey, auth)
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) ([]byte, *http.Response, error) {
client := http.Client{}
req, err := http.NewRequest("GET", "https://"+projectId+".supabase.co"+path, nil)
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
}
defer resp.Body.Close()
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) ([]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 {
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([]byte(decoded), &supabaseJwt)
if err != nil {
supabaselog.Debugf("Failed to json 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 := http.Client{}
req, err := http.NewRequest("POST", "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))
if err != nil {
resp.Body.Close()
return nil, err
}
resp.Body.Close()
var authResp supabaseAuthResponse
if err := json.Unmarshal(body, &authResp); err != nil {
return nil, err
}
auth = authResp.AccessToken
supabaselog.Infof("Created account with JWT %s", auth)
} else {
resp.Body.Close()
}
var collections = []supabaseCollection{}
openAPI, err := getSupabaseOpenAPI(*supabaseJwt.ProjectId, jwt, &auth)
if err != nil {
return nil, err
}
if openAPI.Paths == nil {
return nil, errors.New("paths not found in supabase openapi")
}
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)
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
}