Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions internal/scan/js/endpoints.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,13 @@ var mimePrefixes = []string{
"application/", "multipart/", "model/", "message/",
}

// regexFlagLiterals are quoted regex flags (e.g. `str.replace("/gi", x)`) that
// the endpoint regex mistakes for slash-prefixed paths. Denylisted by exact
// match, not a length bump, so short real endpoints like /v1 still extract.
var regexFlagLiterals = map[string]struct{}{
"/gi": {}, "/gm": {}, "/gs": {}, "/gu": {}, "/gy": {}, "/mi": {}, "/su": {},
}

// ExtractEndpoints pulls candidate paths and urls out of a script body, dedupes
// them, drops obvious noise, and resolves relatives against baseURL so callers
// get absolute targets where possible. a baseURL that won't parse just leaves
Expand Down Expand Up @@ -91,6 +98,10 @@ func isEndpoint(s string) bool {
}

lower := strings.ToLower(s)
if _, ok := regexFlagLiterals[lower]; ok {
return false
}

for i := 0; i < len(mimePrefixes); i++ {
// a mime type is "type/subtype" with no further path; an api route like
// /application/users has a leading slash, so anchor on the bare prefix.
Expand Down
68 changes: 68 additions & 0 deletions internal/scan/js/probe2_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package js

import (
"testing"
"time"
)

func TestProbe_NoPanicOnBinary(t *testing.T) {
inputs := [][]byte{
{}, {0x00}, {0xff, 0xfe, 0xfd},
[]byte("ey."), []byte(`"ey.a"`), []byte(`"eyJ.a.b"`),
[]byte("\xff\xfe invalid utf8 \x80\x81 token=\"x\""),
[]byte(`"//"`), []byte(`"/"`), []byte(`"./"`),
}
// binary with an embedded jwt-ish token to reach the decode path
inputs = append(inputs, []byte(`x="ey`+"\xff\xff"+`.aa.bb"`))
for _, in := range inputs {
s := string(in)
func() {
defer func() {
if r := recover(); r != nil {
t.Errorf("panic on %q: %v", s, r)
}
}()
_ = ExtractEndpoints(s, "https://example.com/a.js")
_ = ScanSecrets(s, "https://example.com/a.js")
_, _ = ScanSupabase(s, "https://example.com/a.js", time.Second)
}()
}
t.Log("no panics across binary/truncated/multibyte/empty inputs")
}

// regression: the 7 quoted regex-flag literals must never be reported as
// endpoints, but short real endpoints in the same shape (/xx or /xxx) must
// still extract normally.
func TestRegexFlagEndpointsNotReported(t *testing.T) {
fps := []string{"/gi", "/gm", "/gs", "/gu", "/gy", "/mi", "/su"}
for _, f := range fps {
got := ExtractEndpoints(`x.replace("`+f+`","")`, "https://ex.com/a.js")
if len(got) > 0 {
t.Errorf("regex-flag literal %q wrongly reported as endpoint: %v", f, got)
}
}
}

func TestShortRealEndpointsStillExtracted(t *testing.T) {
cases := []struct {
content string
want string
}{
{`fetch("/v1")`, "https://ex.com/v1"},
{`fetch("/me")`, "https://ex.com/me"},
{`fetch("/ws")`, "https://ex.com/ws"},
{`fetch("/db")`, "https://ex.com/db"},
}
for _, c := range cases {
got := ExtractEndpoints(c.content, "https://ex.com/a.js")
found := false
for _, e := range got {
if e == c.want {
found = true
}
}
if !found {
t.Errorf("expected %q preserved as an endpoint, got %v", c.want, got)
}
}
}
52 changes: 52 additions & 0 deletions internal/scan/js/probe_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package js

import (
"regexp"
"strings"
"testing"
)

func TestProbe_EndpointFalsePositives(t *testing.T) {
cases := []struct {
name string
content string
}{
{"regex literal flags", `str.replace("/gi", x)`},
{"date format string", `format(d, "dd/mm/yyyy")`},
{"regex char class", `"[a-z]/g"`},
{"math expression in string", `"1/2 cup"`},
{"comment prose", `// see docs at ./foo/bar for details`},
}
for _, c := range cases {
got := ExtractEndpoints(c.content, "https://example.com/app.js")
t.Logf("%-24s input=%-40q => %v", c.name, c.content, got)
}
// log (don't fail) whether the regex-literal FP is present
got := ExtractEndpoints(`x.replace("/gi", "")`, "https://example.com/a.js")
found := false
for _, e := range got {
if strings.Contains(e, "/gi") {
found = true
}
}
if found {
t.Log("CONFIRMED: '/gi' regex-flag literal reported as an endpoint (false positive)")
} else {
t.Log("note: /gi not reported")
}
}

// note: a RE2-backtracking probe was removed here: Go's regexp is RE2 and
// linear by construction, so a wall-clock timing assertion added only flake
// under -race load, not coverage.

// version/entropy extraction is not in the js pkg; this documents the shape
// of the aws-secret capture group instead.
func TestProbe_AwsSecretCaptureGroup(t *testing.T) {
re := regexp.MustCompile(`\b((?:aws_secret_access_key|aws_secret|secret_key)["']?\s*[:=]\s*["']?)([A-Za-z0-9/+]{40})\b`)
m := re.FindStringSubmatch(`aws_secret_access_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"`)
if m == nil {
t.Fatal("no match")
}
t.Logf("group2 (reported value) = %q", m[2])
}
2 changes: 2 additions & 0 deletions internal/scan/js/scan.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,12 +95,14 @@ func JavascriptScan(url string, timeout time.Duration, threads int, logdir strin

doc, err := htmlquery.Parse(io.LimitReader(resp.Body, maxHTMLBodySize))
if err != nil {
spin.Stop()
return nil, err
}

var scripts []string
nodes, err := htmlquery.QueryAll(doc, "//script/@src")
if err != nil {
spin.Stop()
return nil, err
}
for _, node := range nodes {
Expand Down
15 changes: 9 additions & 6 deletions internal/scan/js/supabase.go
Original file line number Diff line number Diff line change
Expand Up @@ -203,15 +203,16 @@ func ScanSupabase(jsContent string, jsUrl string, timeout time.Duration) ([]supa
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 {
resp.Body.Close()
return nil, err
supabaselog.Errorf("Failed to read signup response for %s: %s", *supabaseJwt.ProjectId, err)
continue
}
resp.Body.Close()

var authResp supabaseAuthResponse
if err := json.Unmarshal(body, &authResp); err != nil {
return nil, err
supabaselog.Errorf("Failed to parse signup response for %s: %s", *supabaseJwt.ProjectId, err)
continue
}

auth = authResp.AccessToken
Expand All @@ -225,11 +226,13 @@ func ScanSupabase(jsContent string, jsUrl string, timeout time.Duration) ([]supa

openAPI, err := getSupabaseOpenAPI(*supabaseJwt.ProjectId, jwt, &auth, timeout)
if err != nil {
return nil, err
supabaselog.Errorf("Failed to fetch openapi spec for %s: %s", *supabaseJwt.ProjectId, err)
continue
}

if openAPI.Paths == nil {
return nil, errors.New("paths not found in supabase openapi")
supabaselog.Errorf("No paths found in supabase openapi for %s", *supabaseJwt.ProjectId)
continue
}

for path := range openAPI.Paths {
Expand Down
136 changes: 136 additions & 0 deletions internal/scan/js/supabase_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/

package js

import (
"encoding/base64"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
"time"
)

// supabaseTestRoundTripper redirects *.supabase.co requests to a local
// httptest server, tagging the original host in a header so the fake handler
// can tell projects apart and ScanSupabase's real code path can be exercised.
type supabaseTestRoundTripper struct {
orig http.RoundTripper
target *url.URL
}

func (rt *supabaseTestRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
if strings.HasSuffix(req.URL.Host, ".supabase.co") {
req = req.Clone(req.Context())
req.Header.Set("X-Test-Orig-Host", req.URL.Host)
req.URL.Scheme = rt.target.Scheme
req.URL.Host = rt.target.Host
req.Host = rt.target.Host
}
return rt.orig.RoundTrip(req)
}

// withFakeSupabase points every *.supabase.co request at handler for the
// duration of the test, restoring the real default transport after.
func withFakeSupabase(t *testing.T, handler http.HandlerFunc) {
t.Helper()
server := httptest.NewServer(handler)
t.Cleanup(server.Close)

target, err := url.Parse(server.URL)
if err != nil {
t.Fatalf("parse test server url: %v", err)
}

origTransport := http.DefaultTransport
http.DefaultTransport = &supabaseTestRoundTripper{orig: origTransport, target: target}
t.Cleanup(func() { http.DefaultTransport = origTransport })
}

// makeJWT builds a header.payload.sig token whose payload base64url-encodes
// refJSON, mirroring what supabase.go's jwtRegex and decode step expect.
func makeJWT(refJSON string) string {
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`))
payload := base64.RawURLEncoding.EncodeToString([]byte(refJSON))
sig := base64.RawURLEncoding.EncodeToString([]byte("signaturesignature"))
return header + "." + payload + "." + sig
}

// projectHandler dispatches requests for a single project. openAPIStatus lets
// a case force the openapi fetch to fail (simulating a 500 or bad upstream).
func projectHandler(openAPIStatus int) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/auth/v1/signup":
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"access_token":"tok"}`))
case "/rest/v1/":
if openAPIStatus != http.StatusOK {
w.WriteHeader(openAPIStatus)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"paths":{"/items":{}}}`))
case "/rest/v1/items":
w.Header().Set("Content-Range", "0-1/2")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`[{"a":1},{"a":2}]`))
default:
w.WriteHeader(http.StatusNotFound)
}
}
}

// regression: one project's openapi fetch failing must not discard findings
// already collected for another project in the same scan.
func TestScanSupabase_PartialFailureAccumulates(t *testing.T) {
var mu sync.Mutex
good := projectHandler(http.StatusOK)
bad := projectHandler(http.StatusInternalServerError)

withFakeSupabase(t, func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
defer mu.Unlock()
switch r.Header.Get("X-Test-Orig-Host") {
case "proja.supabase.co":
good(w, r)
case "projb.supabase.co":
bad(w, r)
default:
w.WriteHeader(http.StatusNotFound)
}
})

jwtA := makeJWT(`{"ref":"proja","role":"anon"}`)
jwtB := makeJWT(`{"ref":"projb","role":"anon"}`)
content := `const A = "` + jwtA + `"; const B = "` + jwtB + `";`

results, err := ScanSupabase(content, "https://example.com/app.js", 5*time.Second)
if err != nil {
t.Fatalf("ScanSupabase returned an error, should have skipped the bad project instead: %v", err)
}
if len(results) != 1 {
t.Fatalf("expected 1 surviving result (proja), got %d: %+v", len(results), results)
}
if results[0].ProjectId != "proja" {
t.Fatalf("expected proja to survive, got %q", results[0].ProjectId)
}
if len(results[0].Collections) != 1 || results[0].Collections[0].Name != "items" {
t.Fatalf("expected proja's items collection intact, got %+v", results[0].Collections)
}
}
Loading