Skip to content

Commit 8598e26

Browse files
committed
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.
1 parent 7ea1cd2 commit 8598e26

2 files changed

Lines changed: 95 additions & 4 deletions

File tree

internal/scan/js/frameworks/next.go

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,13 @@ import (
3434
"github.com/vmfunc/sif/internal/httpx"
3535
)
3636

37-
// nextPagesRegex matches JavaScript file references in Next.js build manifest.
38-
var nextPagesRegex = regexp.MustCompile(`\[("([^"]+\.js)"(,?))`)
37+
// nextPagesRegex matches chunk paths in a Next.js build manifest. each route
38+
// maps to an array of chunk paths, so anchoring on the opening bracket dropped
39+
// every chunk but the first. matching every quoted .js literal instead would
40+
// also pull in non-chunk strings such as __rewrites destinations (potentially
41+
// an attacker-controlled absolute URL), so require the relative static/ chunk
42+
// shape, allowing the slash to appear literal or as its / escape.
43+
var nextPagesRegex = regexp.MustCompile(`"(static(?:\\u002[fF]|/)[^"]+\.js)"`)
3944

4045
// maxManifestSize caps the build manifest read so a huge or hostile file
4146
// cannot exhaust memory.
@@ -76,7 +81,7 @@ func GetPagesRouterScripts(scriptUrl string) ([]string, error) {
7681
var scripts []string
7782

7883
for _, el := range list {
79-
var script = strings.ReplaceAll(el[2], "\\u002F", "/")
84+
var script = strings.ReplaceAll(el[1], "\\u002F", "/")
8085
url, err := urlutil.Parse(script)
8186
if err != nil {
8287
continue

internal/scan/js/frameworks/next_test.go

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,39 @@ import (
2020
"testing"
2121
)
2222

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

2957
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
3058
w.Write([]byte(manifest))
@@ -48,3 +76,61 @@ func TestGetPagesRouterScriptsReadsPastLongLine(t *testing.T) {
4876
t.Errorf("want both early.js and late.js, got %v", scripts)
4977
}
5078
}
79+
80+
func TestGetPagesRouterScriptsRealisticManifest(t *testing.T) {
81+
// a realistic pages-router _buildManifest.js: routes map to multi-chunk
82+
// arrays, shared chunks are passed as IIFE args, and non-chunk .js strings
83+
// appear in __rewrites and sortedPages. every chunk must be captured and no
84+
// non-chunk .js string may be, so a hostile manifest cannot steer a fetch to
85+
// an arbitrary url through a rewrite destination.
86+
manifest := `self.__BUILD_MANIFEST=(function(a,b,c){return{` +
87+
`__rewrites:{afterFiles:[{"source":"/proxy/legacy.js","destination":"https://cdn.evil.example/tracker.js"}],beforeFiles:[],fallback:[]},` +
88+
`"/":[a,b,"static/chunks/pages/index-1a2b.js"],` +
89+
`"/_error":[a,"static/chunks/pages/_error-3c4d.js"],` +
90+
`"/blog/[slug]":[a,b,c,"static/chunks/pages/blog/[slug]-5e6f.js"],` +
91+
`sortedPages:["/","/_app","/_error","/blog/[slug]"],` +
92+
`ampFirstPages:[]` +
93+
`}}("static/chunks/webpack-9f8e.js","static/chunks/main-0d1c.js","static/chunks/framework-2b3a.js"));` +
94+
`self.__BUILD_MANIFEST_CB&&self.__BUILD_MANIFEST_CB();`
95+
96+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
97+
w.Write([]byte(manifest))
98+
}))
99+
defer srv.Close()
100+
101+
scripts, err := GetPagesRouterScripts(srv.URL + "/_buildManifest.js")
102+
if err != nil {
103+
t.Fatalf("GetPagesRouterScripts: %v", err)
104+
}
105+
106+
found := func(needle string) bool {
107+
for _, s := range scripts {
108+
if strings.Contains(s, needle) {
109+
return true
110+
}
111+
}
112+
return false
113+
}
114+
115+
// every real chunk, including the trailing IIFE-arg shared chunks
116+
wantChunks := []string{
117+
"static/chunks/pages/index-1a2b.js",
118+
"static/chunks/pages/_error-3c4d.js",
119+
"static/chunks/pages/blog/[slug]-5e6f.js",
120+
"static/chunks/webpack-9f8e.js",
121+
"static/chunks/main-0d1c.js",
122+
"static/chunks/framework-2b3a.js",
123+
}
124+
for _, c := range wantChunks {
125+
if !found(c) {
126+
t.Errorf("missing chunk %q, got %v", c, scripts)
127+
}
128+
}
129+
130+
// no non-chunk .js string may leak into the fetch list
131+
for _, bad := range []string{"legacy.js", "tracker.js", "cdn.evil.example"} {
132+
if found(bad) {
133+
t.Errorf("false positive: captured non-chunk %q in %v", bad, scripts)
134+
}
135+
}
136+
}

0 commit comments

Comments
 (0)