From 6759a9f9e70af6a0804168a6a112e5d0debe6aa9 Mon Sep 17 00:00:00 2001 From: TBX3D <88289044+TBX3D@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:20:12 -0700 Subject: [PATCH] fix(scan): capture every chunk in next.js build manifest arrays the manifest maps each route to an array of chunk paths, but the regex anchored on the opening bracket so only the first .js literal per array was captured, dropping the remaining chunks from the script list. match each quoted chunk path instead, scoped to the relative static/ shape (literal or escaped slash) so non-chunk .js strings such as __rewrites destinations, which can be attacker-controlled absolute urls, are not pulled into the fetch list. --- internal/scan/js/frameworks/next.go | 9 ++- internal/scan/js/frameworks/next_test.go | 86 +++++++++++++++++++++++- 2 files changed, 91 insertions(+), 4 deletions(-) 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) + } + } +}