mirror of
https://github.com/lunchcat/sif.git
synced 2026-07-28 14:37:01 -07:00
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>
This commit is contained in:
@@ -35,8 +35,11 @@ import (
|
||||
"github.com/vmfunc/sif/internal/output"
|
||||
)
|
||||
|
||||
// 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.
|
||||
@@ -77,7 +80,7 @@ func GetPagesRouterScripts(scriptUrl string, timeout time.Duration) ([]string, e
|
||||
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
|
||||
|
||||
@@ -21,11 +21,39 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
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", 5*time.Second)
|
||||
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))
|
||||
@@ -50,6 +78,62 @@ func TestGetPagesRouterScriptsReadsPastLongLine(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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", 5*time.Second)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPagesRouterScriptsHonorsTimeout(t *testing.T) {
|
||||
// a slow manifest host must not hang the scan: the fetch has to give up
|
||||
// once the caller's timeout elapses instead of reading with no deadline.
|
||||
|
||||
Reference in New Issue
Block a user