Skip to content

Commit 6500b08

Browse files
TBX3Dvmfunc
andauthored
fix(scan): capture every chunk in next.js build manifest arrays (#345)
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. Co-authored-by: vmfunc <vmfunc.lc@gmail.com>
1 parent d52cd84 commit 6500b08

2 files changed

Lines changed: 91 additions & 4 deletions

File tree

internal/scan/js/frameworks/next.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,11 @@ import (
3535
"github.com/vmfunc/sif/internal/output"
3636
)
3737

38-
// nextPagesRegex matches JavaScript file references in Next.js build manifest.
39-
var nextPagesRegex = regexp.MustCompile(`\[("([^"]+\.js)"(,?))`)
38+
// nextPagesRegex matches chunk paths in a Next.js build manifest. anchoring
39+
// on the opening bracket dropped every chunk but the first in a route's
40+
// array; matching any quoted .js literal instead would pull in non-chunk
41+
// strings like __rewrites destinations, so we require the static/ chunk shape.
42+
var nextPagesRegex = regexp.MustCompile(`"(static(?:\\u002[fF]|/)[^"]+\.js)"`)
4043

4144
// maxManifestSize caps the build manifest read so a huge or hostile file
4245
// cannot exhaust memory.
@@ -77,7 +80,7 @@ func GetPagesRouterScripts(scriptUrl string, timeout time.Duration) ([]string, e
7780
var scripts []string
7881

7982
for _, el := range list {
80-
var script = strings.ReplaceAll(el[2], "\\u002F", "/")
83+
var script = strings.ReplaceAll(el[1], "\\u002F", "/")
8184
url, err := urlutil.Parse(script)
8285
if err != nil {
8386
continue

internal/scan/js/frameworks/next_test.go

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,39 @@ import (
2121
"time"
2222
)
2323

24+
func TestGetPagesRouterScriptsCapturesAllChunksPerRoute(t *testing.T) {
25+
// a route array can list several chunks; every one is a real script to scan,
26+
// not just the first element after the opening bracket.
27+
manifest := `self.__BUILD_MANIFEST={"/":["static/chunks/pages/index-a.js","static/chunks/shared-b.js"]}`
28+
29+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
30+
w.Write([]byte(manifest))
31+
}))
32+
defer srv.Close()
33+
34+
scripts, err := GetPagesRouterScripts(srv.URL+"/_buildManifest.js", 5*time.Second)
35+
if err != nil {
36+
t.Fatalf("GetPagesRouterScripts: %v", err)
37+
}
38+
39+
found := func(needle string) bool {
40+
for _, s := range scripts {
41+
if strings.Contains(s, needle) {
42+
return true
43+
}
44+
}
45+
return false
46+
}
47+
if !found("index-a.js") || !found("shared-b.js") {
48+
t.Errorf("want both chunks index-a.js and shared-b.js, got %v", scripts)
49+
}
50+
}
51+
2452
func TestGetPagesRouterScriptsReadsPastLongLine(t *testing.T) {
2553
// a manifest token past bufio's 64k cap must not truncate the read and
2654
// drop the script references that follow it.
2755
huge := strings.Repeat("x", bufio.MaxScanTokenSize+1)
28-
manifest := `["early.js"]` + "\n" + huge + "\n" + `["late.js"]`
56+
manifest := `["static/early.js"]` + "\n" + huge + "\n" + `["static/late.js"]`
2957

3058
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
3159
w.Write([]byte(manifest))
@@ -50,6 +78,62 @@ func TestGetPagesRouterScriptsReadsPastLongLine(t *testing.T) {
5078
}
5179
}
5280

81+
func TestGetPagesRouterScriptsRealisticManifest(t *testing.T) {
82+
// routes map to multi-chunk arrays, shared chunks are IIFE args, and
83+
// non-chunk .js strings appear in __rewrites and sortedPages; only the
84+
// former may end up in scripts, or a rewrite destination could steer a fetch.
85+
manifest := `self.__BUILD_MANIFEST=(function(a,b,c){return{` +
86+
`__rewrites:{afterFiles:[{"source":"/proxy/legacy.js","destination":"https://cdn.evil.example/tracker.js"}],beforeFiles:[],fallback:[]},` +
87+
`"/":[a,b,"static/chunks/pages/index-1a2b.js"],` +
88+
`"/_error":[a,"static/chunks/pages/_error-3c4d.js"],` +
89+
`"/blog/[slug]":[a,b,c,"static/chunks/pages/blog/[slug]-5e6f.js"],` +
90+
`sortedPages:["/","/_app","/_error","/blog/[slug]"],` +
91+
`ampFirstPages:[]` +
92+
`}}("static/chunks/webpack-9f8e.js","static/chunks/main-0d1c.js","static/chunks/framework-2b3a.js"));` +
93+
`self.__BUILD_MANIFEST_CB&&self.__BUILD_MANIFEST_CB();`
94+
95+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
96+
w.Write([]byte(manifest))
97+
}))
98+
defer srv.Close()
99+
100+
scripts, err := GetPagesRouterScripts(srv.URL+"/_buildManifest.js", 5*time.Second)
101+
if err != nil {
102+
t.Fatalf("GetPagesRouterScripts: %v", err)
103+
}
104+
105+
found := func(needle string) bool {
106+
for _, s := range scripts {
107+
if strings.Contains(s, needle) {
108+
return true
109+
}
110+
}
111+
return false
112+
}
113+
114+
// every real chunk, including the trailing IIFE-arg shared chunks
115+
wantChunks := []string{
116+
"static/chunks/pages/index-1a2b.js",
117+
"static/chunks/pages/_error-3c4d.js",
118+
"static/chunks/pages/blog/[slug]-5e6f.js",
119+
"static/chunks/webpack-9f8e.js",
120+
"static/chunks/main-0d1c.js",
121+
"static/chunks/framework-2b3a.js",
122+
}
123+
for _, c := range wantChunks {
124+
if !found(c) {
125+
t.Errorf("missing chunk %q, got %v", c, scripts)
126+
}
127+
}
128+
129+
// no non-chunk .js string may leak into the fetch list
130+
for _, bad := range []string{"legacy.js", "tracker.js", "cdn.evil.example"} {
131+
if found(bad) {
132+
t.Errorf("false positive: captured non-chunk %q in %v", bad, scripts)
133+
}
134+
}
135+
}
136+
53137
func TestGetPagesRouterScriptsHonorsTimeout(t *testing.T) {
54138
// a slow manifest host must not hang the scan: the fetch has to give up
55139
// once the caller's timeout elapses instead of reading with no deadline.

0 commit comments

Comments
 (0)