package recon import ( "bytes" "io" "strings" "golang.org/x/net/html" ) var urlFieldHints = []string{ "url", "link", "preview", "webhook", "fetch", "import", "oembed", "image", } var cmsPathMarkers = []struct { path string tag string }{ {"/wp-admin", "wordpress"}, {"/wp-content", "wordpress"}, {"/strapi", "strapi"}, {"/graphql", "graphql"}, {"/admin/login", "admin_login"}, } // ParseHTML extracts upload forms, URL fields, SSRF score, and CMS hints from HTML. func ParseHTML(pageURL, body string) (fileInputs, multipart []FormFinding, urlFields []URLFieldFinding, ssrfScore int, cms []string) { root, err := html.Parse(strings.NewReader(body)) if err != nil { return nil, nil, nil, 0, cmsFromText(pageURL, body) } title := extractTitle(root) _ = title var walk func(*html.Node) walk = func(n *html.Node) { if n.Type == html.ElementNode && n.Data == "form" { form := parseForm(pageURL, n) if form.HasFile { fileInputs = append(fileInputs, form) } if form.Multipart { multipart = append(multipart, form) } ssrfScore += scoreForm(form) } if n.Type == html.ElementNode && n.Data == "input" { if field := parseURLField(pageURL, n); field != nil { urlFields = append(urlFields, *field) ssrfScore += 10 } } if n.Type == html.ElementNode && (n.Data == "textarea" || n.Data == "select") { if field := parseURLFieldFromNamed(pageURL, attr(n, "name"), attr(n, "id"), attr(n, "placeholder")); field != nil { urlFields = append(urlFields, *field) ssrfScore += 8 } } for c := n.FirstChild; c != nil; c = c.NextSibling { walk(c) } } walk(root) cms = cmsFromText(pageURL, body) if ssrfScore > 100 { ssrfScore = 100 } return fileInputs, multipart, urlFields, ssrfScore, cms } func parseForm(pageURL string, form *html.Node) FormFinding { f := FormFinding{ PageURL: pageURL, Action: attr(form, "action"), Method: strings.ToLower(attr(form, "method")), Enctype: strings.ToLower(attr(form, "enctype")), } if f.Method == "" { f.Method = "get" } if strings.Contains(f.Enctype, "multipart") { f.Multipart = true } for c := form.FirstChild; c != nil; c = c.NextSibling { collectFormFields(c, &f) } return f } func collectFormFields(n *html.Node, f *FormFinding) { if n.Type == html.ElementNode { switch n.Data { case "input", "textarea", "select": name := attr(n, "name") if name != "" { f.Fields = append(f.Fields, name) } if n.Data == "input" && strings.EqualFold(attr(n, "type"), "file") { f.HasFile = true f.Multipart = true } if field := parseURLField(f.PageURL, n); field != nil { f.Fields = append(f.Fields, field.Name+"("+field.Hint+")") } } } for c := n.FirstChild; c != nil; c = c.NextSibling { collectFormFields(c, f) } } func parseURLField(pageURL string, n *html.Node) *URLFieldFinding { name := attr(n, "name") id := attr(n, "id") placeholder := attr(n, "placeholder") return parseURLFieldFromNamed(pageURL, name, id, placeholder) } func parseURLFieldFromNamed(pageURL, name, id, placeholder string) *URLFieldFinding { joined := strings.ToLower(strings.Join([]string{name, id, placeholder}, " ")) for _, hint := range urlFieldHints { if strings.Contains(joined, hint) { label := name if label == "" { label = id } return &URLFieldFinding{ PageURL: pageURL, Name: label, ID: id, Type: "text", Hint: hint, } } } return nil } func scoreForm(f FormFinding) int { score := 0 action := strings.ToLower(f.Action) for _, hint := range urlFieldHints { if strings.Contains(action, hint) { score += 15 } } for _, field := range f.Fields { lower := strings.ToLower(field) for _, hint := range urlFieldHints { if strings.Contains(lower, hint) { score += 5 } } } if f.HasFile { score += 5 } return score } func cmsFromText(pageURL, body string) []string { seen := map[string]bool{} var out []string lowerURL := strings.ToLower(pageURL) lowerBody := strings.ToLower(body) add := func(tag string) { if tag == "" || seen[tag] { return } seen[tag] = true out = append(out, tag) } for _, marker := range cmsPathMarkers { if strings.Contains(lowerURL, marker.path) || strings.Contains(lowerBody, marker.path) { add(marker.tag) } } if strings.Contains(lowerBody, "strapi") { add("strapi") } if strings.Contains(lowerBody, "wp-content") || strings.Contains(lowerBody, "wordpress") { add("wordpress") } return out } func extractTitle(root *html.Node) string { var title string var walk func(*html.Node) walk = func(n *html.Node) { if n.Type == html.ElementNode && n.Data == "title" && n.FirstChild != nil { title = strings.TrimSpace(n.FirstChild.Data) return } for c := n.FirstChild; c != nil; c = c.NextSibling { walk(c) } } walk(root) return title } func attr(n *html.Node, key string) string { for _, a := range n.Attr { if a.Key == key { return a.Val } } return "" } func readBodyLimited(r io.Reader, max int64) (string, error) { buf := new(bytes.Buffer) _, err := io.Copy(buf, io.LimitReader(r, max)) if err != nil { return "", err } return buf.String(), nil }