diff --git a/internal/scan/js/frameworks/next.go b/internal/scan/js/frameworks/next.go index 86e76009..b9ca8219 100644 --- a/internal/scan/js/frameworks/next.go +++ b/internal/scan/js/frameworks/next.go @@ -34,8 +34,11 @@ import ( "github.com/vmfunc/sif/internal/httpx" ) -// nextPagesRegex matches JavaScript file references in Next.js build manifest. -var nextPagesRegex = regexp.MustCompile(`\[("([^"]+\.js)"(,?))`) +// nextPagesRegex matches chunk paths in a Next.js build manifest. anchoring +// on the opening bracket dropped every chunk but the first in a route's +// array; matching any quoted .js literal instead would pull in non-chunk +// strings like __rewrites destinations, so we require the static/ chunk shape. +var nextPagesRegex = regexp.MustCompile(`"(static(?:\\u002[fF]|/)[^"]+\.js)"`) // maxManifestSize caps the build manifest read so a huge or hostile file // cannot exhaust memory. @@ -76,7 +79,7 @@ func GetPagesRouterScripts(scriptUrl string) ([]string, error) { var scripts []string for _, el := range list { - var script = strings.ReplaceAll(el[2], "\\u002F", "/") + var script = strings.ReplaceAll(el[1], "\\u002F", "/") url, err := urlutil.Parse(script) if err != nil { continue diff --git a/internal/scan/js/frameworks/next_test.go b/internal/scan/js/frameworks/next_test.go index f22ddb62..d025fae7 100644 --- a/internal/scan/js/frameworks/next_test.go +++ b/internal/scan/js/frameworks/next_test.go @@ -20,11 +20,39 @@ import ( "testing" ) +func TestGetPagesRouterScriptsCapturesAllChunksPerRoute(t *testing.T) { + // a route array can list several chunks; every one is a real script to scan, + // not just the first element after the opening bracket. + manifest := `self.__BUILD_MANIFEST={"/":["static/chunks/pages/index-a.js","static/chunks/shared-b.js"]}` + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Write([]byte(manifest)) + })) + defer srv.Close() + + scripts, err := GetPagesRouterScripts(srv.URL + "/_buildManifest.js") + if err != nil { + t.Fatalf("GetPagesRouterScripts: %v", err) + } + + found := func(needle string) bool { + for _, s := range scripts { + if strings.Contains(s, needle) { + return true + } + } + return false + } + if !found("index-a.js") || !found("shared-b.js") { + t.Errorf("want both chunks index-a.js and shared-b.js, got %v", scripts) + } +} + func TestGetPagesRouterScriptsReadsPastLongLine(t *testing.T) { // a manifest token past bufio's 64k cap must not truncate the read and // drop the script references that follow it. huge := strings.Repeat("x", bufio.MaxScanTokenSize+1) - manifest := `["early.js"]` + "\n" + huge + "\n" + `["late.js"]` + manifest := `["static/early.js"]` + "\n" + huge + "\n" + `["static/late.js"]` srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Write([]byte(manifest)) @@ -48,3 +76,59 @@ func TestGetPagesRouterScriptsReadsPastLongLine(t *testing.T) { t.Errorf("want both early.js and late.js, got %v", scripts) } } + +func TestGetPagesRouterScriptsRealisticManifest(t *testing.T) { + // routes map to multi-chunk arrays, shared chunks are IIFE args, and + // non-chunk .js strings appear in __rewrites and sortedPages; only the + // former may end up in scripts, or a rewrite destination could steer a fetch. + manifest := `self.__BUILD_MANIFEST=(function(a,b,c){return{` + + `__rewrites:{afterFiles:[{"source":"/proxy/legacy.js","destination":"https://cdn.evil.example/tracker.js"}],beforeFiles:[],fallback:[]},` + + `"/":[a,b,"static/chunks/pages/index-1a2b.js"],` + + `"/_error":[a,"static/chunks/pages/_error-3c4d.js"],` + + `"/blog/[slug]":[a,b,c,"static/chunks/pages/blog/[slug]-5e6f.js"],` + + `sortedPages:["/","/_app","/_error","/blog/[slug]"],` + + `ampFirstPages:[]` + + `}}("static/chunks/webpack-9f8e.js","static/chunks/main-0d1c.js","static/chunks/framework-2b3a.js"));` + + `self.__BUILD_MANIFEST_CB&&self.__BUILD_MANIFEST_CB();` + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Write([]byte(manifest)) + })) + defer srv.Close() + + scripts, err := GetPagesRouterScripts(srv.URL + "/_buildManifest.js") + if err != nil { + t.Fatalf("GetPagesRouterScripts: %v", err) + } + + found := func(needle string) bool { + for _, s := range scripts { + if strings.Contains(s, needle) { + return true + } + } + return false + } + + // every real chunk, including the trailing IIFE-arg shared chunks + wantChunks := []string{ + "static/chunks/pages/index-1a2b.js", + "static/chunks/pages/_error-3c4d.js", + "static/chunks/pages/blog/[slug]-5e6f.js", + "static/chunks/webpack-9f8e.js", + "static/chunks/main-0d1c.js", + "static/chunks/framework-2b3a.js", + } + for _, c := range wantChunks { + if !found(c) { + t.Errorf("missing chunk %q, got %v", c, scripts) + } + } + + // no non-chunk .js string may leak into the fetch list + for _, bad := range []string{"legacy.js", "tracker.js", "cdn.evil.example"} { + if found(bad) { + t.Errorf("false positive: captured non-chunk %q in %v", bad, scripts) + } + } +}