Files
AetherForge/server/internal/recon/upload_hunter.go
AetherForge 6859d79552 Add recon upload hunter and admin surface probing for owned-target scans.
Extend web crawl with multipart/drag-drop/JS upload ranking and probe common admin paths for 200 vs 401/403 signals in scan JSON.
2026-06-07 12:20:04 -07:00

253 lines
6.3 KiB
Go

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)
}
}