package recon import ( "net/url" "regexp" "sort" "strings" ) var ( openUploadPathRe = regexp.MustCompile(`(?i)(/api/[^\s"'<>]*upload|/upload[^\s"'<>]*|/v\d+/upload)`) dragDropClassRe = regexp.MustCompile(`(?i)(dropzone|drop-zone|file-drop|drag-drop|fileupload)`) jsUploadHintRe = regexp.MustCompile(`(?i)(multipart/form-data|formdata\s*\(|type\s*:\s*['"]file['"]|/api/[^\s"'<>]*upload|\.upload\s*\(|dropzone)`) ) func collectUploadFromPage(pageURL string, fileInputs, multipart []FormFinding) []UploadHunterFinding { var out []UploadHunterFinding for _, f := range fileInputs { if !f.HasFile { continue } out = append(out, UploadHunterFinding{PageURL: pageURL, Target: resolveUploadTarget(pageURL, f.Action), Source: "file_input", Method: f.Method}) } for _, f := range multipart { if !f.Multipart { continue } out = append(out, UploadHunterFinding{PageURL: pageURL, Target: resolveUploadTarget(pageURL, f.Action), Source: "multipart", Method: f.Method}) } return out } func detectDragDropZones(pageURL, body string) []UploadHunterFinding { root, err := htmlParseRoot(body) if err != nil { return detectDragDropFromText(pageURL, body) } var out []UploadHunterFinding var walk func(*htmlNode) walk = func(n *htmlNode) { if n.tag != "" { cls := strings.ToLower(n.attr("class")) id := strings.ToLower(n.attr("id")) if dragDropClassRe.MatchString(cls) || dragDropClassRe.MatchString(id) || n.attr("data-dropzone") != "" { target := n.attr("data-upload-url") if target == "" { target = n.attr("action") } out = append(out, UploadHunterFinding{PageURL: pageURL, Target: resolveUploadTarget(pageURL, target), Source: "drag_drop"}) } } for _, c := range n.children { walk(c) } } walk(root) if len(out) == 0 { return detectDragDropFromText(pageURL, body) } return out } func detectDragDropFromText(pageURL, body string) []UploadHunterFinding { lower := strings.ToLower(body) if (!strings.Contains(lower, "dropzone") && !strings.Contains(lower, "drag")) || (!strings.Contains(lower, "upload") && !strings.Contains(lower, "file")) { return nil } return []UploadHunterFinding{{PageURL: pageURL, Source: "drag_drop"}} } func extractScriptSrc(pageURL, body string) []string { root, err := htmlParseRoot(body) if err != nil { return nil } var srcs []string var walk func(*htmlNode) walk = func(n *htmlNode) { if n.tag == "script" { if src := strings.TrimSpace(n.attr("src")); src != "" { srcs = append(srcs, src) } } for _, c := range n.children { walk(c) } } walk(root) _ = pageURL return srcs } func scanJSForUpload(jsURL, body string) []UploadHunterFinding { if !jsUploadHintRe.MatchString(body) { return nil } target := jsURL if m := openUploadPathRe.FindString(body); m != "" { target = m } return []UploadHunterFinding{{PageURL: jsURL, Target: target, Source: "js"}} } func isScriptAsset(ref string) bool { ref = strings.ToLower(strings.TrimSpace(ref)) return strings.HasSuffix(ref, ".js") || strings.Contains(ref, ".js?") } func resolveUploadTarget(pageURL, action string) string { action = strings.TrimSpace(action) if action == "" { if u, err := url.Parse(pageURL); err == nil { return u.Path } return pageURL } if strings.HasPrefix(action, "http://") || strings.HasPrefix(action, "https://") { return action } if abs, err := resolveSameOrigin(pageURL, action); err == nil { return abs } return action } func rankUploadFindings(base string, in []UploadHunterFinding) []UploadHunterFinding { if len(in) == 0 { return nil } seen := map[string]UploadHunterFinding{} for _, f := range in { key := strings.ToLower(f.PageURL + "|" + f.Target + "|" + f.Source) if prev, ok := seen[key]; ok { if scoreUploadFinding(f) > scoreUploadFinding(prev) { seen[key] = f } continue } seen[key] = f } out := make([]UploadHunterFinding, 0, len(seen)) for _, f := range seen { f = tagUploadFinding(base, f) f.Score = scoreUploadFinding(f) out = append(out, f) } sort.Slice(out, func(i, j int) bool { if out[i].Score != out[j].Score { return out[i].Score > out[j].Score } return out[i].Target < out[j].Target }) return out } func tagUploadFinding(base string, f UploadHunterFinding) UploadHunterFinding { target := f.Target if target == "" { target = f.PageURL } path := target if u, err := url.Parse(target); err == nil && u.Path != "" { path = u.Path } if openUploadPathRe.MatchString(path) || openUploadPathRe.MatchString(target) { f.Tags = appendUniqueTag(f.Tags, "open_api") } probeURL := target if !strings.HasPrefix(probeURL, "http://") && !strings.HasPrefix(probeURL, "https://") { if abs, err := resolveSameOrigin(base, probeURL); err == nil { probeURL = abs } else { probeURL = strings.TrimRight(base, "/") + "/" + strings.TrimLeft(probeURL, "/") } } status, _, _, err := fetchPage(probeURL) if err == nil { f.StatusCode = status if status == 200 { f.Tags = appendUniqueTag(f.Tags, "no_auth") } } return f } func scoreUploadFinding(f UploadHunterFinding) int { score := 10 switch f.Source { case "multipart": score += 30 case "file_input": score += 25 case "drag_drop": score += 20 case "js": score += 15 } for _, tag := range f.Tags { switch tag { case "no_auth": score += 40 case "open_api": score += 25 } } if f.StatusCode == 200 { score += 10 } return score } func appendUniqueTag(tags []string, tag string) []string { for _, t := range tags { if t == tag { return tags } } return append(tags, tag) } func finalizeUploadHunter(base string, uploadRaw []UploadHunterFinding, jsQueue []string) []UploadHunterFinding { maxJS := 20 if len(jsQueue) > maxJS { jsQueue = jsQueue[:maxJS] } for _, jsURL := range jsQueue { _, jsBody, _, err := fetchPage(jsURL) if err != nil { continue } uploadRaw = append(uploadRaw, scanJSForUpload(jsURL, jsBody)...) } return rankUploadFindings(base, uploadRaw) } func collectUploadJSAtDepth(base, pageURL, body string, depth, maxDepth int, jsSeen map[string]bool, jsQueue *[]string) { if depth > maxDepth { return } for _, src := range extractScriptSrc(pageURL, body) { abs, err := resolveSameOrigin(base, src) if err != nil || !sameOrigin(base, abs) || !isScriptAsset(src) { continue } key := normalizeURLKey(abs) if jsSeen[key] { continue } jsSeen[key] = true *jsQueue = append(*jsQueue, abs) } }